Core Concepts
The mental model behind Strategy Studio — port types, signal vs level semantics, the two-tick clock, per-node timeframes, determinism, and the risk submit barrier.
Six ideas explain almost everything about how a Studio graph behaves. Read this once and the Node Reference reads itself.
Port types (the 9)
Every output port and every input port has one of 9 static types. An edge is only allowed if the source type is compatible with the destination type — invalid wires are rejected as you draw them, not at runtime.
| Type | Axis | Meaning | |
|---|---|---|---|
number | level | A scalar value/state readable on any tick (price, an indicator output, a size). | |
`number\ | candle` | level | Accepts a number or a candle. Indicators use it for value; a connected candle is reduced via the source param. |
boolean | level | A sustained true/false state. | |
candle | level | One OHLCV bar { t, o, h, l, c, v }. | |
series<candle> | level | A rolling history of candles. | |
orderIntent | level | An order produced by an exec.* node, headed for the risk barrier. | |
signal<boolean> | event | A momentary pulse — true for exactly the tick it fires. | |
signal<number> | event | A momentary numeric event — carries a value only on the tick it fires. | |
any | — | Untyped escape hatch; connects in either direction. |
The set was deliberately trimmed to these 9. There is no position, balance, orderResult, series<number>, or signal<any> type — position/balance data flows as plain number ports out of data.position / data.balance.
Compatibility rules
- Identical types connect.
number→number,candle→candle. anyconnects to anything, in either direction.number|candleinputs accept anumberor acandlesource.- signal → level is allowed. A
signal<boolean>may feed abooleaninput; asignal<number>may feed anumber(ornumber|candle) input. At the instant a pulse fires it has a readable value, so a level consumer can read it directly. - level → signal is REJECTED. A plain
booleanmay not feed asignal<boolean>input (likewisenumber→signal<number>). You must convert the level to an event explicitly — see below.
Signals (events) vs levels (states)
This is the trickiest concept and the source of most "why won't this connect?" questions.
- A level (
boolean,number) is a *sustained state*.close > smais true for as long as price stays above the average — possibly hundreds of bars in a row. - A signal (
signal<boolean>,signal<number>) is a *momentary event*. It is true/valued for exactly one tick, then gone.logic.cross.upfires once, on the single bar price crosses above the SMA.
Why it matters: exec.* nodes conceptually want events on their fire input. Since the fire port is a boolean level, wiring a sustained condition into it is allowed — and the exec nodes default to fireMode: "edge", firing once per false→true transition, so a held-true condition places one order rather than one per bar. fireMode: "level" restores fire-on-every-eligible-tick for graphs that pulse fire themselves. Inputs typed signal<> still strictly require an event.
Converting a level to an event
logic.edge is the bridge. It watches a boolean level and pulses a signal<boolean> on the tick it rises (or falls):
logic.compare (close > sma) ──level──> logic.edge (rising) ──signal──> exec.market.fire
Nodes that already emit events (you don't need logic.edge after them):
logic.cross→up/down/any(signal<boolean>)logic.edge→out(signal<boolean>)clock.bar/clock.everyN/clock.everyMs/clock.boundary→tick(signal<boolean>)ind.pivots→newHigh/newLow(signal<boolean>)ui.button→pressed(signal<boolean>)ind.crossover→cross(signal<number>: +1 up-cross bar, −1 down-cross bar, 0 otherwise)
The reverse direction (signal → level) is free: a clock.bar.tick can feed a logic.and boolean input, because a pulse *is* a readable boolean at the moment it fires.
The two-tick model
The engine runs on two kinds of tick. Most strategy logic only cares about the difference for *when state commits* and *when orders fire*.
| Tick | When it happens | ctx.barClosed | Indicator state commits? |
|---|---|---|---|
Live (intra-bar) | each WebSocket BBO / candle update | false | no (state is cloned, then discarded) |
Bar close | at the timeframe boundary | true | yes |
On a live tick, stateful indicators still *recompute* so you see a fresh value flowing, but their internal state is cloned and thrown away — so an EMA doesn't compound ten times within one bar. State only persists at bar close. Backtest is always bar-close. Paper/live see both. This is what makes a backtest and a paper run line up bar-for-bar.
Execution follows the bar by default. Every exec.* order node has fireOnBarClose (default true): it no-ops on intra-bar ticks. For intra-bar timing (a TWAP, say), use clock.everyMs and set fireOnBarClose: false on the exec node.
A handful of nodes need their state to survive intra-bar ticks (timers, edges, latches, kill switch). Those are marked liveStateful — see Stateful nodes.
Per-node timeframe (HTF)
The graph's global cadence is set by the single data.timeframe node. But data.candles, clock.bar, ind.crossover, and every indicator carry an optional timeframe override param so one node can run on a *coarser* timeframe than the rest of the graph:
- Empty = inherit the graph's global timeframe (the common case).
- A non-empty value must be coarser than and an integer multiple of the base (e.g.
4hon a1hgraph, or1don a15mgraph). The node resamples the base candle stream into higher-timeframe (HTF) bars locally, steps its math only when an HTF bar closes, and holds its last value between closes — no repaint, no lookahead.
This is the studio's multi-timeframe story. To trade a 1h chart with a 4h trend filter, set your data.timeframe to 1h and put timeframe: 4h on the filter's EMA. A finer or non-aligned override (e.g. 5m on a 1h graph) is a validation error — you cannot synthesize bars finer than your data feed.
Determinism and warmup
A graph + a seed + a data range produces exactly one event log. The runtime forbids wall-clock leaks: inside dev.js use ctx.now (not Date.now()) and ctx.rng (not Math.random()).
Warmup. Rolling indicators output NaN until their lookback buffer is full. An SMA(200) needs 200 bars before it returns a number. A backtest has no separate warmup: the first bars of the backtest range are the warmup, so make the range long enough for your slowest indicator. Paper and live runs replay recent history first (at least 50 bars, more when the graph's runtime.warmupBars asks for it). Warmup replay can never fire real actions at live start: bar counters like clock.everyN deliberately do not advance, delay queues (logic.delay / logic.delayValue) enqueue nothing, a logic.latch armed by a historical trigger is disarmed at the warmup→live transition, clock.everyMs re-anchors its schedule, and the exec nodes' fire-edge latches carry through: a signal already true during warmup does not fire when the run goes live.
Stateful nodes and warmup
Most indicators commit state only at bar close (the two-tick model). A few primitives must instead track wall-clock time or discrete events and so keep their state across every tick, including intra-bar — these are liveStateful:
- timers:
clock.everyMs,clock.everyN,clock.boundary - edges / gating:
logic.edge,logic.gate,logic.latch,logic.toggle,logic.cooldown,logic.hold,logic.sustain,logic.delay,logic.delayValue,logic.count - risk:
risk.killSwitch,risk.stop,risk.maxDailyLoss - exec: every fire-driven
exec.*node, includingexec.targetPosition(itsfireModeedge latch, fill tracking and, forexec.twap, the slice schedule) - state:
state.memory,state.var,state.extreme,state.first - a few stream-sampled nodes:
data.candles,math.window,math.batch,ind.crossover, and several rolling indicators
You rarely think about this directly; it just means timers and latches behave correctly on live data.
The risk submit barrier
Orders do not go straight from an exec.* node to the exchange. Every intent passes through a single submit barrier (Engine.applyRiskGate) after node evaluation and before any adapter sees it. The risk nodes gate there:
risk.killSwitch— oncetriplatches true, every order this tick (and after) is dropped, managed orders are deactivated, and tracked resting orders are best-effort cancelled — tripping pulls existing orders, not just new ones.risk.maxPositionSizedrops orders on its coin that would push the coin's notional pastmaxNotionaland increase exposure; exposure-reducing and reduce-only orders always pass, so the cap can never trap a position it can't close. Measure picks value at mark or cost (entry), and Shrink to fit cuts an order to the part that fits instead of dropping it.risk.maxFillSize— caps cumulative filled size per coin for the run; once reached, no further orders submit and the run stops.risk.preTradeValidate— wire an exec node'sintentand a booleanok. Whenokis false, the engine drops *that exec node's* intent (managed places included) at the barrier, gating on the node's explicitokoutput. This is a real per-intent gate, not a passthrough.
risk.maxDailyLossblocks new exposure for the rest of the UTC day once the day's PnL falls below the limit (USD, % of day-start equity, or both). Closes still pass.risk.maxTradesPerDayblocks new entries aftermaxEntriesopening fills in the UTC day, optionally also while the losing streak is at leastpauseAfterLosses.
Because gating happens at one chokepoint, the risk stage is auditable and consistent across backtest, paper, and live. A dropped order shows as rejected in the Orders tab, with the gate that blocked it.
---
Next: build something end-to-end in Your First Strategy.