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 withnew. - 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
zschema 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(orrequire) is an error (TS9007), and it collides with the names the engine already provides. - No wall-clock time.
new Date(),Date.now(), andDateTime.now()/.local()/.utc()are rejected (TS9010), because they would make backtests non-deterministic. Usemanager.getCurrentTime(), or a candle'sopenTime/closeTime. Build a date from engine time withDateTime.fromMillis(manager.getCurrentTime()). - No direct network or I/O.
fetch,WebSocket,XMLHttpRequestand similar are rejected (TS9014). Outbound communication goes through the manager (searchNews,llm,onNews,sendWebhook). - No timers.
setTimeoutandsetIntervalare rejected (TS9006): schedule withmanager.cron. - No dynamic code.
evaland theFunctionconstructor 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: anyannotation, casting throughunknown, and the@ts-ignorefamily 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.
riskandproportionare fractions of capital (0.01is one percent), and the checker rejects an implausibly large fraction (TS9019). A stop-loss or take-profitvalueis an absolute price, not a distance. - Order denomination. A cash
amountorder must state itsamountCurrency(for example'USD') (TS9031), andamountCurrencyis only valid withamount(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.llmcall 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
Historyis yesterday. Reading only an older element takes the day before yesterday for yesterday (TS9040). - Exits that work. A take-profit or stop-loss
deltais 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 withopenTrade(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
undefinedmust be checked before use.manager.getCurrentPrice()returnsnumber | undefined, andindicator.getValue()returnsT | undefinedunless you narrowed the indicator withhasValue()first. - Every parameter of a function you declare needs a type. Callback parameters of
managerAPIs are typed for you. - A variable declared without a type and without an initializer (
let last;) becomes an implicitanyand is refused when used in a closure. - A
catch (error)variable isunknown. 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
awaitworks:const metadata = await manager.getSymbolMetadata('AAPL');. - A top-level
returndoes not: it fails with TS1108. Wrap the code in anifinstead.
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 (typeV2Manager).- 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,DurationandIntervalfrom Luxon;zfrom Zod. Note thatIntervalis Luxon's class. The engine's candle interval type is namedStseInterval. - 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:
- sell exits and overweight positions first;
- for each target, compute the deficit (target minus what you hold) and skip it when it is zero or less;
- clamp each buy with
Math.min(deficit, remaining), whereremainingstarts fromMath.max(0, manager.getAvailableCapital())plus this cycle's sale proceeds, and subtract each spend fromremaining.
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 sellplaceOrder, ormanager.closePosition(symbol)/closePosition(symbol, qty)under aposition.qty > 0check: closing a short releases no cash, so only a proven long counts, and neverMath.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 theErrorstack-trace hooks, wherever they appear as a property name; Objectused as anything other than one of its safe statics:Object.keys,values,entries,fromEntries,assign,freeze,isFrozen,seal,isSealed,preventExtensions,isExtensible,is;- the
argumentsobject,with,import.meta,new.target, anddeclarestatements; - indexing a value typed
anyorunknown(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));
});