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.

TypeAxisMeaning
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

  1. Identical types connect. number → number, candle → candle.
  2. any connects to anything, in either direction.
  3. number|candle inputs accept a number or a candle source.
  4. signal → level is allowed. A signal<boolean> may feed a boolean input; a signal<number> may feed a number (or number|candle) input. At the instant a pulse fires it has a readable value, so a level consumer can read it directly.
  5. level → signal is REJECTED. A plain boolean may not feed a signal<boolean> input (likewise number → 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 > sma is 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.up fires 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*.

TickWhen it happensctx.barClosedIndicator 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. 4h on a 1h graph, or 1d on a 15m graph). 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, including exec.targetPosition (its fireMode edge latch, fill tracking and, for exec.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 — once trip latches 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.maxPositionSize drops orders on its coin that would push the coin's notional past maxNotional and 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's intent and a boolean ok. When ok is false, the engine drops *that exec node's* intent (managed places included) at the barrier, gating on the node's explicit ok output. This is a real per-intent gate, not a passthrough.
  • risk.maxDailyLoss blocks 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.maxTradesPerDay blocks new entries after maxEntries opening fills in the UTC day, optionally also while the losing streak is at least pauseAfterLosses.

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.

Core Concepts · Docs | Borsa