Validation rules

Before an agent is saved or run, its code passes a deterministic checker: the TypeScript compiler in strict mode, plus a set of Obside rules. The checker never runs your code. It reads it, and it either passes or returns precise errors (with a code, a line and a column) for you to fix. When you connect a coding assistant over MCP, the validate_agent_code tool runs exactly this checker, so you can iterate until it passes before creating anything. The same checker runs again when create_agent receives code.

This page is the contract: what agent code may and may not do, and why. It lists every rule the checker enforces, each with a refused example and the accepted rewrite. If the checker rejects your code, the fix is always to correct the code, never to silence the checker.

There are no warnings: every error, from the compiler or from a rule, blocks the save. validate_agent_code returns { valid: true } or { valid: false, errors: [{ code, message, line, column }] }, with lines and columns counted from the first line of your code. Code larger than 60 KB (UTF-8) is refused before it is compiled.

Every example on this page is a complete agent in the submitted form, and each one is compiled by the real checker as part of Obside's test suite: blocks marked "refused" fail with the code named, plain blocks pass.

What agent code may do

  • Read market data through the manager (getIntervalData, getCurrentPrice, getSymbolMetadata) and build indicators with new.
  • Attach listeners (onIntervalCandle, cron, onNews, and the others) at the root, and act inside their callbacks.
  • Place and cancel orders, open and close trades, manage positions, read the account book, and keep state in getStatus.
  • Send notifications, write journal comments, post to the configured webhook, and call a language model for structured reasoning.
  • Use the z schema helper and the Luxon date classes, which are already in scope.

What agent code may not do

Each of these is rejected with a clear message. The rule catalog below gives the code and an example for each.

  • No imports. Everything you can use is already in scope. Writing import (or require) is an error (TS9007), and it collides with the names the engine already provides.
  • No wall-clock time. new Date(), Date.now(), and DateTime.now() / .local() / .utc() are rejected (TS9010), because they would make backtests non-deterministic. Use manager.getCurrentTime(), or a candle's openTime / closeTime. Build a date from engine time with DateTime.fromMillis(manager.getCurrentTime()).
  • No direct network or I/O. fetch, WebSocket, XMLHttpRequest and similar are rejected (TS9014). Outbound communication goes through the manager (searchNews, llm, onNews, sendWebhook).
  • No timers. setTimeout and setInterval are rejected (TS9006): schedule with manager.cron.
  • No dynamic code. eval and the Function constructor are rejected (TS9015, TS9039), because they would run text as code and defeat the checks.
  • No host environment. The host runtime is not exposed: process (TS9016), the global object, and reflection over the prototype chain (TS9039) are all unavailable. Work with plain values and the manager API. This keeps agent code to a clear, reviewable surface.
  • No silencing the types. as any, a : any annotation, casting through unknown, and the @ts-ignore family are rejected (TS9025). If the compiler says a property does not exist or a value is not assignable, the name or the value is wrong. Fix it.
  • Indicators at the root, actions in callbacks. Constructing an indicator in a callback (TS9001), creating candle data in a callback (TS9002), reading live data or placing an order at the top level (TS9003), and attaching a listener inside another listener (TS9004) are rejected. Set up at the root; read and act inside callbacks.
  • No wrapper. Submit the body only, not async (manager) => { … } (TS9035).

Rules that protect your results

These are correctness rails specific to trading, not general programming errors. The checker catches the most damaging ones.

  • Order sizing. risk and proportion are fractions of capital (0.01 is one percent), and the checker rejects an implausibly large fraction (TS9019). A stop-loss or take-profit value is an absolute price, not a distance.
  • Order denomination. A cash amount order must state its amountCurrency (for example 'USD') (TS9031), and amountCurrency is only valid with amount (TS9032). This keeps a dollar notional meaning the same thing in a backtest and live.
  • Existing cash versus new money. An order sized from the wallet's capital must set inhibitAutoInvest: true (TS9017); a fixed contribution buy must not (TS9030). See Orders, trades and risk.
  • Capital sizing. Reading the whole wallet's value and buying that amount every cycle silently compounds your exposure. The right shape trades each position's difference from its target and clamps every buy by the free cash. The checker flags the runaway shape (TS9038).
  • Time zones. A daily-or-coarser candle listener must state the asset's time zone, so its daily boundary is unambiguous (TS9017).
  • Paid AI calls. A manager.llm call on a schedule or candle must be guarded by a key saved in the status bag, so a repeated tick never pays twice (TS9026).
  • Reading the right bar. A daily series holds completed days only, so inside an intraday callback the last element of its History is yesterday. Reading only an older element takes the day before yesterday for yesterday (TS9040).
  • Exits that work. A take-profit or stop-loss delta is a whole percentage (TS9041), and a stop trailed from the current price only ever tightens (TS9042).
  • Your agent's own position. A demo or live portfolio is shared with your own holdings and your other agents, so a strategy decides "am I in a position?" on its own trades or fills, never on getPositionQty, and sells only what it bought (TS9047). Its own trades exist only if it opens them with openTrade (TS9048).

Why a checker at all

Two reasons. First, safety: agent code runs on Obside's infrastructure, so it is held to a small, reviewable surface with no access to the host or the network except through the manager. Second, reliability: strict typing and the trading rails catch the mistakes that would otherwise turn into a silent zero-return backtest or an order that never fills. The checker is fast and free to run, so treat a rejection as useful feedback and iterate.

How your code is compiled

Compiler settings

Setting Value What it means for you
target ES2020 The standard library is ES2020. Newer methods do not exist on the types (see below). Newer syntax such as ??= is fine: it is compiled down.
module / moduleResolution NodeNext Top-level await is allowed.
strict true Enables the whole strict family: strictNullChecks, noImplicitAny, noImplicitThis, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, alwaysStrict, useUnknownInCatchVariables.
skipLibCheck true Only your code is reported.
lib not set The default library for ES2020 applies, which includes browser (DOM) typings. That is why setTimeout, fetch or window type-check; Obside rules refuse them (TS9006, TS9014, TS9039).
Node.js typings not installed require, process and other Node globals do not exist: they fail with TS2591 on top of the Obside rule.

What strict mode means in practice:

  • A value that can be undefined must be checked before use. manager.getCurrentPrice() returns number | undefined, and indicator.getValue() returns T | undefined unless you narrowed the indicator with hasValue() first.
  • Every parameter of a function you declare needs a type. Callback parameters of manager APIs are typed for you.
  • A variable declared without a type and without an initializer (let last;) becomes an implicit any and is refused when used in a closure.
  • A catch (error) variable is unknown. Narrow it (error instanceof Error) before reading .message.

Options that are NOT enabled: noUncheckedIndexedAccess (so closes[0] is number, not number | undefined), noUnusedLocals, noUnusedParameters, noImplicitReturns. Unused variables do not fail the check.

Two consequences of the code being compiled as a module:

  • Top-level await works: const metadata = await manager.getSymbolMetadata('AAPL');.
  • A top-level return does not: it fails with TS1108. Wrap the code in an if instead.

What ES2020 gives you

These all pass:

manager.cron('0 * * * *', async () => {
    const settled = await Promise.allSettled([manager.searchNews('AAPL Apple')]);
    const pairs = Object.fromEntries([['a', 1]]);
    const flat = [[1], [2]].flat();
    const digits = [...'a1b2'.matchAll(/\d/g)].map((m) => m[0]);
    const big = BigInt(10) * BigInt(2);
    const padded = '7'.padStart(3, '0').trimEnd();
    let counter: number | undefined;
    counter ??= 0;
    const nested: { a?: { b?: number } } = {};
    manager.debugLog(settled.length, pairs, flat, digits, big.toString(), padded, counter, nested.a?.b ?? 0);
});

These are newer than ES2020 and fail with TS2550 ("Do you need to change your target library?"):

Not available Added in Use instead
Array.prototype.at ES2022 arr[arr.length - 1]
String.prototype.replaceAll ES2021 s.replace(/-/g, '_') or s.split('-').join('_')
Object.hasOwn ES2022 ({}).hasOwnProperty.call(obj, key) (and Object.hasOwn is also refused by TS9039)
Array.prototype.findLast / findLastIndex ES2023 [...arr].reverse().find(...) or a reverse for loop

Names already in scope

The following names are pre-declared for your code. Use them directly. Do not import them (TS9007) and do not declare anything with the same name: const RSI = 14 or interface News {} fails. A clash with one of these names is reported as a single error with code 0 whose message names the clashing identifier (see "Redeclaring a pre-declared name" below).

  • manager: the agent API (type V2Manager).
  • Indicators: ADX, BBW, BBWP, BeepBoop, Bollinger, BosNChoch, CCI, Chop, Choppy, ChaikinMoneyFlow, ChandeMomentumOscillator, Crossover, DC, Divergences, DPO, EIT, Elder, EMA, Engulfing, Extreme, FindMarketBottom, FIR, FVG, HalfTrend, HARSI, Heikinashi, HeikinashiCandles, History, HullMovingAverage, Ichimoku, Index, KAMA, KeltnerChannels, KDJ, LinearRegression, Lunar, MACD, MarketStructure, MFI, Momentum, MVWAP, OnBalanceVolume, OrderBlocks, Percentile, PivotPoints, PMAR, PMARP, Renko, RenkoAtr, RMA, ROC, RSI, RSILambda, SAR, SMA, SqueezeMomentum, Stochastic, StrongCandleStructure, SuperTrend, SupportResistance, Swings, TR, TRIX, TTS, VIDYA, VolumeProfile, VWAP, VWMA, WilliamsAlligator, WilliamsFractal, WMA, WilliamsPercentR, ZigZag.
  • Indicator building blocks: CandleIndicator, DerivedIndicator, IteratorIndicator, LambdaIndicator, ManualIndicator, PartialIndicator, PineIndicator.
  • Helpers: updateStoploss, rebalance, ensureSymbolSingleTrade, up, down, calculateFibonacciRetracements, generateStoploss, getStoploss, getStoplossDistance, getStoplossRisk, generateTakeprofit, stoplossKeys, stoplossMainKey, takeprofitKeys.
  • Libraries: DateTime, Duration and Interval from Luxon; z from Zod. Note that Interval is Luxon's class. The engine's candle interval type is named StseInterval.
  • Types: AltDataPoint, ResolveAssetsRequest, ResolvedAsset, Candle, OpenedCandle, Indicator, IDerivedIndicator, ICandleIndicator, StseInterval, LatentData, OrderMetrics, TradeMetrics, CapitalMetrics, SwapFeesMetrics, LatentMetrics, WalletMetrics, ModelMetrics, Metrics, BaseNewsRequest, XNewsRequest, NewsRequest, News, CreateOrder, Order, PieuvreSeverity, PieuvreDataType, PieuvreEventRequest, PieuvreEvent, PieuvreMetricQuery, PieuvreMetric, PieuvreSeriesQuery, PieuvreSeriesPoint, PieuvreRankRequest, PieuvreRankedEntity, PieuvreUpcomingRequest, Position, ProportionPositionSize, RiskPositionSize, AmountPositionSize, QtyPositionSize, PositionSize, V2EnterSignal, LeaveSignal, Status, FixedStoploss, TrailingStoploss, SpecialStoploss, Stoploss, FixedStoplossRequest, SpecialStoplossRequest, TrailingStoplossRequest, StoplossRequest, StoplossKey, SymbolMetadata, FixedTakeprofit, TriggerTakeprofit, TrailingTakeprofit, Takeprofit, FixedTakeprofitRequest, TriggerTakeprofitRequest, TrailingTakeprofitRequest, TakeprofitRequest, TakeprofitKey, Exit, Slippage, Trade, V2Manager.

Obside rules (TS90xx)

Vocabulary

  • Root scope: the top level of your code, which runs once when the agent is built.
  • Callback: the function you pass to one of the listener methods: onPreciseCandle, onIntervalCandleOpen, onIntervalCandle, onAltData, onAnyData, cron, onTradeFilled, onTradeFinished, onOrderExecuted, onOrderCanceled, onNews, onPieuvreEvent, onMessage.
  • Periodic callback: a callback that fires on a clock or on market data rather than on a specific event: onPreciseCandle, onIntervalCandleOpen, onIntervalCandle, onAnyData, cron.
  • Scope rules follow calls: a helper function called from a callback counts as running in that callback, and a helper called from the root counts as running at the root. The message then names the helper.

Live codes

Code Rule
TS9001 Indicators are created at the root
TS9002 getIntervalData (and getSymbolMetadata in periodic callbacks) is called at the root
TS9003 Live-data and trading calls run inside a callback
TS9004 Listeners are attached at the root
TS9006 No setTimeout / setInterval
TS9007 No import / require / import()
TS9008 manager cannot be redeclared or reassigned
TS9010 No wall-clock time
TS9014 No direct network access
TS9015 No eval / Function
TS9016 No process
TS9017 Daily-or-higher interval listeners pass a time zone; capital-sized orders set inhibitAutoInvest: true (two rules share this code)
TS9018 updateStoploss receives the trade's own stop
TS9019 Position size is a plausible fraction of capital
TS9020 Variable names are string literals
TS9021 Variable defaults are literals
TS9022 One default per variable
TS9023 At most 16 variables
TS9024 getVariable is called directly
TS9025 No any, no as unknown, no @ts-ignore
TS9026 LLM calls in periodic callbacks are guarded by a stored key
TS9027 getPieuvreRank requests use known fields and values
TS9028 Every getVariable mention yields a variable
TS9029 handleError receives an error, not a promise
TS9030 A contribution buy does not set inhibitAutoInvest: true
TS9031 Cash-sized orders state amountCurrency
TS9032 amountCurrency only with amount
TS9033 Your functions type their parameters
TS9034 getStatus takes a state type when you read values
TS9035 No wrapper function around the code
TS9036 Whole-number indicator arguments get whole-number defaults
TS9037 Order payloads are visible to the check
TS9038 Rebalancing buys are clamped by a cash budget
TS9039 No access to the host environment
TS9040 A daily-or-higher History is read at its last element for the previous bar
TS9041 A take-profit or stop-loss delta is a whole number from 0 to 100
TS9042 A stop trailed from the current price only tightens
TS9047 A strategy decides and sizes its exits on its own position, not the portfolio's
TS9048 An order is not decided on trades the code never opens

Codes TS9005, TS9009, TS9011, TS9012 and TS9013 are not used.

TS9001: indicators are created at the root

Create every indicator once, at the root, and keep it in a const. Read it inside callbacks.

Why: an indicator created inside a callback is rebuilt on every call, starts empty each time and never warms up, so it never produces a value you can trade on.

Message: "Indicator 'RSI' must be instantiated at the root scope, not inside a 'manager.onIntervalCandle(...)' callback. Move the 'new RSI(...)' to the top level and capture the indicator in a const."

const hourly = manager.getIntervalData('AAPL', 60);
manager.onIntervalCandle('AAPL', 60, () => {
    const rsi = new RSI(hourly.close, 14);
    if (rsi.hasValue()) manager.debugLog(rsi.getValue());
});
const hourly = manager.getIntervalData('AAPL', 60);
const rsi = new RSI(hourly.close, 14);
manager.onIntervalCandle('AAPL', 60, () => {
    if (rsi.hasValue()) manager.debugLog(rsi.getValue());
});

TS9002: data subscriptions are made at the root

manager.getIntervalData(...) registers a data subscription, so it must be called at the root. manager.getSymbolMetadata(...) is refused inside periodic callbacks for the same reason (hoist it to the root with top-level await); it is allowed inside event callbacks such as onNews, onMessage or onPieuvreEvent, where the symbol is only known when the event fires.

Why: the engine only accepts new subscriptions while the agent is being built. Calling getIntervalData from a callback throws at run time and the agent keeps crashing.

Message: "'manager.getIntervalData(...)' must be called at the root scope, not inside a 'manager.onIntervalCandle(...)' callback. Move the call to the top level (await it if it returns a Promise), capture the result in a const, and read that const inside the callback."

manager.onIntervalCandle('AAPL', 60, () => {
    const hourly = manager.getIntervalData('AAPL', 60);
    manager.debugLog(hourly.getPending());
});
const hourly = manager.getIntervalData('AAPL', 60);
manager.onIntervalCandle('AAPL', 60, () => {
    manager.debugLog(hourly.getPending());
});

TS9003: live-data and trading calls run inside a callback

getCurrentTime, getCurrentPrice, placeOrder, closePosition, cancelOrder, openTrade, closeTrade and increaseCapital must run inside a callback, never at the root or in a helper called from the root.

Why: when the root runs there is no current tick, price, time or capital yet.

Message: "'manager.getCurrentPrice(...)' must be called inside a 'manager.on*(...)' callback (e.g. 'manager.onIntervalCandle', 'manager.cron'), not at the root scope. Live data is not available at module load."

const price = manager.getCurrentPrice('AAPL');
manager.onIntervalCandle('AAPL', 60, () => {
    manager.debugLog(price);
});
manager.onIntervalCandle('AAPL', 60, () => {
    const price = manager.getCurrentPrice('AAPL');
    manager.debugLog(price);
});

TS9004: listeners are attached at the root

Call the listener methods (manager.on… and manager.cron) only at the root. A root-level for loop or forEach that registers one listener per symbol is fine.

Why: a listener attached inside a callback is attached again every time the outer callback fires, so the handler runs once, then twice, then three times.

Message: "Listener 'manager.onTradeFilled(...)' must be attached at the root scope, not inside a 'manager.onIntervalCandle(...)' callback. A new listener would be added every time the outer callback fires."

manager.onIntervalCandle('AAPL', 60, () => {
    manager.onTradeFilled((trade) => {
        manager.debugLog(trade.symbol);
    });
});
manager.onTradeFilled((trade) => {
    manager.debugLog(trade.symbol);
});
manager.onIntervalCandle('AAPL', 60, () => {
    manager.debugLog('hourly candle closed');
});

TS9006: no timers

setTimeout(...) and setInterval(...) are refused. Schedule work with manager.cron(...).

Why: timers run outside the engine's clock. In a backtest they do not follow simulated time, and in a live run they do not survive a restart.

Message: "'setTimeout(...)' is forbidden in agent code. Use 'manager.cron(...)' for scheduled work."

manager.onIntervalCandle('AAPL', 60, () => {
    setTimeout(() => manager.debugLog('five minutes later'), 5 * 60 * 1000);
});
manager.cron('*/5 * * * *', () => {
    manager.debugLog('every five minutes');
});

TS9007: no imports

import declarations, require(...) and dynamic import(...) are refused. Everything you can use is already in scope (see "Names already in scope").

Why: agent code cannot load modules. Importing a name that is already provided (such as z or DateTime) also collides with it (TS2300).

Message: "'import' is forbidden in agent code. The system already provides every module you can use"

import { z } from 'zod';
const Verdict = z.object({ buy: z.boolean() });
manager.debugLog(Verdict);
const Verdict = z.object({ buy: z.boolean() });
manager.debugLog(Verdict);

TS9008: manager is provided

Do not declare a variable named manager and do not assign to manager. Passing manager to a helper as an argument is fine.

Why: the engine provides the one manager your agent talks to. Shadowing or replacing it disconnects the code from the engine. TypeScript also reports TS2451 or TS2588 for the same line.

Message: "Cannot redeclare 'manager'. The system provides 'manager' in scope"

const manager = { debugLog: (...args: unknown[]) => args.length };
manager.debugLog('hello');
function logPrice(api: V2Manager, symbol: string): void {
    api.debugLog(symbol, api.getCurrentPrice(symbol));
}
manager.onIntervalCandle('AAPL', 60, () => {
    logPrice(manager, 'AAPL');
});

TS9010: no wall-clock time

new Date() with no argument, Date.now(), DateTime.now(), DateTime.local() and DateTime.utc() are refused. Use manager.getCurrentTime() (milliseconds) for "now", and candle.openTime / candle.closeTime for candle times. new Date(timestamp), Date.parse(...) and DateTime.fromMillis(...) are fine.

Why: in a backtest "now" is the simulated time, not the time of your computer. Wall-clock time makes backtests wrong and non-reproducible.

Message: "'Date.now()' is forbidden in agent code. Use 'manager.getCurrentTime()' for the engine's current time, or read 'candle.openTime' / 'candle.closeTime' from candle data."

manager.onIntervalCandle('AAPL', 60, () => {
    const now = Date.now();
    manager.debugLog(new Date(now).toISOString());
});
manager.onIntervalCandle('AAPL', 60, () => {
    const now = manager.getCurrentTime();
    const newYork = DateTime.fromMillis(now, { zone: 'America/New_York' });
    manager.debugLog(new Date(now).toISOString(), newYork.hour);
});

TS9014: no direct network access

fetch(...), new XMLHttpRequest(), new WebSocket(...) and new EventSource(...) are refused. Reach outside data through the manager API: searchNews, onNews, onMessage, llm and the data methods.

Why: agent code has no network access of its own. The manager methods are the supported, metered and backtest-aware way in.

Message: "'fetch(...)' is forbidden in agent code."

manager.cron('0 * * * *', async () => {
    const response = await fetch('https://example.com/aapl-news.json');
    manager.debugLog(response.status);
});
manager.cron('0 * * * *', async () => {
    const items = await manager.searchNews('AAPL Apple', { limit: 5 });
    manager.debugLog(items.map((item) => item.title));
});

TS9015: no eval or Function

Calling eval(...), Function(...) or new Function(...) is refused. TS9039 also refuses these names in any other form (an alias, an argument), so a refused example reports both codes.

Why: code built from a string at run time escapes every check on this page.

Message: "'new Function(...)' is forbidden in agent code. Dynamic code execution defeats the static checks the engine relies on."

const double = new Function('a', 'return a * 2');
manager.debugLog(double(21));
function double(a: number): number {
    return a * 2;
}
manager.debugLog(double(21));

TS9016: no process

Any reference to process (environment variables, arguments, working directory) is refused. A property that happens to be named process (job.process, { process: 1 }) is fine. For a setting a user should be able to change per run, use a launch variable (manager.getVariable).

Why: the host process is not exposed to agent code. TypeScript also reports TS2591, since Node.js typings are not loaded.

Message: "'process' is not available in agent code. Configuration, env vars and host runtime details are managed by the engine and not exposed to user code."

const threshold = Number(process.env.THRESHOLD ?? '30');
manager.debugLog(threshold);
const threshold = manager.getVariable('threshold', 30);
manager.debugLog(threshold);

TS9017 (1 of 2): daily candles need a time zone

When onIntervalCandle or onIntervalCandleOpen uses a daily or longer interval (1440 minutes or more, 'week', 'month'), pass the asset's IANA time zone as the last argument: 'America/New_York' for US stocks and ETFs, 'Europe/Paris' for a Paris listing, 'UTC' for crypto. Only literal intervals are checked.

Why: without a zone the daily boundary can fall back to UTC. For an instrument that trades extended hours, a UTC boundary makes one day's close and the next day's open fire on the same candle, so "buy at the close, sell at the next open" becomes a flat round trip.

Message: "'manager.onIntervalCandle(...)' with a daily-or-higher interval must pass the asset's IANA time zone as the last argument (e.g. 'America/New_York' for a US stock/ETF, 'Europe/Paris' for an EU listing, 'UTC' for 24/7 crypto)."

manager.onIntervalCandle('AAPL', 1440, () => {
    manager.debugLog(manager.getCurrentPrice('AAPL'));
});
manager.onIntervalCandle('AAPL', 1440, () => {
    manager.debugLog(manager.getCurrentPrice('AAPL'));
}, 'America/New_York');

TS9017 (2 of 2): orders sized from wallet capital set inhibitAutoInvest: true

A placeOrder whose amount or qty is computed from a wallet capital reading (getAvailableCapital, getCurrentCapital, getLatentCapital, or the reported equity, available funds and buying power readings) must set inhibitAutoInvest: true.

Why: on a paper wallet, an order larger than the cash available is topped up automatically unless this flag is set. An order meant to spend the cash you already have would otherwise create money out of nothing and inflate the results. For a fixed recurring contribution, use a fixed amount instead (see TS9030).

Message: "This placeOrder is sized from wallet capital but does not set 'inhibitAutoInvest: true'."

manager.onIntervalCandle('AAPL', 60, () => {
    const cash = manager.getAvailableCapital();
    manager.placeOrder({ symbol: 'AAPL', type: 'MARKET', position: 'buy', amount: cash * 0.5, amountCurrency: 'USD' });
});
manager.onIntervalCandle('AAPL', 60, () => {
    const cash = manager.getAvailableCapital();
    manager.placeOrder({ symbol: 'AAPL', type: 'MARKET', position: 'buy', amount: cash * 0.5, amountCurrency: 'USD', inhibitAutoInvest: true });
});

TS9018: updateStoploss moves the trade's own stop

The third argument of updateStoploss(manager, trade, stoploss, price) must be the trade's own stop (trade.stoploss), not a new object literal.

Why: updateStoploss changes the stop object it receives. A fresh object is not attached to any trade, so the call does nothing and the real stop never moves. To give a trade a stop it does not have yet, set stoploss when you call openTrade.

Message: "'updateStoploss' mutates the stoploss object you pass: a fresh object is not attached to any trade, so this call is a silent no-op (the trade's actual stop never moves)." (The real message uses a dash instead of the colon.)

manager.onIntervalCandle('AAPL', 60, () => {
    const price = manager.getCurrentPrice('AAPL');
    if (price === undefined) return;
    for (const trade of manager.getSymbolPendingTrades('AAPL')) {
        updateStoploss(manager, trade, { type: 'fixed', absolute: price * 0.98, initial: price * 0.98 }, price * 0.98);
    }
});
manager.onIntervalCandle('AAPL', 60, () => {
    const price = manager.getCurrentPrice('AAPL');
    if (price === undefined) return;
    for (const trade of manager.getSymbolPendingTrades('AAPL')) {
        if (trade.position === 'buy' && trade.stoploss !== undefined && price * 0.98 > trade.stoploss.absolute) {
            updateStoploss(manager, trade, trade.stoploss, price * 0.98);
        }
    }
});

TS9019: position size is a fraction of capital

In a position size object, { type: 'risk', risk } must be below 0.5, and { type: 'proportion', proportion } must be at most 1. Both are fractions: risk: 0.01 risks 1% of capital at the stop, proportion: 0.1 opens 10% of capital. Only literal numbers are checked.

Why: risk: 1 means "lose 100% of the account if the stop is hit", which is almost always a percent written as a whole number. If you really mean to commit a large sum, use { type: 'amount', ... } or { type: 'proportion', ... }.

Message: "'risk: 1' risks 100% of the capital on a single trade"

manager.onIntervalCandle('AAPL', 60, () => {
    const price = manager.getCurrentPrice('AAPL');
    if (price === undefined) return;
    manager.openTrade({
        symbol: 'AAPL',
        position: 'buy',
        type: 'MARKET',
        positionSize: { type: 'risk', risk: 1 },
        stoploss: { type: 'fixed', value: price * 0.95 },
    });
});
manager.onIntervalCandle('AAPL', 60, () => {
    const price = manager.getCurrentPrice('AAPL');
    if (price === undefined) return;
    manager.openTrade({
        symbol: 'AAPL',
        position: 'buy',
        type: 'MARKET',
        positionSize: { type: 'risk', risk: 0.01 },
        stoploss: { type: 'fixed', value: price * 0.95 },
    });
});

Launch variables (TS9020 to TS9024, TS9028, TS9036)

manager.getVariable('name', default) declares a setting the user can change when starting a run. The launch form is built by reading your code, not by running it, so each call has to be readable as written: manager.getVariable called directly, a string literal name, a literal default.

TS9020: variable names are string literals

The name must be a string literal matching [A-Za-z][A-Za-z0-9_]{0,63}: no variable, no expression, no template string.

Why: the name labels the field in the launch form, so it must be known without running the code.

Message: "'manager.getVariable(...)' needs a literal variable name matching [A-Za-z][A-Za-z0-9_]{0,63} (e.g. 'rsiPeriod')."

const periodName = 'rsiPeriod';
const period = manager.getVariable(periodName, 14);
manager.debugLog(period);
const period = manager.getVariable('rsiPeriod', 14);
manager.debugLog(period);

TS9021: variable defaults are literals

The default must be a literal: a finite number (a leading minus is fine), a string, or true / false. Not a variable, not an expression, not Infinity or NaN.

Why: the default is the value used when the run provides none, and it is shown in the launch form.

Message: "'manager.getVariable(...)' needs a literal default: a finite number (e.g. 14 or -0.5), a string (e.g. 'long') or true/false."

const period = manager.getVariable('rsiPeriod', 7 * 2);
manager.debugLog(period);
const period = manager.getVariable('rsiPeriod', 14);
manager.debugLog(period);

TS9022: one default per variable

Every getVariable call for the same name must use the same default (same type and value). Simplest: read each variable once at the root and reuse the const.

Why: otherwise the launch form and the value used when none is provided would depend on which call runs first.

Message: "Variable 'threshold' is read elsewhere with a different default (30)."

const threshold = manager.getVariable('threshold', 30);
manager.onIntervalCandle('AAPL', 60, () => {
    manager.debugLog(threshold, manager.getVariable('threshold', 25));
});
const threshold = manager.getVariable('threshold', 30);
manager.onIntervalCandle('AAPL', 60, () => {
    manager.debugLog(threshold);
});

TS9023: at most 16 variables

An agent declares at most 16 distinct launch variables.

Why: keeps the launch form usable. Keep the settings a user would realistically change between runs, and hardcode the rest.

Message: "Too many launch-time variables: an agent may declare at most 16."

const settings: number[] = [
    manager.getVariable('v1', 1),
    manager.getVariable('v2', 2),
    manager.getVariable('v3', 3),
    manager.getVariable('v4', 4),
    manager.getVariable('v5', 5),
    manager.getVariable('v6', 6),
    manager.getVariable('v7', 7),
    manager.getVariable('v8', 8),
    manager.getVariable('v9', 9),
    manager.getVariable('v10', 10),
    manager.getVariable('v11', 11),
    manager.getVariable('v12', 12),
    manager.getVariable('v13', 13),
    manager.getVariable('v14', 14),
    manager.getVariable('v15', 15),
    manager.getVariable('v16', 16),
    manager.getVariable('v17', 17),
];
manager.debugLog(settings);
const settings: number[] = [
    manager.getVariable('v1', 1),
    manager.getVariable('v2', 2),
    manager.getVariable('v3', 3),
    manager.getVariable('v4', 4),
    manager.getVariable('v5', 5),
    manager.getVariable('v6', 6),
    manager.getVariable('v7', 7),
    manager.getVariable('v8', 8),
    manager.getVariable('v9', 9),
    manager.getVariable('v10', 10),
    manager.getVariable('v11', 11),
    manager.getVariable('v12', 12),
    manager.getVariable('v13', 13),
    manager.getVariable('v14', 14),
    manager.getVariable('v15', 15),
    manager.getVariable('v16', 16),
];
manager.debugLog(settings);

TS9024: getVariable is called directly

Always write manager.getVariable('name', default) with exactly two inline arguments. Do not store the method in a variable, bind it, destructure it, pass it to a helper, call it through bracket access or spread its arguments. Any call to a method named getVariable on something other than manager is refused too.

Why: indirect calls work at run time but are invisible to the launch form, so the setting could never be changed. Read the value once into a const at the root and pass the value around.

Message: "'getVariable' must be called directly as manager.getVariable('name', )"

const read = manager.getVariable;
manager.debugLog(read('rsiPeriod', 14));
const rsiPeriod = manager.getVariable('rsiPeriod', 14);
manager.debugLog(rsiPeriod);

TS9025: no type suppression

as any, <any>, a : any annotation on a variable or parameter, as unknown (the first half of x as unknown as T), and the @ts-ignore, @ts-expect-error and @ts-nocheck comments are refused. A catch (error: any) annotation is tolerated, but catch (error) plus a narrowing check is cleaner.

Why: they switch the compiler off for that value, and the compiler is what catches a misspelled field or a wrong value before your agent fails at run time.

Message: "Widening to 'any' is forbidden in agent code"

manager.onIntervalCandle('AAPL', 60, () => {
    const price = manager.getCurrentPrice('AAPL') as any;
    manager.debugLog(price.toFixed(2));
});
manager.onIntervalCandle('AAPL', 60, () => {
    const price = manager.getCurrentPrice('AAPL');
    if (price === undefined) return;
    manager.debugLog(price.toFixed(2));
});

TS9026: LLM calls in periodic callbacks use a stored key

A manager.llm(...) call reached from a periodic callback must be preceded, in the same function, by a manager.getStatus(...) read. Use it to store a key for the current candle or period, claim the key before the call, and release it if the call fails. Event callbacks (onMessage, onNews, onPieuvreEvent, ...) are not concerned.

Why: a candle or a cron period can be delivered more than once. Without a stored key, the same period can trigger the same paid LLM call several times.

Message: "'manager.llm(...)' inside a 'manager.cron(...)' callback must use a persisted idempotency key from 'manager.getStatus(...)' before the LLM call."

const Verdict = z.object({ buy: z.boolean() });
manager.cron('0 14 * * 1-5', async () => {
    const verdict = await manager.llm('default', 'You are a trading assistant.', 'Should I buy AAPL today?', Verdict);
    if (verdict?.buy === true) {
        manager.placeOrder({ symbol: 'AAPL', type: 'MARKET', position: 'buy', amount: 100, amountCurrency: 'USD' });
    }
}, 'America/New_York');
interface LlmGuard { lastDay?: string }
const Verdict = z.object({ buy: z.boolean() });
manager.cron('0 14 * * 1-5', async () => {
    const guard = manager.getStatus<LlmGuard>('llm-guard');
    const day = DateTime.fromMillis(manager.getCurrentTime(), { zone: 'America/New_York' }).toISODate() ?? '';
    if (guard.lastDay === day) return;
    guard.lastDay = day;
    try {
        const verdict = await manager.llm('default', 'You are a trading assistant.', 'Should I buy AAPL today?', Verdict);
        if (verdict?.buy === true) {
            manager.placeOrder({ symbol: 'AAPL', type: 'MARKET', position: 'buy', amount: 100, amountCurrency: 'USD' });
        }
    } catch (error) {
        guard.lastDay = undefined;
        manager.handleError(error);
    }
}, 'America/New_York');

TS9027: getPieuvreRank requests use known fields and values

Every field of a manager.getPieuvreRank(...) request must be one of sourceId, periodType, metric, universe, country, region, sector, industry, type, order, limit, mode, and order / mode must use an allowed value (order: 'asc' | 'desc'). The check looks through casts and through one const holding the request.

Why: TypeScript does not flag an unknown field once the request is stored in a variable or cast, but the ranking service rejects it at run time, so every run of the agent fails.

Message: "Unknown field 'sort' in the 'manager.getPieuvreRank(...)' request" followed by "Did you mean 'order'?" and the list of allowed fields.

manager.cron('0 0 1 * *', async () => {
    const request = { metric: 'market_cap', universe: 'sp500', limit: 10, sort: 'desc' } as const;
    const ranked = await manager.getPieuvreRank(request);
    manager.debugLog(ranked.length);
});
manager.cron('0 0 1 * *', async () => {
    const request = { metric: 'market_cap', universe: 'sp500', limit: 10, order: 'desc' } as const;
    const ranked = await manager.getPieuvreRank(request);
    manager.debugLog(ranked.length);
});

TS9028: a getVariable mention that yields no variable

A last-resort check: it fires only when nothing else failed, your code mentions .getVariable somewhere (outside comments and strings), and yet no launch variable could be read from it. The usual cause is getVariable used in a position that is not a direct call, such as a type query.

Why: saving such an agent would store settings that can never be set at launch.

Message: "This code names 'getVariable' but no launch-time variable could be read from it."

type ReadVariable = typeof manager.getVariable;
manager.debugLog('ready');
const rsiPeriod = manager.getVariable('rsiPeriod', 14);
manager.debugLog('ready', rsiPeriod);

TS9029: handleError receives the error, not the work

Pass manager.handleError(...) the error a failed operation threw, from a catch block. Do not pass it a promise.

Why: a promise passed to handleError is never awaited. The work runs unsupervised, the report carries no reason, and a failure has nobody to catch it.

Message: "'manager.handleError' takes the error an operation FAILED with, not the operation itself"

async function runCycle(): Promise<void> {
    const items = await manager.searchNews('AAPL Apple', { limit: 5 });
    manager.debugLog(items.length);
}
manager.cron('0 18 * * 1-5', () => {
    manager.handleError(runCycle());
}, 'America/New_York');
async function runCycle(): Promise<void> {
    const items = await manager.searchNews('AAPL Apple', { limit: 5 });
    manager.debugLog(items.length);
}
manager.cron('0 18 * * 1-5', async () => {
    try {
        await runCycle();
    } catch (error) {
        manager.handleError(error);
    }
}, 'America/New_York');

TS9030: a contribution buy does not set inhibitAutoInvest: true

A buy with a fixed size (not computed from wallet capital) must not set inhibitAutoInvest: true, unless the agent funds its wallet with manager.increaseCapital(...) somewhere. This is the mirror of TS9017.

Why: investing and dollar-cost-averaging backtests start with no cash: each contribution is the money. inhibitAutoInvest: true turns off that top-up and turns on the margin check, so every such order is cancelled for insufficient funds and the backtest shows zero fills.

Message: "This BUY sets 'inhibitAutoInvest: true', but its size is not derived from wallet capital and the agent never calls 'manager.increaseCapital(...)'"

manager.cron('0 10 1 * *', () => {
    manager.placeOrder({ symbol: 'SPY', type: 'MARKET', position: 'buy', amount: 500, amountCurrency: 'USD', inhibitAutoInvest: true });
}, 'America/New_York');
manager.cron('0 10 1 * *', () => {
    manager.placeOrder({ symbol: 'SPY', type: 'MARKET', position: 'buy', amount: 500, amountCurrency: 'USD' });
}, 'America/New_York');

TS9031: cash-sized orders state their currency

A placeOrder with amount must also set amountCurrency. Use 'USD' unless you mean another currency.

Why: the currency of a cash amount must be explicit. Backtests have no exchange rates: they treat 'USD', 'USDT' and 'USDC' as the same cash, so those size one-to-one on a listing quoted in any of them (US stocks and ETFs, dollar-quoted crypto pairs). Any other currency only sizes on demo and live runs, and such an agent cannot be backtested.

Message: "This cash-sized order ('amount') does not state its denomination. New agent code must always set 'amountCurrency' explicitly on 'amount' orders."

manager.cron('0 10 1 * *', () => {
    manager.placeOrder({ symbol: 'SPY', type: 'MARKET', position: 'buy', amount: 500 });
}, 'America/New_York');
manager.cron('0 10 1 * *', () => {
    manager.placeOrder({ symbol: 'SPY', type: 'MARKET', position: 'buy', amount: 500, amountCurrency: 'USD' });
}, 'America/New_York');

TS9032: amountCurrency only with amount

amountCurrency is refused on an order that has no amount (a qty order is in units and has no currency).

Why: the engine rejects such an order at run time with "amountCurrency is only valid with amount".

Message: "'amountCurrency' denominates the cash 'amount' and is only valid with it"

manager.cron('0 10 1 * *', () => {
    manager.placeOrder({ symbol: 'SPY', type: 'MARKET', position: 'buy', qty: 2, amountCurrency: 'USD' });
}, 'America/New_York');
manager.cron('0 10 1 * *', () => {
    manager.placeOrder({ symbol: 'SPY', type: 'MARKET', position: 'buy', qty: 2 });
}, 'America/New_York');

TS9033: your functions type their parameters

Every parameter of a function you declare needs a type annotation. This one error replaces the per-parameter TS7006 errors.

Why: strict mode makes an untyped parameter an implicit any. Fix the declaration rather than casting at each call site.

Message: "'changePct' declares untyped parameters (from, to). The gate compiles in strict mode: every parameter of a function you declare needs a type annotation"

function changePct(from, to) {
    return (to - from) / from;
}
manager.debugLog(changePct(100, 110));
function changePct(from: number, to: number): number {
    return (to - from) / from;
}
manager.debugLog(changePct(100, 110));

TS9034: getStatus takes a state type

When you read values out of manager.getStatus('key'), give it a type argument: manager.getStatus<State>('key'). Reads then come back as State[key] | undefined, so initialise before use (status.count ??= 0). This one error replaces the cascade of "is of type 'unknown'" errors on every read.

Why: without a type argument every stored value is unknown, so any comparison, arithmetic or property access on it fails. Declaring the shape once is the fix; narrowing or casting each read is not.

Message: "manager.getStatus(...) without a type argument returns Partial<Record<string, unknown>>: every value read from it is 'unknown' and fails on any comparison, arithmetic or property access."

manager.onIntervalCandle('AAPL', 60, () => {
    const status = manager.getStatus('counter');
    status.count = (status.count ?? 0) + 1;
    manager.debugLog(status.count);
});
interface Counter { count: number }
manager.onIntervalCandle('AAPL', 60, () => {
    const status = manager.getStatus<Counter>('counter');
    status.count = (status.count ?? 0) + 1;
    manager.debugLog(status.count);
});

TS9035: no wrapper function

Submit the body only. A top-level arrow or function expression that nothing calls, typically async (manager) => { ... }, is refused.

Why: the engine already runs your code as the body of such a function. Wrapped again, the inner function is never called, nothing registers, and the run is empty.

Message: "The code is wrapped in a function that nothing invokes, so no listener, cron or indicator would ever register and the run would be empty."

async (manager: V2Manager) => {
    manager.onIntervalCandle('AAPL', 60, () => {
        manager.debugLog(manager.getCurrentPrice('AAPL'));
    });
};
manager.onIntervalCandle('AAPL', 60, () => {
    manager.debugLog(manager.getCurrentPrice('AAPL'));
});

TS9036: whole-number indicator arguments get whole-number defaults

When a launch variable feeds an indicator argument that must be a whole number (a period, a length, a bar count), its default must be an integer.

Why: new RSI(closes, 14.5) compiles and runs, but computes nonsense.

Message: "Variable 'rsiPeriod' feeds a whole-number indicator argument (a period, a length, a bar count) but its literal default 14.5 is not an integer."

const hourly = manager.getIntervalData('AAPL', 60);
const rsiPeriod = manager.getVariable('rsiPeriod', 14.5);
const rsi = new RSI(hourly.close, rsiPeriod);
manager.onIntervalCandle('AAPL', 60, () => {
    if (rsi.hasValue()) manager.debugLog(rsi.getValue());
});
const hourly = manager.getIntervalData('AAPL', 60);
const rsiPeriod = manager.getVariable('rsiPeriod', 14);
const rsi = new RSI(hourly.close, rsiPeriod);
manager.onIntervalCandle('AAPL', 60, () => {
    if (rsi.hasValue()) manager.debugLog(rsi.getValue());
});

TS9037: order payloads are visible to the check

The argument of placeOrder must be an object literal, a local variable holding one, or an object built by spreading only local object literals. Do not pass the result of a function, a parameter, or spread a runtime value into an order.

Why: TS9017, TS9030, TS9031 and TS9032 check amountCurrency and inhibitAutoInvest on the order object. An order produced at run time could change or drop those fields after they were checked.

Message: "This placeOrder payload cannot be fully resolved at save time, so its final 'amountCurrency' and 'inhibitAutoInvest' values cannot be safety-checked."

function buyOrder(symbol: string, amount: number): CreateOrder {
    return { symbol, type: 'MARKET', position: 'buy', amount, amountCurrency: 'USD' };
}
manager.cron('0 10 1 * *', () => {
    manager.placeOrder(buyOrder('SPY', 100));
}, 'America/New_York');
function buy(symbol: string, amount: number): void {
    const order: CreateOrder = { symbol, type: 'MARKET', position: 'buy', amount, amountCurrency: 'USD' };
    manager.placeOrder(order);
}
manager.cron('0 10 1 * *', () => {
    buy('SPY', 100);
}, 'America/New_York');

TS9038: rebalancing buys are clamped by a cash budget

A buy placed in a loop and sized from portfolio or position value (getLatentCapital, getCurrentCapital, position values) must be limited by one shared cash budget that each buy decrements.

Why: portfolio value includes the positions you already hold. Buying "total value x weight" for each name buys again what you already own, so exposure grows at every rebalance until the account is wiped out. inhibitAutoInvest: true alone does not prevent it. Rebalance by difference instead:

  1. sell exits and overweight positions first;
  2. for each target, compute the deficit (target minus what you hold) and skip it when it is zero or less;
  3. clamp each buy with Math.min(deficit, remaining), where remaining starts from Math.max(0, manager.getAvailableCapital()) plus this cycle's sale proceeds, and subtract each spend from remaining.

Do not apply this clamp to contribution buys that bring new money in (see TS9030).

The check proves the budget from the shape of the code, so two more rules apply to sale proceeds:

  • add each sell's proceeds, as qty × manager.getCurrentPrice(symbol) of the sold line, in the same loop and branch that places that sell (a sell placeOrder, or manager.closePosition(symbol) / closePosition(symbol, qty) under a position.qty > 0 check: closing a short releases no cash, so only a proven long counts, and never Math.abs(qty));
  • sells planned into an array and executed in a later loop cannot be matched with their proceeds, and are refused.

Read the free cash once, before the sells: on paper a sell settles at once, so cash read after it already includes the proceeds.

Message: "This BUY is sized from portfolio/position VALUE (a total that includes positions already held) and placed in a loop without a cash-budget clamp."

const TARGETS = ['AAPL', 'MSFT'];
manager.cron('0 10 1 * *', () => {
    const total = manager.getLatentCapital();
    for (const symbol of TARGETS) {
        manager.placeOrder({ symbol, type: 'MARKET', position: 'buy', amount: total / TARGETS.length, amountCurrency: 'USD', inhibitAutoInvest: true });
    }
}, 'America/New_York');
const TARGETS = ['AAPL', 'MSFT'];
manager.cron('0 10 1 * *', () => {
    const held = new Map<string, number>();
    for (const position of manager.getPositions()) {
        const price = manager.getCurrentPrice(position.symbol);
        if (position.qty > 0 && price !== undefined && price > 0) held.set(position.symbol, position.qty * price);
    }
    const cash = manager.getAvailableCapital();
    let total = Math.max(0, cash);
    for (const value of held.values()) total += value;
    const target = (total * 0.95) / TARGETS.length;
    let remaining = Math.max(0, cash);
    for (const symbol of TARGETS) {
        const deficit = target - (held.get(symbol) ?? 0);
        const spend = Math.min(deficit, remaining);
        if (spend <= 0) continue;
        manager.placeOrder({ symbol, type: 'MARKET', position: 'buy', amount: spend, amountCurrency: 'USD', inhibitAutoInvest: true });
        remaining -= spend;
    }
}, 'America/New_York');

TS9039: no access to the host environment

Agent code works through manager, the pre-declared names and plain values. Constructs whose purpose is to reach the environment around the agent are refused:

  • the global object (globalThis, global, window, self) and the reflection and low-level globals (Reflect, Proxy, WebAssembly, SharedArrayBuffer, Atomics);
  • dynamic code (eval, Function) in any form, including as a value;
  • prototype and constructor access: member names such as prototype, constructor, __proto__, caller, callee, the legacy getter/setter helpers, and the Error stack-trace hooks, wherever they appear as a property name;
  • Object used as anything other than one of its safe statics: Object.keys, values, entries, fromEntries, assign, freeze, isFrozen, seal, isSealed, preventExtensions, isExtensible, is;
  • the arguments object, with, import.meta, new.target, and declare statements;
  • indexing a value typed any or unknown (or a function) with a key the compiler cannot pin down, and destructuring with a computed key.

A local variable you declare with one of these names (const self = ...) is not the global and is allowed. Type positions (let f: Function) are allowed.

Why: these are not needed to write a strategy, and they are the constructs used to step outside the agent API. Every message starts with "Implementation guard on this expression, not a missing feature: rewrite it and keep the agent's behaviour", then names a rewrite that passes. The rewrites below are the ones the messages suggest.

Own-key check. Object.prototype.hasOwnProperty.call(obj, key) is refused (it reads prototype, and uses Object outside its safe statics). Use ({}).hasOwnProperty.call(obj, key). Not Object.hasOwn: it is newer than ES2020 (TS2550) and is not a safe static. Not key in obj: it is also true for inherited keys such as 'toString'. Not obj.hasOwnProperty(key): it breaks when the data has its own hasOwnProperty key.

Message: "'prototype' may not be accessed. For an own-key check use '({}).hasOwnProperty.call(obj, key)'"

interface Seen { symbols?: Record<string, boolean> }
manager.cron('0 * * * *', () => {
    const status = manager.getStatus<Seen>('seen');
    const seen = status.symbols ?? {};
    if (!Object.prototype.hasOwnProperty.call(seen, 'AAPL')) seen['AAPL'] = true;
    status.symbols = seen;
});
interface Seen { symbols?: Record<string, boolean> }
manager.cron('0 * * * *', () => {
    const status = manager.getStatus<Seen>('seen');
    const seen = status.symbols ?? {};
    if (!({}).hasOwnProperty.call(seen, 'AAPL')) seen['AAPL'] = true;
    status.symbols = seen;
});

The global object. To share a value between callbacks, declare a variable at the root: every callback of the agent sees it. For state that must survive a restart, use manager.getStatus. (In the refused example TypeScript also reports TS7017, since the global object has no such property.)

Message: "'globalThis' is not reachable here: use the 'manager' API or plain local values." (The real message uses a dash instead of the colon.)

manager.onPreciseCandle('AAPL', (candle) => {
    globalThis.lastClose = candle.close;
});
let lastClose = 0;
manager.onPreciseCandle('AAPL', (candle) => {
    lastClose = candle.close;
});
manager.cron('0 * * * *', () => {
    manager.debugLog(lastClose);
});

Parsed JSON. JSON.parse returns any, and indexing it with a runtime key is refused. Type the result first, for example as a Record.

Message: "A runtime key cannot index an untyped value or a function. Type the value first"

interface Config { raw?: string }
manager.cron('0 * * * *', () => {
    const status = manager.getStatus<Config>('config');
    const weights = JSON.parse(status.raw ?? '{}');
    const symbol: string = 'AAPL';
    manager.debugLog(weights[symbol]);
});
interface Config { raw?: string }
manager.cron('0 * * * *', () => {
    const status = manager.getStatus<Config>('config');
    const weights = JSON.parse(status.raw ?? '{}') as Record<string, number>;
    const symbol: string = 'AAPL';
    manager.debugLog(weights[symbol] ?? 0);
});

Reflection. Use the safe Object statics, spreads and plain property access instead of Reflect or the reflective Object methods.

Message: "'Reflect' is not reachable here"

const weights: Record<string, number> = { AAPL: 0.6, MSFT: 0.4 };
manager.debugLog(Reflect.ownKeys(weights));
const weights: Record<string, number> = { AAPL: 0.6, MSFT: 0.4 };
const copy = { ...weights };
manager.debugLog(Object.keys(copy), Object.entries(copy).length);

Constructor access. Tell values apart with typeof, Array.isArray or instanceof, not through .constructor.

Message: "'constructor' may not be accessed."

function kind(value: object): string {
    return value.constructor.name;
}
manager.debugLog(kind([1, 2]));
function kind(value: object): string {
    return Array.isArray(value) ? 'Array' : typeof value;
}
manager.debugLog(kind([1, 2]));

TS9040: the previous day is the last element of a daily History

Inside a callback that runs more often than the bars of a History (an intraday onIntervalCandle or onIntervalCandleOpen, onPreciseCandle, cron), a History over daily, weekly or monthly candles is read at its last element for the previous bar. Code that reads only an older element ([0] of a two-bar History, [length - 2]) and never the last one is refused.

Why: a candle series holds completed bars only, oldest first. During the day the forming day is not in the daily series, so the last element is yesterday and [0] of new History(daily, 2) is the day before yesterday: every level taken from it is a day stale. In the daily callback itself the last element is the day that just closed, so [0] is the previous day there and the rule does not apply. When you really need the bar before the previous one, read it next to the previous bar (bars[bars.length - 1], or the series itself, daily.getValue()), for example to compare the two days.

A cron that fires exactly when a bar closes (00:00 in the series' time zone for daily bars) runs before that close in the same tick, so its last element is still the day before. Schedule such a cron a few minutes later.

Message: "'bars[0]' reads an older element of a History over day candles, and nothing in the code reads its LAST element."

const daily = manager.getIntervalData('AAPL', 1440, 'America/New_York');
const days = new History(daily, 2);
manager.onIntervalCandle('AAPL', 60, () => {
    if (!days.hasValue()) return;
    const bars = days.getValue();
    const yesterday = bars[0];
    const price = manager.getCurrentPrice('AAPL');
    if (yesterday === undefined || price === undefined) return;
    if (price > yesterday.high) manager.sendNotification(`AAPL is above yesterday's high ${yesterday.high}`);
});
const daily = manager.getIntervalData('AAPL', 1440, 'America/New_York');
const days = new History(daily, 2);
manager.onIntervalCandle('AAPL', 60, () => {
    if (!days.hasValue()) return;
    const bars = days.getValue();
    const yesterday = bars[bars.length - 1];
    const price = manager.getCurrentPrice('AAPL');
    if (yesterday === undefined || price === undefined) return;
    if (price > yesterday.high) manager.sendNotification(`AAPL is above yesterday's high ${yesterday.high}`);
});

TS9041: a take-profit or stop-loss delta is a whole percentage

The delta of a take-profit or stop-loss request, or of closeTrade, is the percentage of the trade's initial quantity that the exit closes: a whole number from 0 to 100. Values the checker can compute (a literal, arithmetic, a constant, a launch variable's default) are checked.

Why: the engine checks delta and throws otherwise, so a share such as 100 / 3 stops the agent at its first signal. For thirds, use 33, 33 and 34, or leave delta off the last level: an exit without delta closes whatever remains.

Message: "'delta: 100 / 3' (= 33.333333333333336) is not a whole number from 0 to 100."

manager.onIntervalCandle('BTC', 240, () => {
    const price = manager.getCurrentPrice('BTC');
    if (price === undefined || manager.getSymbolPendingTrades('BTC').length > 0) return;
    manager.openTrade({
        symbol: 'BTC', position: 'buy', type: 'MARKET',
        positionSize: { type: 'proportion', proportion: 0.1 },
        stoploss: { type: 'fixed', value: price * 0.95 },
        takeprofit: { type: 'fixed', value: price * 1.05, delta: 100 / 3 },
        takeprofit2: { type: 'fixed', value: price * 1.1, delta: 100 / 3 },
        takeprofit3: { type: 'fixed', value: price * 1.15, delta: 100 / 3 },
    });
});
manager.onIntervalCandle('BTC', 240, () => {
    const price = manager.getCurrentPrice('BTC');
    if (price === undefined || manager.getSymbolPendingTrades('BTC').length > 0) return;
    manager.openTrade({
        symbol: 'BTC', position: 'buy', type: 'MARKET',
        positionSize: { type: 'proportion', proportion: 0.1 },
        stoploss: { type: 'fixed', value: price * 0.95 },
        takeprofit: { type: 'fixed', value: price * 1.05, delta: 33 },
        takeprofit2: { type: 'fixed', value: price * 1.1, delta: 33 },
        takeprofit3: { type: 'fixed', value: price * 1.15, delta: 34 },
    });
});

TS9042: a stop trailed from the current price only tightens

A stop moved with updateStoploss to a level computed from the current price (a candle's close, high or low, or getCurrentPrice) is compared with the stop already in place first: level > trade.stoploss.absolute for a long, < for a short (in the callback or in a helper it calls), or clamped with Math.max / Math.min against the current stop. A level that is a fixed fraction of a running peak (trade.highestPrice * 0.95, or a peak your code stores and only raises) already only tightens and needs no comparison.

Why: updateStoploss keeps the stop on the right side of the current price but accepts a looser level. Recomputed on every bar without that comparison, a long's stop follows the price down on a decline and rarely fires, and the loss runs past the distance you set. A stop at a fixed distance needs no code: open the trade with stoploss: { type: 'trailing', value } and the engine trails it.

Message: "'updateStoploss' moves this stop to 'price * 0.98', a level computed from the current price, and nothing compares it with the stop already in place."

manager.onIntervalCandle('AAPL', 60, () => {
    const price = manager.getCurrentPrice('AAPL');
    if (price === undefined) return;
    for (const trade of manager.getSymbolPendingTrades('AAPL')) {
        if (trade.position === 'buy' && trade.stoploss !== undefined) {
            updateStoploss(manager, trade, trade.stoploss, price * 0.98);
        }
    }
});
manager.onIntervalCandle('AAPL', 60, () => {
    const price = manager.getCurrentPrice('AAPL');
    if (price === undefined) return;
    for (const trade of manager.getSymbolPendingTrades('AAPL')) {
        if (trade.position === 'buy' && trade.stoploss !== undefined && price * 0.98 > trade.stoploss.absolute) {
            updateStoploss(manager, trade, trade.stoploss, price * 0.98);
        }
    }
});

TS9047: a strategy decides on its own position

An order is refused when a flat-or-not test of manager.getPositionQty(symbol) decides whether it runs (=== 0, !== 0, > 0, <= 0, a flag computed from one, a guard clause that returns), unless the order is sized from the holding itself. An exit is refused when it sells a quantity read from getPositionQty (all of it or a share of it), or calls closePosition, on a symbol the agent also buys on its own signals.

Why: getPositionQty is the position of the whole portfolio the agent runs on. In a backtest that portfolio is the agent's alone, so the code looks right. A demo or live portfolio is shared with your own holdings and with your other agents: a strategy waiting for getPositionQty(symbol) === 0 never enters while anyone holds the symbol, and a sell of getPositionQty(symbol) sells shares the agent never bought. Decide on the agent's own book instead: its open trades (getSymbolPendingTrades(symbol), closed with closeTrade, which sells only what the trade bought), or for an order-based agent the quantity its own fills bought, counted in onOrderExecuted (see the RSI mean reversion example). Agents whose job is the whole portfolio keep getPositionQty: a rebalancer trades the difference to a target, which this rule does not refuse, and an agent managing positions it did not open exits with closePosition.

Message: "'manager.getPositionQty(symbol) === 0' decides this openTrade on manager.getPositionQty, the quantity the WHOLE portfolio holds."

const symbol = 'BTC';
const candles = manager.getIntervalData(symbol, 240, 'UTC');
const fast = new EMA(new PartialIndicator(candles, 'close'), 9);
const slow = new EMA(new PartialIndicator(candles, 'close'), 21);
manager.onIntervalCandle(symbol, 240, () => {
    const f = fast.getValue();
    const s = slow.getValue();
    if (f === undefined || s === undefined) return;
    if (f > s && manager.getPositionQty(symbol) === 0) {
        manager.placeOrder({ symbol, type: 'MARKET', position: 'buy', amount: 500, amountCurrency: 'USD' });
    } else if (f < s && manager.getPositionQty(symbol) > 0) {
        manager.placeOrder({ symbol, type: 'MARKET', position: 'sell', qty: manager.getPositionQty(symbol), inhibitAutoInvest: true });
    }
}, 'UTC');
const symbol = 'BTC';
const candles = manager.getIntervalData(symbol, 240, 'UTC');
const fast = new EMA(new PartialIndicator(candles, 'close'), 9);
const slow = new EMA(new PartialIndicator(candles, 'close'), 21);
manager.onIntervalCandle(symbol, 240, () => {
    const f = fast.getValue();
    const s = slow.getValue();
    if (f === undefined || s === undefined) return;
    const open = manager.getSymbolPendingTrades(symbol);
    if (f > s && open.length === 0) {
        manager.openTrade({ symbol, type: 'MARKET', position: 'buy', positionSize: { type: 'amount', amount: 500 }, comment: `EMA9 ${f.toFixed(0)} above EMA21 ${s.toFixed(0)}` });
    } else if (f < s) {
        for (const trade of open) manager.closeTrade(trade, { cause: `EMA9 ${f.toFixed(0)} back below EMA21 ${s.toFixed(0)}` });
    }
}, 'UTC');

TS9048: an order is not decided on trades the code never opens

An order is refused when getSymbolPendingTrades or getPendingTrades decides whether it runs, in code that never calls openTrade.

Why: only openTrade creates trades. Orders placed with placeOrder change the position but open no trade, so in such an agent the list of its trades is always empty: a test of it reads "flat" on every signal and the agent buys again each time. Either open trades (openTrade, then closeTrade), or count the quantity the agent's own fills bought in onOrderExecuted and test that.

Message: "'manager.getSymbolPendingTrades(symbol)' reads this agent's open TRADES, but the code never calls manager.openTrade."

const symbol = 'ETH';
const candles = manager.getIntervalData(symbol, 60, 'UTC');
const rsi = new RSI(new PartialIndicator(candles, 'close'), 14);
manager.onIntervalCandle(symbol, 60, () => {
    const value = rsi.getValue();
    if (value === undefined || value >= 30) return;
    if (manager.getSymbolPendingTrades(symbol).length > 0) return;
    manager.placeOrder({ symbol, type: 'MARKET', position: 'buy', amount: 200, amountCurrency: 'USD' });
}, 'UTC');

Common TypeScript errors

The compiler errors external authors hit most often on this API, with the fix.

TS2339: property does not exist

The name is wrong for that type. A candle has open, high, low, close, volume, openTime and closeTime; the current price comes from manager.getCurrentPrice(symbol).

manager.onPreciseCandle('AAPL', (candle) => {
    manager.debugLog(candle.price);
});
manager.onPreciseCandle('AAPL', (candle) => {
    manager.debugLog(candle.close);
});

TS18048 / TS2532: possibly undefined

manager.getCurrentPrice() returns number | undefined (TS18048 names the variable). An indicator's getValue() returns T | undefined (TS2532 "Object is possibly 'undefined'"). Check first: an if (x === undefined) return; guard, or indicator.hasValue() which narrows the next getValue().

manager.onIntervalCandle('AAPL', 60, () => {
    const price = manager.getCurrentPrice('AAPL');
    manager.debugLog(price * 2);
});
manager.onIntervalCandle('AAPL', 60, () => {
    const price = manager.getCurrentPrice('AAPL');
    if (price === undefined) return;
    manager.debugLog(price * 2);
});
const hourly = manager.getIntervalData('AAPL', 60);
const sma = new SMA(hourly.close, 20);
manager.onIntervalCandle('AAPL', 60, () => {
    manager.debugLog(sma.getValue().toFixed(2));
});
const hourly = manager.getIntervalData('AAPL', 60);
const sma = new SMA(hourly.close, 20);
manager.onIntervalCandle('AAPL', 60, () => {
    const value = sma.getValue();
    if (value === undefined) return;
    manager.debugLog(value.toFixed(2));
});

TS2345: argument not assignable

A value of the wrong type is passed to an API. A frequent case: intervals are minutes (60, 240, 1440) or 'week' / 'month', not strings such as '1h'.

manager.onIntervalCandle('AAPL', '1h', () => {
    manager.debugLog('hourly');
});
manager.onIntervalCandle('AAPL', 60, () => {
    manager.debugLog('hourly');
});

TS7034 / TS7005: implicit any on a variable

A let declared without a type or an initializer and then used inside a callback. Give it a type.

let lastSignal;
manager.onIntervalCandle('AAPL', 60, () => {
    lastSignal = manager.getCurrentTime();
});
manager.onTradeFilled(() => {
    manager.debugLog(lastSignal);
});
let lastSignal: number | undefined;
manager.onIntervalCandle('AAPL', 60, () => {
    lastSignal = manager.getCurrentTime();
});
manager.onTradeFilled(() => {
    manager.debugLog(lastSignal);
});

TS2550: method from a newer library

The method exists in a later JavaScript version than ES2020 (see the table under "What ES2020 gives you").

manager.onIntervalCandle('AAPL', 60, () => {
    const closes: number[] = [101, 102, 103];
    const last = closes.at(-1);
    const label = 'AAPL-US'.replaceAll('-', '_');
    manager.debugLog(last, label);
});
manager.onIntervalCandle('AAPL', 60, () => {
    const closes: number[] = [101, 102, 103];
    const last = closes[closes.length - 1];
    const label = 'AAPL-US'.replace(/-/g, '_');
    manager.debugLog(last, label);
});

Redeclaring a pre-declared name (TS2300, code 0)

Importing a pre-declared name reports TS2300 "Duplicate identifier" (plus TS9007). Declaring your own variable, function, class or interface with a pre-declared name (RSI, News, Trade, Interval, ...) collides with the declaration Obside provides: the check returns a single error with code 0 and the message "Your code declares 'RSI', which is a name already provided in the agent's global scope". Rename your declaration.

import { DateTime } from 'luxon';
manager.onIntervalCandle('AAPL', 60, () => {
    manager.debugLog(DateTime.fromMillis(manager.getCurrentTime()).toISO());
});
const RSI = 14;
manager.onIntervalCandle('AAPL', 60, () => {
    manager.debugLog(RSI);
});
const rsiPeriod = 14;
manager.onIntervalCandle('AAPL', 60, () => {
    manager.debugLog(rsiPeriod, DateTime.fromMillis(manager.getCurrentTime()).toISO());
});

TS2365: comparing an indicator's label with a number

Some indicators return labels, not numbers: Crossover returns 'firstUp' | 'secondUp' | null, Elder returns 'bullish' | 'bearish' | 'neutral', Engulfing returns 'bullish' | 'bearish' | 'none'. Comparing them with a number fails (here TypeScript also reports TS2533 for the possible null). Compare with the label.

const hourly = manager.getIntervalData('AAPL', 60);
const fast = new EMA(hourly.close, 12);
const slow = new EMA(hourly.close, 26);
const cross = new Crossover(fast, slow);
manager.onIntervalCandle('AAPL', 60, () => {
    if (cross.getValue() > 0) manager.debugLog('fast EMA crossed above');
});
const hourly = manager.getIntervalData('AAPL', 60);
const fast = new EMA(hourly.close, 12);
const slow = new EMA(hourly.close, 26);
const cross = new Crossover(fast, slow);
manager.onIntervalCandle('AAPL', 60, () => {
    if (cross.getValue() === 'firstUp') manager.debugLog('fast EMA crossed above');
});

TS18046: the caught error is unknown

manager.cron('0 * * * *', async () => {
    try {
        await manager.searchNews('AAPL Apple');
    } catch (error) {
        manager.debugLog(error.message);
    }
});
manager.cron('0 * * * *', async () => {
    try {
        await manager.searchNews('AAPL Apple');
    } catch (error) {
        manager.debugLog(error instanceof Error ? error.message : String(error));
    }
});

TS1108: return at the top level

Your code is compiled as a module, where a top-level return is not allowed. Use an if.

const enabled = manager.getVariable('enabled', true);
if (!enabled) return;
manager.onIntervalCandle('AAPL', 60, () => {
    manager.debugLog('running');
});
const enabled = manager.getVariable('enabled', true);
if (enabled) {
    manager.onIntervalCandle('AAPL', 60, () => {
        manager.debugLog('running');
    });
}

TS2304: cannot find name

The name is not in scope: there is no Lodash, no Node.js module and no browser library. Use plain JavaScript and the pre-declared names.

manager.onIntervalCandle('AAPL', 60, () => {
    manager.debugLog(_.sum([1, 2, 3]));
});
manager.onIntervalCandle('AAPL', 60, () => {
    manager.debugLog([1, 2, 3].reduce((sum, n) => sum + n, 0));
});
Browse the documentation