Orders, trades and risk
How to buy and sell, how to size, how the wallet's capital works, and how to protect positions. The method reference is in the Manager API; the exact object shapes in Types.
Trade or order?
- A trade (
manager.openTrade/manager.closeTrade) is an entry with a planned exit: a stop-loss, a take-profit or an exit rule. It produces realized profit when it closes, and the engine manages its stops. Use trades for active strategies where every entry has an exit. - An order (
manager.placeOrder) changes a position's quantity with no exit attached. Use orders for buy-and-hold, dollar-cost averaging, accumulation and rebalancing. Their profit is measured mark-to-market on the position.
An operation with no exit plan must use orders: an open trade shows no realized profit until it closes, so a buy-and-hold written with openTrade shows a flat equity curve.
The two calls share symbol, position ('buy' or 'sell', never 'long' / 'short' or side) and type ('MARKET', 'LIMIT', 'MARKET_TRIGGER'). They differ in the rest:
placeOrder (CreateOrder) |
openTrade (V2EnterSignal) |
|
|---|---|---|
| Size | qty (units) or amount + amountCurrency (cash, MARKET only) |
positionSize: { type, … } (required) |
| Limit or trigger price | limitPrice (LIMIT), triggerPrice (MARKET_TRIGGER) |
price |
| Stops | none | stoploss, takeprofit (and 2…4 variants) |
| Note | none (no comment field) |
comment |
Sizing an order
// Units you computed:
manager.placeOrder({ symbol, type: 'MARKET', position: 'buy', qty: 250 / price });
// Cash, when the price is unknown or irrelevant:
manager.placeOrder({ symbol, type: 'MARKET', position: 'buy', amount: 250, amountCurrency: 'USD' });
// A resting limit:
manager.placeOrder({ symbol, type: 'LIMIT', position: 'buy', qty: 0.01, limitPrice: 58000 });
amountCurrencyis required withamount(TS9031) and refused without it (TS9032). Use'USD'unless the user sized in another currency. Backtests have no exchange rates: they treat'USD','USDT'and'USDC'as the same cash, which sizes one-to-one on US stocks, ETFs and dollar-quoted crypto. Any other currency ('EUR') only sizes in demo and live runs, so such an agent cannot be backtested.amountis for MARKET orders only. For a LIMIT order computeqtyfrom a price.- Symbols discovered at run time (a ranking, a resolved ticker) have no price on the first cycle: size them with
amount, and let the engine size the order at the first price. Do not skip the order becausegetCurrentPriceisundefined: the order is what subscribes the price. - Futures:
qtycounts contracts; the engine applies the contract size to value and profit. Do not multiplyqtybycontractSize.
Sizing a trade
positionSize is an object whose type names the field that follows:
positionSize: { type: 'proportion', proportion: 0.1 } // 10% of the wallet's value opened
positionSize: { type: 'risk', risk: 0.01 } // 1% of the wallet's value lost at the stop
positionSize: { type: 'amount', amount: 500 } // $500 opened
positionSize: { type: 'qty', qty: 2 } // 2 units
risk and proportion are fractions; the validator refuses risk of 0.5 or more and proportion above 1 (TS9019). risk sizing divides by the stop distance: it needs a stop, and a very tight stop gives a very large position, which the wallet then refuses for lack of margin.
Capital and auto-invest
A paper wallet (backtests and demo runs) exposes several capital figures, and they are not interchangeable:
| Method | What it is | Use it for |
|---|---|---|
getAvailableCapital() |
free cash: realized capital minus the cost of open positions (0 when fully invested) | "invest the remaining cash", clamping buys |
getCurrentCapital() |
realized capital; opening a position does not lower it | reporting realized results |
getLatentCapital() |
the whole wallet at market value | monitoring, weights, drawdowns |
getInvestedCapital() |
all money ever put in | return on invested capital |
A backtest wallet starts with the starting capital chosen when the backtest is launched (in USD), deposited on the first tick; a demo run uses the demo portfolio's cash and positions. increaseCapital(amount) adds to whatever is there, in the same tick: getAvailableCapital() includes it right away.
Auto-invest. When a plain placeOrder buys more than the wallet's cash, the paper wallet does not refuse it: it silently adds the missing money as new invested capital. This is what makes a recurring contribution work (a dollar-cost-averaging backtest starts with no cash: every buy brings its own money in), and it is also how a badly sized order inflates invested capital. One flag decides which behaviour you get, and one question decides the flag: does this order spend money the wallet already has, or bring new money in?
- Spending existing cash (a rebalance, "invest my remaining cash", a sell that reduces or closes a long): set
inhibitAutoInvest: true. The validator requires it on any order sized from a capital reading (TS9017).closePosition,closeTradeand engine exits never auto-invest, whatever you set. - Bringing new money in (a fixed contribution, a DCA buy, "invest $500 every month"): set nothing. The validator refuses
inhibitAutoInvest: trueon a fixed-size buy unless the agent funds itself withincreaseCapital(TS9030), because with the flag the order is checked against a wallet that has no cash, and every buy is cancelled.
// Fixed contribution: exactly $100, new money, no flag.
manager.placeOrder({ symbol, type: 'MARKET', position: 'buy', amount: 100, amountCurrency: 'USD' });
// Spend what is free: clamp to the free cash, flag it.
const cash = Math.max(0, manager.getAvailableCapital());
if (cash > 1) manager.placeOrder({ symbol, type: 'MARKET', position: 'buy', amount: cash, amountCurrency: 'USD', inhibitAutoInvest: true });
A fixed amount per period is sized on that amount, never on getAvailableCapital(): on its first run the latter would buy all the idle cash, not $100.
A budget of your own is not the wallet. When the agent manages a sleeve ("a $2,000 basket") in a wallet that holds other money, getAvailableCapital() measures all free cash and getLatentCapital() the whole wallet. Track the sleeve yourself in the status bag: the amount invested, the symbols bought and the quantity your own fills bought of each (count them in onOrderExecuted), with the sleeve's value computed as the sum of that quantity × getCurrentPrice(symbol) over your symbols. getPositionQty(symbol) is the whole portfolio's position, so it also counts shares of the same symbol that are not the sleeve's.
Rebalancing to target weights
Rebalance by the difference from target, never by buying each target in full: a portfolio's value includes what you already hold, so buying "value × weight" for every name re-buys it and exposure compounds at every rebalance. The validator refuses a buy loop sized from portfolio value without a cash clamp (TS9038). The correct cycle:
- Resolve the targets. If the data is missing (a price, a ranking), abort the cycle and retry later; never trade on a partial picture.
- Sell first: positions no longer targeted, and overweights down to target. Add up the estimated proceeds (
qty × price) in the same loop that sells. - Compute the investable total as the value of the targets you hold plus the proceeds plus
getAvailableCapital()(not floored at 0, so past overspending shrinks the targets), and keep 2 to 5% headroom for fees and slippage. - Buy each target's deficit only, every buy clamped by one shared budget that each buy decrements, starting at the free cash read before the sells plus the proceeds:
let remaining = Math.max(0, cash) + proceeds, whereconst cash = manager.getAvailableCapital()was read before the first sell. (On paper a sell settles at once, so free cash read after the sells already contains their proceeds; live it does not yet. Reading it first is right in both.) Every order in the cycle setsinhibitAutoInvest: true.
The validator proves the budget from the shape of the code, so keep it recognisable: add each sell's proceeds (qty × manager.getCurrentPrice(symbol) of the sold line) in the same loop and branch that places that sell. A closePosition sell counts only under a position.qty > 0 check (closing a short releases no cash). Sells planned into an array and executed in a later loop cannot be matched with their proceeds and are refused (TS9038).
The 60/40 example in Schedules and launch actions and the top-10 example in Examples follow this shape.
On a live broker, sells do not settle instantly: the cash from a sell may not be usable by a buy placed in the same callback. Size buys from your own estimate of the proceeds (as above), and accept that a buy may be refused and caught up on the next cycle.
Stops and take-profits
On a trade, a stop or take-profit value is an absolute price, never a distance:
const atrValue = 120;
stoploss: { type: 'fixed', value: entry - 2 * atrValue }, // right: a price below the entry
stoploss: { type: 'fixed', value: 2 * atrValue }, // wrong: a stop at 240 on a $50,000 asset
'fixed'stays where it is set.'trailing'keeps its initial distance (entry minus the initial stop, in price units) behind the highest high since entry (lowest low for a sell);{ type: 'trailing', value: entry * 0.9 }therefore trails 10% of the entry price below the best high.'special'behaves like'fixed'.- Levels are placed from the price you pass, usually
getCurrentPrice(symbol)when you callopenTrade; a market entry fills at the next price plus spread, so the level is relative to the signal price, not to the fill. Read the fill fromtrade.enterPriceinonTradeFilledif you need to re-anchor a level withupdateStoploss. - Take-profit
'fixed'exits atvalue;'trigger'arms when price reachestrigger, then exits atvalue;'trailing'arms attrigger, then trailsrelativebehind the best price. delta(0 to 100) is the percentage of the initial position closed at that level; several levels (stoploss2,takeprofit2, …) scale out. Withoutdelta, the level closes everything that is left. The stops keep covering whatever remains after a partial take-profit:
takeprofit: { type: 'fixed', value: entry * 1.03, delta: 50 }, // half at +3%
takeprofit2: { type: 'fixed', value: entry * 1.06 }, // the rest at +6%
stoploss: { type: 'fixed', value: entry * 0.98 }, // covers whatever is still open
- A buy's stop must be below its entry (and a sell's above), or
openTradereturnsundefined. - Do not also code the same stop in a callback: the engine already watches it.
Moving a stop uses the updateStoploss helper with the trade's own stop object (the validator refuses a fresh object, which would move nothing: TS9018). It never moves a buy stop above the current price.
// Move the stop to break-even once a trade is 2% in profit.
const symbol = 'SOL';
manager.onPreciseCandle(symbol, () => {});
manager.cron('*/5 * * * *', () => {
const price = manager.getCurrentPrice(symbol);
if (price === undefined) return;
for (const trade of manager.getSymbolPendingTrades(symbol)) {
if (trade.position !== 'buy' || trade.filledQty <= 0) continue; // entry not filled yet
const stop = trade.stoploss;
if (stop === undefined || stop.absolute >= trade.enterPrice) continue; // no stop, or already at entry
if (price >= trade.enterPrice * 1.02) updateStoploss(manager, trade, stop, trade.enterPrice);
}
});
Reading a take-profit requires narrowing on its type first: value exists on 'fixed' and 'trigger', not on 'trailing'. A stop's initial and absolute fields are readable without narrowing.
Record why
Every openTrade should carry a comment naming the rule and the values that fired it, and every closeTrade a cause: they are the only trace of the agent's reasoning in the trades table. Keep them to one line.
manager.openTrade({ symbol, position: 'buy', type: 'MARKET', positionSize: { type: 'proportion', proportion: 0.1 },
comment: `EMA20 ${fast.toFixed(2)} crossed above EMA50 ${slow.toFixed(2)}` });
manager.closeTrade(trade, { cause: `RSI ${rsiValue.toFixed(1)} above 70` });
closeTrade's second argument is { cause, delta? } and nothing else. placeOrder has no comment field.
Positions the agent did not open
An agent can manage what you already hold ("trail a stop on everything I own", "close all positions on Friday"):
- Declare no assets. Iterate
manager.getPositions()each cycle; never snapshot the holdings into the code. - Use
position.symbolfor trading and price calls andposition.ticker ?? position.symbolin messages. - The entry price is
manager.getAveragePrice(position.symbol); the direction is the sign ofposition.qty. - These positions have no engine stop. Keep the stop level in the status bag, only ever tighten it, and exit with
manager.closePosition(symbol)when price crosses it. Never exit with an opposite-sideplaceOrder: on a live account, if the position changed meanwhile, that order could open a reverse position;closePositioncannot. - Before exiting, skip symbols that already have a pending exit order on the closing side, and forget a stored level only once the position is actually gone (a refused exit must fire again on the next tick).
- Run the check from a
manager.cron('* * * * *', …). It cannot be backtested (a backtest starts with an empty wallet): validate it on a demo portfolio that holds positions.
A complete example is in Examples.
Live accounts
LIMITandMARKET_TRIGGERorders exist only on paper for now; a live broker acceptsMARKETonly (aLIMITis skipped: the call returnsundefinedand the journal says so; aMARKET_TRIGGERthrows). To approach a price, watch the price and send aMARKETorder when it reaches your level. That level is a trigger, not a limit: the order fills at what the market offers when it arrives, which can be worse than your level. For a stop on a live position, keep the level yourself and exit withclosePosition, or use a trade's engine-watched stop.- Engine stops are watched while the agent runs; they are not orders resting at the broker.
- Fills arrive asynchronously and can be partial. Confirm an exit with
onOrderExecutedand the order'sfilledQty, not by a missing position alone. - Size live orders from
getAvailableCapital()or the relevantgetBalances()row, never fromgetReportedEquity()or buying power.