Expressions
Expressions are the formulas you use inside rule conditions, order quantities, price levels, and variable assignments. They let you go beyond simple indicator-to-number comparisons and build dynamic, adaptive trading logic – all without writing code.
You enter expressions in the formula fields throughout the strategy builder. Stratifyre validates them in real time, so you will see an error message immediately if something is not right.
Arithmetic operators
Section titled “Arithmetic operators”Use standard math to build calculations:
| Operator | Meaning | Example |
|---|---|---|
+ |
Addition | price + 10 |
- |
Subtraction | price - ATR(14) |
* |
Multiplication | account.equity * 0.02 |
/ |
Division | account.balance / 100 |
% |
Modulo (remainder) | bar_index % 5 (every 5th bar) |
You can combine operators freely and use parentheses to control the order of operations:
(account.equity * 0.02) / (ATR(14) * 2)
This example calculates a risk-based position size: 2% of equity divided by twice the 14-period ATR.
Comparison operators
Section titled “Comparison operators”These are used inside conditions to compare two values. (For the full set of 15 condition operators available in the rule builder, see Rules.)
| Operator | Meaning | Example |
|---|---|---|
== |
Equal to | position.open_qty == 0 |
!= |
Not equal to | position.open_qty != 0 |
> |
Greater than | RSI(14) > 70 |
< |
Less than | RSI(14) < 30 |
>= |
Greater than or equal | volume >= 1000000 |
<= |
Less than or equal | price <= SMA(200) |
Logical operators
Section titled “Logical operators”Combine multiple comparisons into complex conditions:
| Operator | Meaning | Example |
|---|---|---|
&& |
AND – both sides must be true | RSI(14) < 30 && price > SMA(50) |
|| |
OR – at least one side must be true | RSI(14) > 70 || price < SMA(200) |
! |
NOT – inverts true/false | !session.is_rth (true outside regular hours) |
Use parentheses to make complex logic clear:
(RSI(14) < 30 && price > SMA(50)) || (MACD().histogram > 0 && volume > SMA(20, "volume") * 1.5)
Counting signals
Section titled “Counting signals”Boolean comparisons in Stratifyre expressions evaluate to numbers — 1 when true, 0 when false. That means you can add them together to count how many of a set of signals are currently active. This unlocks several patterns that would otherwise require nested boolean logic or multiple helper rules.
“Any N of M” confirmation
Section titled ““Any N of M” confirmation”Suppose you want to enter only when at least 2 of 4 independent signals agree:
(RSI(14) < 30) + (MACD().histogram > 0) + (close > SMA(50)) + (volume > AVG(volume, 20)) >= 2Each parenthesized comparison produces 1 or 0. Summing them gives the number of true signals, and >= 2 gates the rule on that count. This is a single condition — no helper rules, no nested boolean tree.
Variants:
| Goal | Pattern |
|---|---|
| At least one signal true (same as OR) | (a) + (b) + (c) >= 1 |
| All signals true (same as AND) | (a) + (b) + (c) >= 3 |
| Exactly one signal true | (a) + (b) + (c) == 1 |
| Majority of signals true | (a) + (b) + (c) + (d) + (e) >= 3 |
Weighted voting
Section titled “Weighted voting”Because the booleans are just numbers, you can weight them to give some signals more influence than others:
3 * (close > SMA(200)) + 2 * (RSI(14) < 30) + 1 * (MACD().histogram > 0) >= 4Here the long-term trend filter is worth 3 points, RSI oversold is worth 2, and MACD momentum is worth 1. The rule fires when the total score reaches 4. Pure boolean logic cannot express this kind of prioritization — you would need a separate rule for every combination of signals.
Cleaner alternatives to OR chains
Section titled “Cleaner alternatives to OR chains”Instead of writing a long chain of || operators, counting is often more readable:
// Hard to scanRSI(14) < 30 || RSI(14) > 70 || ATR(14) > ATR(14)[20] * 1.5 || volume > AVG(volume, 20) * 3
// Easier to reason about(RSI(14) < 30) + (RSI(14) > 70) + (ATR(14) > ATR(14)[20] * 1.5) + (volume > AVG(volume, 20) * 3) >= 1Both mean the same thing, but the counted form makes it trivial to change “any one” to “any two” later — just change >= 1 to >= 2.
Math functions
Section titled “Math functions”Stratifyre provides built-in math functions for common calculations:
| Function | What it does | Example |
|---|---|---|
abs(x) |
Absolute value | abs(price - SMA(20)) |
max(a, b) |
Larger of two values | max(high, high[1]) |
min(a, b) |
Smaller of two values | min(low, low[1]) |
round(x) |
Round to nearest integer | round(account.equity * 0.01 / price) |
floor(x) |
Round down | floor(account.equity / price) |
ceil(x) |
Round up | ceil(quantity * 1.1) |
pow(x, n) |
Raise to a power | pow(1.02, 12) (compound 2% monthly) |
sqrt(x) |
Square root | sqrt(variance) |
log(x) |
Natural logarithm | log(price / price[20]) (log return) |
Market data
Section titled “Market data”Current bar
Section titled “Current bar”Access the current bar’s price and volume data:
| Field | Description |
|---|---|
price |
Current price (same as close) |
open |
Open price of the current bar |
high |
High price of the current bar |
low |
Low price of the current bar |
close |
Close price of the current bar |
volume |
Volume of the current bar |
Historical values
Section titled “Historical values”For bar fields and scalar indicators, append [N] to look back N bars:
| Expression | Meaning |
|---|---|
close[1] |
Previous bar’s close |
high[2] |
High from two bars ago |
volume[1] |
Previous bar’s volume |
RSI(14)[1] |
RSI value from the previous bar |
SMA(20)[3] |
SMA value from three bars ago |
This lookback syntax applies to bar fields and scalar indicator values. Session volume profiles use two explicit history axes; see Session volume profile history below.
Multi-timeframe data
Section titled “Multi-timeframe data”Access data from a different timeframe than your strategy’s primary resolution with the canonical bars['[<InstrumentId>~]<Interval>'].<field>[<index>] shape:
| Expression | Meaning |
|---|---|
bars['D'].close[1] |
Previous day’s close for current instrument |
bars['D'].high[0] |
Current day’s high for current instrument |
bars['1h'].volume[1] |
Previous hour’s volume for current instrument |
bars['W'].open[2] |
Open from two weeks ago for current instrument |
bars['15m'].low[1] |
Previous 15-minute bar’s low for current instrument |
bars['EQ:NASDAQ:QQQ~1m'].close |
Current 1-minute close for QQQ |
bars['EQ:NASDAQ:QQQ~D'].high[1] |
Previous daily high for QQQ |
Inside an instrument-scoped rule, bars['5m'] means “5-minute bars for instrument currently being evaluated.” In portfolio-level rules with no current instrument, use the explicit instrument form such as bars['EQ:NASDAQ:QQQ~5m'].
You can also reference indicators on a different timeframe by passing a timeframe parameter. See Multi-Timeframe Strategies for details.
Session volume profile history
Section titled “Session volume profile history”SVP exposes a profile for each retained session and a snapshot for each update within that
session. Both indexes are zero-based and count backward from the newest value, so [0] means
the current value and [1] means the immediately previous value on that axis.
The index before the property selects a session. The index after the property selects a snapshot within that session:
| Expression | Meaning |
|---|---|
SVP({dataInterval: '30'}).poc |
Current session’s latest POC |
SVP({dataInterval: '30'}).poc[1] |
Previous snapshot in the current session |
SVP({dataInterval: '30'})[1].poc |
Previous session’s latest POC – typically yesterday’s final POC |
SVP({dataInterval: '30'})[1].poc[1] |
Previous session’s previous-to-last snapshot |
The same syntax works with vah and val. lookback must retain enough sessions for the
outer index you use. SVP always groups by daily session using the instrument’s exchange calendar;
dataInterval determines the source bars. SVP(...)[1] without a property is invalid because a
profile has multiple named outputs.
Rolling volume profile
Section titled “Rolling volume profile”VP computes one rolling profile from a fixed number of trailing bars. lookback counts bars,
and interval selects the source-bar resolution; it does not reset at session boundaries.
VP({lookback: 20, interval: '30'}).pocVP({lookback: 50, interval: '1h'}).vahPercentage changes
Section titled “Percentage changes”Built-in fields for common percentage-change calculations:
| Field | Description |
|---|---|
pct_change_day |
Percentage change since the start of the current day |
pct_change_week |
Percentage change since the start of the current week |
pct_change_month |
Percentage change since the start of the current month |
pct_change_year |
Percentage change since the start of the current year |
You can also calculate custom percentage changes:
(close - close[5]) / close[5] * 100 – Percentage change over the last 5 bars.
Account data
Section titled “Account data”Access your account’s financial information:
| Field | Description |
|---|---|
account.balance |
Current account balance |
account.equity |
Balance plus unrealized profit and loss |
account.available_margin |
Margin available for new positions |
account.used_margin |
Margin currently in use |
account.total_realized_pnl |
Total realized profit and loss |
account.total_unrealized_pnl |
Total unrealized profit and loss |
account.total_commission_paid |
Total commissions paid |
account.total_drawdown_pct |
Maximum drawdown as a percentage |
Common use: Risk-based position sizing that adapts to your current equity:
account.equity * 0.02 / ATR(14) – Risk 2% of equity per trade, with stop distance based on ATR.
Position data
Section titled “Position data”Access information about the current position for the instrument being evaluated:
| Field | Description |
|---|---|
position.open_qty |
Open quantity (positive = long, negative = short, zero = flat) |
position.market_value |
Current market value of the position |
position.unrealized_pnl |
Unrealized profit or loss |
position.realized_pnl |
Realized profit or loss |
position.margin |
Margin requirement for the position |
position.positionReturnPct |
Return as a percentage of position size |
position.accountReturnPct |
Return as a percentage of account equity |
position.drawdown |
Position drawdown from peak |
position.commission_paid |
Commissions paid for this position |
Common use: Profit-target exit:
position.positionReturnPct > 5 – Exit when the position is up 5%.
Common use: Stop-loss exit:
position.unrealized_pnl < -(account.equity * 0.01) – Exit when loss exceeds 1% of equity.
Time and session data
Section titled “Time and session data”Time fields
Section titled “Time fields”| Field | Description |
|---|---|
time.min_since_midnight |
Minutes elapsed since midnight |
time.day_of_week |
Day of the week (0 = Sunday, 6 = Saturday) |
time.min_since_rth_open |
Minutes since regular trading hours opened |
time.min_since_eth_open |
Minutes since extended trading hours opened |
Session fields
Section titled “Session fields”| Field | Description |
|---|---|
session.is_rth |
True during regular trading hours |
session.is_eth |
True during extended trading hours |
Common use: Only trade during the first two hours of the session:
time.min_since_rth_open < 120
Common use: Avoid trading on Mondays:
time.day_of_week != 1
Order data
Section titled “Order data”Access information about your pending orders:
| Field | Description |
|---|---|
orders.pending_buy_qty |
Total quantity of pending buy orders |
orders.algo_active |
Whether a parent execution algorithm is running for the current instrument, including between slices |
orders.pending_sell_qty |
Total quantity of pending sell orders |
orders.stop_loss_price |
Price of the active stop-loss order |
orders.take_profit_price |
Price of the active take-profit order |
orders.limit_buy_price |
Best (highest) pending limit buy price |
orders.limit_sell_price |
Best (lowest) pending limit sell price |
Order-relative price levels
Section titled “Order-relative price levels”The takeProfitPrice and stopLossPrice fields on a bracket action are expressions that resolve to quote-price levels. They are not cash amounts. When the engine evaluates either field, order.entry_price is available as the effective entry level for the submitted order:
| Order type | order.entry_price |
|---|---|
| Market | Current market price |
| Limit | The limit price |
| Stop | The stop price |
| Stop-Limit | The limit price |
For a fixed offset, + and - use price units (points), not dollars and not a count of ticks. For example, order.entry_price + 40 means 40 price points above entry. It does not mean 40 ticks. To express a tick distance, multiply the number of ticks by the instrument’s minimum tick size.
ES example
Section titled “ES example”Assume one ES contract with an entry at 5000.00. ES has a 0.25 minimum tick, so:
+40ticks =40 * 0.25=+10price points-16ticks =-16 * 0.25=-4price points
For a long position, the expressions and resulting levels are:
| Field | Expression | Result |
|---|---|---|
takeProfitPrice |
order.entry_price + 10 (or order.entry_price + 40 * 0.25) |
5010.00 |
stopLossPrice |
order.entry_price - 4 (or order.entry_price - 16 * 0.25) |
4996.00 |
For a short position, the signs reverse: take profit is below entry and stop loss is above it.
| Field | Expression | Result |
|---|---|---|
takeProfitPrice |
order.entry_price - 10 |
4990.00 |
stopLossPrice |
order.entry_price + 4 |
5004.00 |
The backtest engine aligns submitted order prices to the instrument’s minimum tick. For bracket legs, it uses conservative directional rounding when a calculated level falls between ticks: a long take-profit is rounded down and its stop-loss up; for a short position, the take-profit is rounded up and the stop-loss down.
Price distance and dollar P&L are separate calculations. Gross futures P&L is determined by price change, quantity, and the instrument multiplier: price_change * quantity * multiplier. For standard ES, the multiplier is $50 per price point, so 40 ticks (10 points) is $500 per contract before fees and slippage.
Custom variables
Section titled “Custom variables”Custom variables let you store values in one rule and use them in another. This is how you build logic that spans multiple rules — for example, recording your entry price and then referencing it in a trailing-stop calculation.
Variables are not just a convenience feature in Stratifyre — they are the central abstraction that lets a deliberately simple rule model express arbitrarily complex trading logic. Understanding why they were designed this way will save you from reaching for features that don’t exist (and don’t need to).
Why variables exist
Section titled “Why variables exist”Most rule-based trading platforms eventually hit a wall: users want to express something that doesn’t fit a single condition — sequential patterns, multi-step confirmation, state that persists across bars, cross-instrument coordination, counters, latches. The usual response is to keep bolting features onto the rule model: nested boolean trees, sub-rules, scripting escape hatches, DSL extensions. Every addition makes the engine harder to reason about, harder to debug, harder for LLMs to generate correctly, and harder for new users to learn.
Stratifyre took the opposite path. Rather than growing the rule model, we gave you persistent named state (SET_LOCAL_VAR and SET_GLOBAL_VAR) and made it a first-class citizen of the expression language. This one primitive collapses a surprising amount of complexity:
- Sequential logic — “X happens, then Y happens relative to X” becomes two rules connected by a latch variable.
- Counters — “enter on the third pullback of the day” is
vars.pullbackCount += 1in one rule andvars.pullbackCount == 3in another. - Latches and state machines — “arm on breakout, disarm on reversal” is literally two rules that flip a flag.
- Cross-instrument coordination — a global variable lets AAPL’s rule react to what MSFT’s rule did.
- Derived intermediate values — store
riskBudget = account.equity * 0.02once and reference it everywhere instead of re-computing it in every condition. - Grouped boolean logic — compute
(A AND B)as one flag and(C AND D)as another, then OR them in a consumer rule. (See Expressing complex boolean logic in the Rules guide.)
What variables unlock that the rule model alone cannot
Section titled “What variables unlock that the rule model alone cannot”Some things genuinely cannot be expressed with conditions and operands, no matter how sophisticated. Variables are the only path:
-
Ordering — “RSI crossed 30 before price broke the prior high.” Boolean logic has no notion of before and after. A latch variable records that the first event happened, and a second rule watches for the follow-up.
-
Accumulated state — “Enter only after 3 consecutive bars of rising volume” needs a counter that increments when the condition holds and resets when it doesn’t. There is no expression that reaches back an arbitrary number of bars to test “were the last N all true.”
-
Cross-rule memory — “Don’t re-enter for 10 bars after a stop-out.” The exit rule stamps
vars.lastStopBarIndex, and the entry rule gates onbar_index - vars.lastStopBarIndex > 10. -
Portfolio-level coordination — “Stop all new entries once 5 positions are open across the strategy.” A global counter incremented on entry and decremented on exit, checked in every entry rule’s conditions.
-
Anchored levels — “Trail the stop behind the highest high since entry.” Record
vars.highWaterMark = max(vars.highWaterMark, high)every bar, and compute the stop asvars.highWaterMark - 2 * ATR(14).
None of these are “workarounds for missing features” — they are the intended way to do stateful trading logic. A nested boolean tree couldn’t express any of them.
Why this design scales better than alternatives
Section titled “Why this design scales better than alternatives”A few concrete reasons the variable-based model outperforms bigger, more complex rule languages in practice:
- Inspectable state. When a strategy misfires, you can look at each variable’s current value and see exactly which flag was set, which counter was at what value, and which latch was armed. A 40-line nested boolean expression offers none of that — you see only its final
true/false. - Named intent. A variable called
pullbackSignalorregimeIsTrendingdocuments what the strategy is trying to do. A bare(RSI(14) < 30 && close > SMA(200))only documents what it’s checking. Over time, named flags turn into a glossary of the strategy’s concepts. - LLM-friendly. Most Stratifyre users describe their strategies in natural language and let an LLM generate the rule graph. LLMs are notoriously poor at deeply nested parentheses and precedence, but very good at breaking a description into discrete steps — which is exactly what the variable model rewards.
- Composable. A flag set by one rule is a building block for the next. You can layer three rules to build a three-step confirmation sequence without rewriting any of them. Nested expressions are atomic — changing one sub-clause often means re-architecting the whole thing.
- Zero new concepts. Everything in the variable system uses the same expression language you already know. There is no second syntax, no sub-DSL, no “when you need more power, drop into scripting mode.”
Setting a variable
Section titled “Setting a variable”In a rule’s action, choose Set Variable and provide:
- Name – A descriptive identifier (like
entryPrice,stopLevel, ortradeCount) - Value – Any expression (like
price,price - ATR(14) * 1.5, orvars.tradeCount + 1) - Scope – Local (unique to each instrument) or Global (shared across all instruments)
Reading a variable
Section titled “Reading a variable”Reference your variable anywhere in an expression using vars.:
| Expression | Meaning |
|---|---|
vars.entryPrice |
The stored entry price |
vars.stopLevel |
The stored stop-loss level |
vars.tradeCount |
A counter you are maintaining |
price - vars.entryPrice |
How far price has moved from entry |
Local vs. global variables
Section titled “Local vs. global variables”- Local variables are specific to each instrument. If your strategy trades AAPL and MSFT, each has its own
vars.entryPrice. - Global variables are shared across all instruments in the strategy. Use these for portfolio-level logic, like tracking the total number of open positions or a shared risk budget.
See Multi-Instrument Strategies for detailed examples of local and global variable usage.
Putting it all together
Section titled “Putting it all together”Here are some practical expression patterns:
ATR-based position sizing
Section titled “ATR-based position sizing”account.equity * 0.02 / ATR(14)
Allocates 2% of equity per trade, scaled by the 14-period ATR. When volatility is high, you trade fewer shares; when volatility is low, you trade more.
Dynamic stop-loss
Section titled “Dynamic stop-loss”vars.entryPrice - (1.5 * ATR(14))
Sets the stop loss at 1.5 times ATR below the entry price. Store this in a variable so your exit rule can reference it.
Intraday range filter
Section titled “Intraday range filter”(high - low) / open * 100 > 2
Only triggers when the current bar’s range exceeds 2% of the open – useful for filtering out low-volatility periods.
Equity curve protection
Section titled “Equity curve protection”account.total_drawdown_pct < 0.15
Disables new entries when the account drawdown exceeds 15%.
Consecutive-bar counter
Section titled “Consecutive-bar counter”In one rule with condition volume > volume[1], action: Set Variable risingVolumeBars = vars.risingVolumeBars + 1. In a second rule with condition volume <= volume[1], action: Set Variable risingVolumeBars = 0. A third rule gates on vars.risingVolumeBars >= 3 to fire on the third consecutive rising-volume bar. This pattern — increment-on-match, reset-on-miss, consume-on-threshold — handles any “N in a row” requirement.
Re-entry cooldown
Section titled “Re-entry cooldown”Exit rule action: Set Variable lastExitBar = bar_index. Entry rule condition: bar_index - vars.lastExitBar > 10. Prevents re-entering for 10 bars after any exit, without needing a dedicated “cooldown” feature.
High-water-mark trailing stop
Section titled “High-water-mark trailing stop”On every bar, a rule with no conditions (or a simple position.open_qty > 0 gate) sets vars.highWaterMark = max(vars.highWaterMark, high). An exit rule then uses price < vars.highWaterMark - 2 * ATR(14) as its condition. The stop trails the highest price reached since entry — anchored state that no pure expression can express.
Portfolio position cap
Section titled “Portfolio position cap”On entry: Set Variable (global) openPositions = globals.openPositions + 1. On exit: globals.openPositions - 1. Entry rule gates on globals.openPositions < 5. Caps total open positions across all instruments in the strategy.
Multi-signal confirmation
Section titled “Multi-signal confirmation”(RSI(14) < 30) + (close > SMA(200)) + (MACD().histogram > 0) >= 2
Fires when at least 2 of 3 independent signals agree — trend, momentum, and mean-reversion all voting together. See Counting signals for more patterns.
Volume spike detection
Section titled “Volume spike detection”volume > SMA(20, "volume") * 2
True when the current bar’s volume is more than double the 20-period average – a common filter for confirming breakouts.
Best practices
Section titled “Best practices”- Use parentheses generously. Even when the order of operations is technically correct, parentheses make complex expressions much easier to read and verify.
- Break long expressions into variables. Instead of one giant formula, use Set Variable actions to compute intermediate values, then reference them in your conditions.
- Watch for division by zero. If a denominator could ever be zero (like an indicator value), add a guard condition to your rule.
- Test with historical data. After writing an expression, backtest to make sure it evaluates the way you expect across different market conditions.
- Use round() for position sizing. When calculating share quantities, wrap the result in
round()to get whole numbers:round(account.equity * 0.02 / price).
