Rearview Join the waitlist

The Rearview strategy language

A strategy is one plain-text file ending in .rvw. It says which securities to look at, when to enter and exit, how to rank candidates when there are more than free slots, and how big each position is. Rearview evaluates the script on every trading day, using only what was known at that day's close, and turns it into orders for the next session.

This page is the whole language. It's written for traders and for the AI coding agents some of them use; the last section explains how an agent can check its own work.

A first script

language 1
strategy "RSI(2) pullback with market filter" {
  universe   members("SP500")                        // index members on each date
  regime     close("SPY") > sma(close("SPY"), 200)   // the market is in an uptrend
  rsi2       rsi(close, 2)
  enter long when regime and close > sma(close, 200) and rsi2 < rsi_entry
  rank by    -rsi2                                   // most oversold first
  size       10% of equity, max 10 positions
  exit when  rsi2 > 70 or bars_held >= 10
  fill       next_open
  param      rsi_entry = 10 in 5..20 step 5
}
  • language 1 comes first in every file. Version 1 scripts keep working in every later release.
  • One strategy block holds the statements, one per line, in any order.
  • regime and rsi2 are bindings: names for sub-expressions, used like built-in series. There is no =.
  • rsi_entry is a param: a number with a range, which searches and walk-forward tests can vary.
  • // starts a comment.

How a script runs

On each trading day t, at the close:

  1. Universe. It is worked out for day t: index membership, tickers and kinds as they stood that day.
  2. Exits. Every open position whose exit condition holds gets an exit order, and so does one that has been held max bars bars. Then the positions whose scale out ... when condition holds sell part of their shares. Stops, trailing stops and targets are watched during the next session.
  3. Entries. Every security in the universe whose entry condition and filter hold is a candidate. Candidates are sorted by rank by, highest first (ties go to the lower security id). They fill the free slots (max positions minus open positions), each sized by size. Before them, positions whose add when condition holds get an add. A rebalance script works differently, as Rebalancing describes.
  4. Fills. The orders fill in the next session, at the open by default (fill). Sells go first, then buys in rank order. If there isn't enough cash after a gap, the share count shrinks rather than the trade being skipped.

A value that can't be known yet, during an indicator's warm-up for instance, is unknown, and an unknown condition is never true. Nothing on day t can see day t + 1.

Statements

StatementWhat it does
universe <u>Required. The securities considered. See Universes.
filter <condition>A candidate must also meet this. Same as adding and <condition> to every enter. It never blocks an exit.
<name> <expression>A binding. Names are unique and shared with params. A binding may be used before the line that defines it.
enter long when <condition>At least one enter is required. The long entry signal.
enter short when <condition>The short entry signal. A script with both trades both sides.
exit when <condition>Closes a position of either side. exit long when and exit short when apply to one side.
stop 8%A protective stop 8% from the entry price. stop 3 x atr(20): three times the 20-day ATR at entry.
trail 10% or trail 2 x atr(14)A trailing stop, from the highest high (or, for shorts, lowest low) since entry.
target 25%A profit target 25% from the entry price.
max bars 10Close a position after 10 bars. exit when ... or bars_held >= 10 means the same.
rank by <expression>Orders candidates when more qualify than there are free slots. Higher is preferred, so rank by -rsi2 prefers the lowest RSI.
size 10% of equityEach position is 10% of equity at the signal day's close.
size equal weightEquity divided equally across max positions.
size $5000 per positionA fixed amount per position.
size risk 1% with stopRisk 1% of equity per position, with the distance taken from the ATR stop, which is then required.
size weight <expression>The fraction of equity given by the expression, per security and day (0.05 is 5%).
max positions 10At most 10 open positions. It may also end the size line: size 10% of equity, max 10 positions. The default is 10, with a warning.
max position 25%No position may be more than 25% of equity. Default 100%.
same_bar target_firstWhen a stop and the target both lie inside one day's range, the target fills. The default, same_bar stop_first, assumes the stop was hit first.
flat at closeHold nothing overnight: every open position exits at the close of its session's last bar. See Intraday bars.
rebalance monthlyRebalance on the first trading day of each month: hold the best-ranked candidates, each resized to its size. Also weekly, quarterly and every 21 days; , band 20% skips small resizes, and , keep top 15 keeps a holding while it ranks 15th or better. See Rebalancing.
add when <condition>Add one more size to a held position whose condition holds. See Scaling in and out.
max adds 2At most 2 adds per trade. Default 1.
scale out 50% when <condition>Sell half of a position, once per trade, when the condition holds.
scale out 50% at 10%Sell half of a position, once per trade, at a partial profit target 10% from the entry price.
scale out 33% at 10%, 33% at 20%A ladder: sell a third at 10% from the entry price and another third at 20%, each a share of the position held at the first step. 2 to 8 steps, their levels rising.
max scale outs 3With scale out ... when: repeat the step up to 3 times a trade. A number from 1 to 8, not a param.
fill next_openOrders fill at the next session's open. Default.
fill next_closeOrders fill at the next session's close.
fill same_closeOrders fill at the signal day's own close. It isn't tradable, so results are marked approximate and these orders never appear in a next-day order list.
entry marketMarket orders. Default.
entry limit <price>A limit order at a price worked out on the signal day, such as entry limit close * 0.98. It only works with fill next_open and lasts one day.
entry stop <price>A stop order to enter on strength, such as entry stop high + $0.05. It only works with fill next_open and lasts one day.
param <name> = <default> in <low>..<high> step <step>A tunable number. See Params.

Every statement except bindings and params may appear once. enter long, enter short, exit, exit long and exit short each count as their own statement.

A strategy with no way to exit (no exit condition, stop, trail, target or max bars) only closes positions when the data ends, and gets a warning.

bars_held

bars_held is allowed only as a top-level or term of an exit, in the form bars_held >= 10, where the number is a whole number or a param. The checker turns it into max bars 10. Anywhere else it is an error. In a strategy that trades both sides, it can't be in exit long or exit short either, because max bars holds for both sides; put it in exit when.

Values and units

Every expression has a unit, and the checker refuses combinations that make no sense, such as comparing a price with an RSI.

UnitMeaningExamples
Pricedollars per shareclose, sma(close, 20), atr(14), close - open
Volumesharesvolume, sma(volume, 20)
Dollarsan amount of moneydollar_volume, adv(20), $5000, close * volume
Percenta 0 to 100 scalersi(close, 2), stoch_k(14), rank_pct(x)
Ratioa fractionclose / open, roc(close, 10), ibs(), 10%
Counta whole number of thingsbucket(x, 10), count(close > open, 5)
Booltrue or falseany comparison, crosses_above(a, b)
Numbera plain number2, 0.5, a param; it takes on the other side's unit
  • Percent literals are fractions. 10% is 0.10, a Ratio. The RSI family runs from 0 to 100 instead, so write rsi(close, 2) < 10; rsi(close, 2) < 10% is an error. x * 100 turns a Ratio into a Percent and x / 100 turns a Percent into a Ratio.
  • Money literals such as $5 read as a price next to a price: close > $5.
  • Comparisons (< <= > >= == !=) need the same unit on both sides, or a plain Number on one side.
  • Arithmetic:

    • + and - keep the unit;
    • Price / Price is a Ratio;
    • Price * Volume is Dollars;
    • multiplying or dividing by a Number, Ratio or Count keeps the unit.
  • Conditions of filter, enter and exit must be Bool, and rank by must be a number. Combine conditions with and, or and not.

x[k] is the value k bars ago: close[1] is yesterday's close, and (close > open)[1] is whether yesterday was an up day. k is a whole number from 0 to 10,000, written out; to go back a param's number of bars, use lag(x, n).

Syntax

language 1 comes first, then exactly one strategy "name" { ... } or scan "name" { ... } block. Blank lines and // comments can go anywhere, before language 1 too.

Lines

A statement ends at the end of its line, or at the } that closes the block. It carries on over more lines only inside ( ) or [ ]. Anywhere else, a line that ends with an operator (and, or, +, > and the rest) or a comma is an error, so put a long condition in parentheses to wrap it:

language 1
strategy "A long entry condition" {
  universe   members("SP500")
  enter long when (close > sma(close, 200)
                   and rsi(close, 2) < 10
                   and volume > sma(volume, 50))
  size       10% of equity, max 10 positions
  max bars   10
}

Names

Binding and param names are made of ASCII letters, digits and _, and don't start with a digit. Case matters: Close isn't close. These words belong to the language, and no binding or param can take one:

language strategy universe filter enter exit stop target trail max rank
size fill entry param and or not except when by of in step long short
per with all stocks etfs crypto futures forex members symbols sector
industry same_bar flat rebalance add scale
open high low close volume dollar_volume vwap time bar_of_day bars_held

Words that mean something at only one point of one statement, such as equity, weight, market, next_open or exchange, are free to use as names. So are function names, because a call always has parentheses: sma rsi(close, 2) makes a binding named sma, and sma(close, 20) is still the average.

Numbers, times and strings

  • Numbers are written 20 or 0.5. There is no exponent (1e5), no leading point (write 0.5, not .5) and no separator (write 1000, not 1,000 or 1_000). A - in front is the minus operator.
  • Whole numbers have no point. Where one is needed (a window, a count, x[k], bars_held >= N, language 1), write 10, not 10.0.
  • Percents and money: 10% and $5000, with the % or $ touching the number (see Values and units). A param name with % straight after it, such as stop_pct%, reads the param as a percentage, and only where a param sets a statement, as in stop stop_pct% (see Params). % is never a remainder.
  • Times are written HH:MM, from 00:00 to 23:59: 09:30, not 9:30. See Intraday bars.
  • Strings are written in straight double quotes on one line, as in "SPY". Single quotes, and the curly quotes some editors type, are errors. A string can hold any character but ", and there are no escapes.

Operators

From the tightest to the loosest:

  1. x[k], the value k bars ago
  2. -x
  3. * and /
  4. + and -
  5. the comparisons <, <=, >, >=, == and !=
  6. not
  7. and
  8. or

So:

  • close - open * 2 is close - (open * 2).
  • a or b and c is a or (b and c).
  • Operators on the same level work from left to right: a - b - c is (a - b) - c.
  • Comparisons don't chain: write a < b and b < c, not a < b < c.
  • not is looser than a comparison but tighter than and: not close > open means not (close > open), and not a and b means (not a) and b.
  • not can't follow a comparison or arithmetic operator without parentheses: 1 < not c is an error.

A universe has operators of its own; see Universes.

Prices, splits and dividends

Rearview stores the prices you actually traded at and computes adjusted series itself, so a split or dividend never looks like a price move. Each function runs on the series that suits it:

  • As traded: the bare series and arithmetic on them. close is today's actual close and close > $5 compares it with five dollars. ibs(), adv(n) and dollar_volume are as traded too.
  • Split-adjusted: averages, ranges and bands (sma, ema, highest, atr, bollinger_*, keltner_*, stoch_k, adx and the rest of the price-based functions), plus x[k], lag, change and the cross functions. A value read on day t is in the shares of day t, so close > sma(close, 200) compares like with like across a split.
  • Total return: percentage indicators (rsi, roc, volatility, percent_rank, rsi_composite, efficiency_ratio), so neither splits nor dividends look like returns.

The adjustments only ever use the past: an indicator on day t never knows about a split after t.

When a run starts

A run starts on the first day a stock or ETF in your data has a bar, or on the start date you choose if that is later. A strategy of coins only starts on the first day a coin has a bar, and one that holds both on the earlier of the two. A strategy that can hold futures counts their bars as it counts a stock's. Days on which only an index has bars are left out: nothing could trade on them, and counting them would add years of cash to every result. Data whose market index goes back to 1927, but whose first stocks start in 1962, gives runs from 1962. A start date before that day moves to it.

The first day comes from the data alone, so every script on the same data starts on the same day and its runs compare like-for-like. A script can still read something that starts later: another ticker, such as the ETF in close("SPY") > sma(close("SPY"), 200), or the run's benchmark. Until that ticker has bars, whatever reads it is unknown, so a filter on it holds the run in cash. The run then warns SYMBOL_STARTS_LATE (or BENCHMARK_UNAVAILABLE for the benchmark) and names the day the ticker starts. In the app, Start on in the warning moves the run's start to that day. On the command line, set start in the config's run.

Indicators still need their lookback after the start. When a run starts on a stock's first bar, sma(close, 200) is unknown for its first 199 bars. When you choose a later start, the bars before it count toward the lookback.

Missing values

A value that can't be computed is unknown: an indicator's warm-up, a missing bar of another ticker, a division by zero, a cross-section with nothing to rank. Unknown values spread through arithmetic, and a comparison with one is unknown. Conditions use three-valued logic:

  • false and unknown is false;
  • true or unknown is true;
  • not unknown is unknown;
  • an unknown condition never holds, so not (close > sma(close, 200)) does not hold during the warm-up either.

Other tickers

close("SPY"), open("SPY"), high(...), low(...) and volume(...) read another ticker's series. The ticker is looked up on each date, because tickers change hands. An expression built only from one ticker, such as sma(close("SPY"), 200), is computed on that security's own bars, with its own splits. On a day the ticker has no bar, its values are unknown; a stock read on a weekend by a run that trades every day is the exception (Coins and the exchange's days). A ticker the data doesn't have at all gets a SYMBOL_NOT_IN_DATA warning: whatever reads it is unknown every day, so check the spelling or add its prices.

Intraday bars

On intraday data (see Intraday bars in the import guide), a bar is one moment of a session, such as the 5 minutes from 10:05. A script runs at every bar, and every lookback counts bars, so sma(close, 200) is 200 bars, not 200 days. To work in days, use daily.

  • time is the bar's start, and 10:00 a time written HH:MM. A time compares only with a time: time >= 10:00 and time < 15:30.
  • bar_of_day is the bar's place in its session, 1 for the first.
  • daily(x) evaluates x on daily bars: the completed days plus today so far. daily(high) is today's high so far, daily(close[1]) is yesterday's close, and daily(sma(close, 200)) averages 199 completed days' closes and today's close so far.
  • vwap is the session's average price weighted by volume so far, from each bar's (high + low + close) / 3. While the session has traded nothing, it is the bar's own typical price.
  • flat at close holds nothing overnight: every open position exits at the close of its session's last bar, half days included. It can't go with rebalance.

On daily data, daily(x) is simply x, vwap is the day's typical price, and each day is a session, so flat at close sells at the close what the open bought. time and bar_of_day are an error there (INTRADAY_ONLY).

daily(vwap) isn't the session's vwap: inside daily, a whole day is one bar, so vwap there is that bar's typical price. The same goes for weekly and monthly.

Weekly and monthly bars

weekly(x) and monthly(x) evaluate x on weekly or monthly bars. On each day, those are the completed weeks (months) plus the one in progress, built from the days so far, so nothing after today is used.

  • weekly(close) is today's close;
  • weekly(high) is the week's high so far;
  • weekly(sma(close, 10)) averages nine completed week closes and today's close;
  • sma(weekly(close), 10) runs on daily bars again: it is the 10-day average of weekly(close).

Universes

The universe is worked out on each date. It limits entries; exits always apply.

UniverseSecurities
allevery stock and ETF
stocks, etfsby kind
cryptoevery coin
futuresevery futures chain
forexevery forex pair
exchange("ASX")every security of that exchange but coins; exchange("US") holds the US exchanges' and OTHER's
members("SP500")the members of an index label from your data, on that date
symbols("SPY", "QQQ")the securities holding those tickers on that date
sector("Energy"), industry("Software")the securities with exactly that label (case matters)
a and b, a or b, a except bboth, either, the first without the second

and and except bind tighter than or; use parentheses to group. An index (kind INDEX) is never traded, and a security is only in the universe on days it has a bar. all, stocks and etfs hold every exchange's stocks and ETFs. all holds no coins, futures or forex pairs, so a strategy trades them only when its universe names them, with crypto, futures, forex or otherwise; see Coins and the exchange's days, Futures and their rolls, Several exchanges' days and Currencies and forex pairs. Sector and industry are each security's one current label, not their history, and a run that uses them says so in its assumptions.

Rebalancing

A strategy with rebalance holds a target portfolio and trades back to it on a schedule:

language 1
strategy "Monthly momentum, rebalanced" {
  universe   members("SP500")
  mom        roc(close, 126)
  enter long when mom > 0
  rank by    mom
  size       equal weight, max 10 positions
  rebalance  monthly, band 20%
}
  • When. weekly, monthly and quarterly rebalance on the first trading day of each week, month or quarter: the day after a week, month or quarter has ended. The run's first day (When a run starts) is a rebalance day too, so the strategy invests at once. every 21 days counts trading days from the first day of the run. A strategy of coins only counts every day instead; one that holds coins and anything else counts the exchange's days. Orders fill at the next session, as fill says.
  • What it holds. On a rebalance day the candidates are the securities whose enter (and filter) holds, the ones already held included. They are taken by rank by, best first, up to max positions. Each is sized by size, exactly as an entry would be. A held position that is no longer among them is sold. One that is gets trimmed or topped up to its size. New ones are bought. A security that would get 0 shares, such as one with a weight of 0, doesn't take a place, and the next candidate does.
  • Keeping holdings. rebalance monthly, keep top 15 lets a held security keep its place while it ranks 15th or better among the candidates. With max positions 8, a holding that slips from 8th to 12th isn't sold; one that slips below 15th, or stops qualifying, is. New names fill only the places the kept holdings leave, best first, and when more holdings rank in the top 15 than there are places, the best-ranked keep theirs. This trades less at the edge, where names hover around the cut. keep top must be at least max positions. A holding with no bar that day, or sized to 0 shares, isn't kept. Without keep top, a holding is sold as soon as it drops out of the top max positions.
  • The band. band 20% leaves a held position alone when its resize would be smaller than 20% of its target size. Without a band every target is traded back to its exact size.
  • Between rebalance days the strategy makes no new entries. Exits, stops, targets and time exits still work every day, and a position they close leaves its place empty until the next rebalance.
  • Missing data. A held position whose security has no bar on a rebalance day keeps its shares and its place. One whose size can't be worked out that day (an unknown weight, say) keeps its shares too.
  • rebalance can't be combined with add when, scale out or limit and stop entries: it resizes positions itself.

Scaling in and out

add when adds to a winner (or to any held position whose condition holds) on a day its security is in the universe. Each add is one more size, cut so the position stays within max position. max adds caps the adds per trade (one by default). Adds are market orders, even when entries are limit or stop orders.

language 1
strategy "Breakout with pyramiding" {
  universe   members("SP500")
  enter long when close > highest(close, 50)[1]
  add when   close > highest(close, 20)[1]
  max adds   2
  size       5% of equity, max 8 positions
  max position 15%
  scale out  50% at 15%
  trail      3 x atr(20)
}

scale out sells part of a position, once per trade:

  • scale out 50% when <condition>: at the next fill after the condition holds, like an exit;
  • scale out 50% at 15%: at a partial profit target 15% from the entry price, watched during the session like target. It must be closer than target. When a stop and the partial target are both reached on one day, same_bar decides which came first.

A ladder sells in steps. scale out 33% at 10%, 33% at 20% sells a third 10% above the entry price, another third at 20%, and leaves the rest to the strategy's exits. Each step's share is of the position held when the first step sold, so the thirds stay thirds; the last step sells at most what's left. The levels rise and stay closer than target. One day can fill several steps in order, each at its level, or at the open when the day opens beyond it. With a condition, scale out 25% when rsi2 > 70 and max scale outs 3 sell a quarter on each of up to three days the condition holds, one step a day. A ladder has 2 to 8 steps.

After an add, the entry price that stops and targets are measured from is the average cost of the shares held, so they move with it. Trimming or scaling out leaves it alone. A trade's entry price in the trade list is the average of all its buys.

Cross-sectional functions

rank_pct(x), cs_zscore(x) and bucket(x, k) compare securities with each other on each date. They are computed among the securities in the universe that day whose x is known; the others get no value. The result is a series like any other, so sma(rank_pct(roc(close, 20)), 5) works.

Your own data: series("name")

series("pe_ratio") reads series/pe_ratio.csv from the data you imported: the current security's latest value dated on or before the day, or unknown before its first row. Use it for scores, fundamentals or anything else you have per security and date.

A value dated d is used from day d's close on. Date each row by when the value became known (a filing date, not the end of the quarter it describes), or the backtest will use it too early. A run fails, naming the series the data does have, when the name doesn't exist.

Params

param n = 20 in 10..50 step 10

declares n with default 20, which searches can set to 10, 20, 30, 40 or 50. The low end must be below the high end, the step must divide the range, and the default must lie within it. Values may be negative or fractional.

A param is a plain Number in expressions. It can also stand for a window (sma(close, n), atr(n)), as long as every value in its range is a whole number the function accepts.

A param can also set a statement, so a search can try different stops or position counts:

language 1
strategy "Breakout with a tunable stop" {
  universe   members("SP500")
  enter long when close > highest(close, 50)[1]
  size       equal weight, max n positions
  stop       stop_pct%
  max bars   hold
  param      stop_pct = 8 in 4..12 step 2
  param      n = 10 in 5..20 step 5
  param      hold = 20 in 10..40 step 10
}
  • Percentages (stop, trail, target, size ... of equity, size risk, max position, scale out, the band of rebalance): write the param with %. It holds the number of percent, so stop_pct = 8 is an 8% stop.
  • ATR multiples: stop k x atr(20).
  • Counts: max positions n, max bars hold, bars_held >= hold in an exit, max adds n, rebalance every n days, keep top n.

Every value in the param's range must make sense there: a percentage above 0 and within its limit, a multiple above 0, a count that is a whole number from 1 to 1,000,000.

A walk-forward can vary these too. Its joined out-of-sample run trades each fold's winning setting from the fold's first day, and the positions it already holds switch to that setting the same day: a new stop is measured from their average cost, a new ATR multiple scales the distance they had, and a lower max positions sells nothing but opens nothing new until fewer are held. The run's assumptions list what changed on which day.

Scans

A scan lists the securities that meet some conditions on a day, with their values as columns, as a stock screen does. It is a script whose header says scan instead of strategy:

language 1
scan "Oversold above the 200-day" {
  universe   members("SYN500")
  rsi2       rsi(close, 2)
  trend      close > sma(close, 200)
  filter     trend and rsi2 < rsi_max
  rank by    -rsi2
  param      rsi_max = 10 in 5..20 step 5
}
  • What a scan holds: universe (required), bindings, filter, rank by and param. A statement that trades, such as enter, exit, size, stop or rebalance, is an error (SCAN_STATEMENT). scan means something only at the top of a file, so a binding may still be named scan.
  • What it lists on day t: exactly the entry candidates a strategy with the same universe, bindings, filter and rank would have at that day's close: in the universe with a bar that day, filter true, the rank known, and never an index. It uses only what was known at the close, as a strategy does.
  • The order: best ranked first, ties going to the lower security id; by ticker without rank by.
  • A strategy can be scanned too. It then lists its entry candidates on each side it trades, with a side column: the securities it would have ranked for free slots that day. These are exactly its signals' entry rows.
  • The columns: the day, the security (id, ticker, name, kind, and currency when the data has several), side for a strategy, position and score, the day's bar as traded, every binding with a value on the security's own bars in that day's units, and each condition as true, false, or empty while unknown. A scan's binding can't be named like one of its own columns (date, id, ticker, name, kind, currency, side, position, score, passes or a bar's): that's SCAN_COLUMN_NAME. A strategy's binding that is, or that is named like a condition (enter_long, say), is listed with _binding after its name, such as score_binding (again, while another binding has that name).
  • The days: the data's last day by default, behind a sealed lockbox the last day before it; or one day, or every day of a range, a block of rows each. On intraday data a day is a moment, and a range lists every moment. More than 1,000,000 rows is refused (SCAN_TOO_LARGE), with the count.

A scan can't be run, optimized, walked forward, traded live or added to a portfolio (SCAN_NOT_RUNNABLE). It shows no returns, so it isn't counted as a try. Once the trial has ended without a licence it stops, as a run does.

In the app, ⌘R on a scan scans it, and Run ▸ Scan Candidates (⇧⌘R) scans a strategy. File ▸ New Scan starts one from a template. The results area lists the rows in a table you can sort by any column; step a day back or forward, choose Range, or Every Member to add the members that don't pass, with passes false. Export CSV… saves the rows. The evening routine can run your scans after the orders (see The evening routine).

From the command line:

rvw scan --store <data store> --script <scan.rvw> --out rows.csv
rvw scan --store <data store> --script <scan.rvw> --from 2025-11-03 --to 2025-11-28 --every --out rows.csv

It writes the CSV and prints what it wrote as JSON: the script, its kind, the days, how many members and rows, and each column with its unit and where it comes from. --date gives one day, --param sets a param, and the lockbox options are those of rvw run.

Several strategies

A portfolio file (.rvwp) runs 2 to 8 scripts together, each with its params and a weight: its share of the starting cash. In the app, add one with New Portfolio from the + menu under the navigator; on the command line, rvw run --portfolio two.rvwp.

  • Shared cash. One account. A security belongs to one strategy at a time, each strategy sizes from its weight's share of the equity, and when cash runs short the strategy listed first is funded first. Every strategy must use the same fill.
  • Fixed sleeves. One account per strategy, each starting with its weight's share of the cash. Weights under 100% leave the rest idle, earning nothing. By default nothing moves between sleeves, so a winning sleeve's share of the whole grows.

The portfolio file

The app writes the file for you. To write one yourself, it's JSON:

{
  "format": "rvw-portfolio",
  "version": 1,
  "mode": "sleeves",
  "strategies": [
    {"script": "rsi2_pullback.rvw", "weight": 500000,
     "params": {"rsi_entry": 15}, "name": "Pullback"},
    {"script": "breakout_atr.rvw", "weight": 400000}
  ]
}
  • format and version are always "rvw-portfolio" and 1.
  • mode is "shared" for shared cash or "sleeves" for fixed sleeves.
  • strategies lists the 2 to 8 scripts, each with these keys:

    • script is the script's path from the portfolio file's folder;
    • params sets params by name, each one the script declares and within its range, and can be left out to keep every param at its default;
    • weight is the strategy's share in whole millionths, so 500000 is 50%. Each is from 1 to 1,000,000, and together they add up to at most 1,000,000;
    • name labels the strategy in the results, and can be left out to use the script's file name without .rvw.
  • The order of strategies counts: in shared cash the first is funded first, sleeves pass cash in this order, and in a search the first one's weight is 0.weight.
  • rebalance, for sleeves only, is in the next section.
  • Any other key is an error.

Rebalancing between sleeves

Sleeves can move cash back towards their weights on a schedule. In the app it's Rebalance between sleeves in the portfolio editor; in the file:

"rebalance": {"every": "quarter", "band": 50000, "sell": true}

every is "week", "month", "quarter" or {"days": 21}, as for a script's rebalance, band is in millionths (50000 is 5%; 0 by default), and sell is Sell to rebalance (off by default).

  • On each rebalance day except the run's first, after the day's trading, each sleeve's target is its weight's share of the whole: the sleeves' equity plus the idle cash.
  • Sleeves above their target pass cash to those below, in portfolio order, with the idle cash last. A sleeve gives no more than its excess, the cash it holds, or its equity above its margin requirement. Without selling, a fully invested sleeve has nothing to give.
  • Sell to rebalance: a sleeve that couldn't give enough sells part of each of its longs, all in the same proportion, to raise the rest. The sales are market orders at the next session (Sold to rebalance in the trade list), and the cash moves the day after. A long without a bar that day, or with an exit order, isn't sold, and shorts never are. At that close the sleeve makes no other orders, so its own buys don't spend the cash; a strategy that rebalances itself waits for its next rebalance day.
  • A sleeve within the band of its target takes no part, and neither does a wiped-out one.
  • Each move is a TRANSFER event of the sleeve on that day, and orders made at that close size from the new equity. By strategy shows each sleeve's net cash moved. Its CAGR and the correlations leave the moves out, so a sleeve that was given cash doesn't look as if it had made it.

Searching weights

A portfolio search can vary weights as well as params. 0.weight is the first strategy's weight in percent, 1.weight the second's:

rvw search --portfolio two.rvwp --store data --out search \
  --space 0.weight=20..60:10 --space 1.weight=20..60:10
  • A combination whose weights add up to more than 100%, or that includes a 0, is left out before the search runs (a genetic or CMA-ES search skips such a setting when it comes up). search.json says how many as left_out, and only the trials that ran count for the Deflated Sharpe and PBO.
  • Varying a weight doesn't evaluate the scripts again; only the engine runs.
  • In the app, each strategy has a Weight, % row in Optimize, off until you turn it on. Use These Values writes the best weights into the file.
  • A walk-forward can vary them too. Each window picks its weights with its params, and the joined run starts with the first window's weights and changes them on each later window's first day where they differ; its assumptions list the dates. Sleeves move towards new weights by rebalancing, so turn on Sell to rebalance, or a fully invested sleeve stays where it drifted.
  • In a portfolio search a script can't have a param named weight, since 0.weight names the weight.

Coins and the exchange's days

Coins (kind CRYPTO) trade every day; stocks, ETFs and indexes trade on the exchange's days. The crypto universe holds the coins, and a strategy can hold them alone or beside anything else:

language 1
strategy "Coin trend with a market filter" {
  universe   crypto
  regime     close("SPY") > sma(close("SPY"), 200)
  enter long when regime and close > sma(close, days)
  exit when  close < sma(close, days) or not regime
  rank by    roc(close, days)
  size       30% of equity, max 3 positions
  param      days = 50 in 20..100 step 10
}
  • A run's days. A run trades every day when one of its strategies can hold a coin, and on the exchange's days otherwise. What a strategy can hold comes from its universe over the whole data: all, stocks and etfs hold no coins, crypto holds only coins, and members, symbols, sector and industry hold a coin when one matches them. A stock script on data with coins gives the same results as on the same data without them.
  • Each security keeps its own days. A stock's order made on a Friday fills on Monday, and a coin's on Saturday. A weekend isn't a halt: the order waits for its security's next session rather than being cancelled. Indicators run on each security's own bars, so sma(close, 50) covers 50 days for a coin and 50 trading days for a stock, and bars_held and max bars count bars.
  • A stock on a weekend. On a day that isn't a stock's session, close("SPY"), anything built only from it, and its series values are those of its last session, Friday's. On a session without a bar (a halt) they are unknown, as always.
  • Cross-sections rank the securities with a bar that day, so on a weekend rank_pct and bucket rank the coins among themselves.
  • Schedules. rebalance counts the exchange's days in a strategy that can hold anything besides coins, so a monthly rebalance of SPY, TLT and BTC falls on the month's first exchange day, and every day in a strategy of coins only. Rebalancing between sleeves counts the exchange's days unless every strategy holds only coins, and a sleeve's trims fill at each security's next session.
  • Long only, with cash. A short entry or short rebalance target on a coin is dropped, with the warning NO_COIN_SHORTS. In a Reg T account a coin needs the cash for it and its commission, and its margin requirement is 100% of its value.
  • A year is 365 days in a run that trades every day: Sharpe, Sortino and volatility are annualised with √365, and rolling one-year windows are 365 days long.
  • Quantities of a coin come in steps of 0.00000001 unless Coin step in Run settings says otherwise.

The examples' coins folder has this script, and a portfolio of an ETF sleeve beside a coin sleeve.

Futures and their rolls

A futures chain (kind FUTURE; see Futures in the import guide) is the contract you'd hold each day, rolled from one to the next. The futures universe holds the chains, and a strategy can hold them alone or beside stocks:

language 1
strategy "Futures trend, long or flat" {
  universe   futures
  enter long when close > sma(close, 100) and roc(close, 50) > 0
  rank by    roc(close, 50)
  stop       3 x atr(20)
  size       risk 1% with stop, max 3 positions
  exit when  close < sma(close, 50)
}
  • What a strategy can hold. futures holds only chains; all, stocks, etfs and crypto hold none; symbols, members, sector and industry hold a chain that matches them. symbols("SPY") or futures holds both. A stock script on data with futures gives the same results as on the same data without them.
  • Prices across a roll. close is the price of the contract held that day, so close > 4000 compares it. Indicators, x[k] and change read the chain adjusted by ratio at each roll, as they read a stock adjusted for splits, so sma(close, 100) never mistakes a roll's gap for a move.
  • Contracts and their value. A chain trades in whole contracts, and every amount from a quantity counts the point value: one ES contract at 5,000 is worth $250,000. Sizing by % of equity or by risk to a stop, max position and the exposure limits use that value, so an account too small for one contract sizes to none.
  • Rolls. A held chain rolls on its own on the roll's date, at the previous close: out of the old contract and into the new one, each leg paying slippage and commission. The trade carries on, flagged *Rolled*, and its stop, target and pending limit and stop prices move by the roll's ratio.
  • Daily settlement. Each position settles at every close, so its gains and losses reach cash that day. A cash account backs each contract with cash for its full value: it never borrows, and a short entry is refused. A Reg T account holds futures on margin, long or short; see Account in Run settings.
  • Days. Chains trade on the exchange's days, and a year has 252 of them.

The examples' futures folder has this script, and a portfolio of an ETF sleeve beside a futures sleeve.

Several exchanges' days

Each security trades on its exchange's days (see Exchanges and their days in the import guide): a US stock on the US exchanges', an ASX stock on the ASX's. One strategy can hold both:

language 1
strategy "Momentum across exchanges" {
  universe   stocks
  mom        roc(close, 126)
  enter long when mom > 0 and close > sma(close, 200)
  rank by    mom
  size       equal weight, max 10 positions
  rebalance  monthly, band 20%
}
  • What a strategy can hold. all, stocks and etfs hold every exchange's; exchange("ASX"), symbols, members, sector and industry hold the securities that match them, and a signals folder the ones its rows name. The run's config.json lists the exchanges as exchanges when they aren't just the US's.
  • A run's days are the days any of its strategies' exchanges trades, and every day when one can hold a coin. Another exchange's securities don't change a run that can't hold them.
  • Each security keeps its own days, as coins do: an order waits for its security's next session, over the other exchange's holidays too, and a closed day isn't a halt. On a day its exchange is closed, close("BHP") and anything built only from it are its last session's.
  • Schedules. rebalance counts the days on which every exchange of the strategy trades, so a monthly rebalance of US and ASX stocks falls on the month's first day both are open. Sleeves rebalance on the days any of their strategies would.
  • The first day is the first day a stock or ETF of the strategy's exchanges has a bar.
  • A year is 252 days on one exchange's days. On several, it is the run's days per 365.25 calendar days, half_even(365.25 × (N − 1) / S) for N days over S calendar days, or 252 for a run of under a year. The run's Assumptions say which.
  • Times of day aren't modelled: each date's bars count as known at that date's end on every exchange, so a fill at the signal's own close stays approximate.

Currencies and forex pairs

Your account has one currency, US dollars unless Run Settings ▸ Account ▸ Currency (run.currency) says otherwise. Each security's prices are in its own currency (see Currencies in the import guide).

  • What is in which currency. Prices, and every level built from them (close > $5, stops, limits, targets, the entry and exit prices of a trade), are in the security's currency. Money is in the account's: cash, equity, P&L, commissions (a per-share commission too), interest, size $10,000 and the margin minimums. A percentage commission is a share of the converted value.
  • Converting. An amount from a price converts at the day's rate from the data's forex pairs: at the pair's open for fills at the open and during the session, at its close for marks, fills at the close and sizing. A security's P&L therefore includes its currency's moves against yours.
  • No rate yet. A security whose currency has no rate yet (no pair links it, or the pair's first bar hasn't come) isn't bought, with the warning NO_RATE. A script can still read its prices.
  • A trade's return in the trade list is its P&L over its entry's value only when both are in the same currency; one priced in another currency shows none.

Forex pairs (kind FOREX) trade too. The forex universe holds them:

language 1
strategy "Forex trend, long or flat" {
  universe   forex
  enter long when close > sma(close, 100) and roc(close, 50) > 0
  rank by    roc(close, 50)
  stop       3 x atr(20)
  size       risk 1% with stop, max 3 positions
  exit when  close < sma(close, 50)
}
  • Units. A pair trades in whole units of its base currency: 100,000 of EURUSD is €100,000, worth about $110,000 at 1.10.
  • Daily settlement, as for futures: each position settles at every close in the pair's quote currency, converted. A cash account backs each pair with cash for its full value and refuses a short entry; a Reg T account holds pairs on margin, long or short, 5% of their value to open and 4% to keep unless Advanced ▸ Forex margin says otherwise.
  • Carry. Each day a pair is held it earns its base currency's interest rate and pays its quote's on its value, counted act/360, from the data's rates.csv; a short does the opposite. A currency without a rate counts 0, and the trade is flagged *Assumed rate*. Carry is part of the trade's P&L and shows among the run's costs.
  • No volume cap and no haircut. A pair's volume isn't used, and a pair that ends closes at its last close.
  • Live accounts can't trade pairs, nor a security in another currency than the account's.

The examples' world folder has these scripts, momentum on one exchange for an account in any currency, and a portfolio of an ETF sleeve beside a forex sleeve.

Functions

In the signatures:

  • x, a and b are expressions;
  • c is a condition;
  • n, m, k, fast, slow and signal are numbers written as a literal or a param, never an expression.

A window is a whole number from 1 to 10,000.

Moving averages

sma(x, n)

Simple moving average.

  • Result: the unit of x.
  • Formula: the mean of the last n values.
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: close > sma(close, 200)

ema(x, n)

Exponential moving average.

  • Result: the unit of x.
  • Formula: seeded with the sma of the first n values, then e = e + 2 / (n + 1) * (x - e).
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: ema(close, 12) > ema(close, 26)

wma(x, n)

Linearly weighted moving average: the newest value weighs n, the oldest 1.

  • Result: the unit of x.
  • Formula: (1 * x[n-1] + ... + n * x) / (n(n+1)/2).
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: close > wma(close, 20)

dema(x, n)

Double exponential moving average, which lags less than ema.

  • Result: the unit of x.
  • Formula: 2 * ema(x, n) - ema(ema(x, n), n).
  • Price series: split-adjusted.
  • Warm-up: 2n - 2 bars.
  • Example: close > dema(close, 20)

tema(x, n)

Triple exponential moving average, which lags less than dema.

  • Result: the unit of x.
  • Formula: 3 * ema(x, n) - 3 * ema(ema(x, n), n) + ema(ema(ema(x, n), n), n).
  • Price series: split-adjusted.
  • Warm-up: 3n - 3 bars.
  • Example: close > tema(close, 20)

hma(x, n)

Hull moving average: smooth, with little lag.

  • Result: the unit of x.
  • Formula: wma(2 * wma(x, n / 2) - wma(x, n), sqrt(n)), the half and the root rounded down.
  • Price series: split-adjusted.
  • Warm-up: n + sqrt(n) - 2 bars, the root rounded down.
  • Example: close > hma(close, 21)

kama(x, n)

Kaufman's adaptive moving average: close to price in a trend, nearly still when price chops.

  • Result: the unit of x.
  • Formula: k = k + sc * (x - k), where sc = (er * (2/3 - 2/31) + 2/31)^2 and er is the efficiency_ratio over n bars (0 for a flat window); seeded with the value before the first one.
  • Price series: split-adjusted.
  • Warm-up: n bars.
  • Example: close > kama(close, 10)

median(x, n)

Median of the last n values.

  • Result: the unit of x.
  • Formula: the middle of the sorted values; the mean of the two middle ones when n is even.
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: close > median(close, 20)

vwma(x, n)

Volume-weighted moving average.

  • Result: the unit of x.
  • Formula: sum(x * volume) / sum(volume) over the last n bars; unknown when the volume is 0.
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: close > vwma(close, 20)

adv(n)

Average dollar volume.

  • Result: Dollars.
  • Formula: sma(close * volume, n) in dollars as traded.
  • Price series: as traded.
  • Warm-up: n - 1 bars.
  • Example: adv(20) > $1000000

Momentum and oscillators

rsi(x, n)

Relative strength index with Wilder smoothing, 0 to 100.

  • Result: Percent.
  • Formula: average gain and loss of the changes, seeded with plain means of the first n and then avg = (avg * (n - 1) + current) / n; 100 - 100 / (1 + gain / loss), 50 with no change at all.
  • Price series: total return.
  • Warm-up: n bars.
  • Example: rsi(close, 2) < 10

roc(x, n)

Rate of change over n bars, as a fraction (0.10 is +10%).

  • Result: Ratio.
  • Formula: x / x[n] - 1.
  • Price series: total return.
  • Warm-up: n bars.
  • Example: roc(close, 60) > 0

rsi_composite(x)

A three-part short-term reversion score, 0 to 100.

  • Result: Percent.
  • Formula: the mean of rsi(x, 3), a 2-bar RSI of the up-or-down streak length, and the percentile of today's 1-day change among the last 100.
  • Price series: total return.
  • Warm-up: 101 bars.
  • Example: rsi_composite(close) < 15

stoch_k(n)

Stochastic %K: where the close sits in the range of the last n bars, 0 to 100.

  • Result: Percent.
  • Formula: 100 * (close - lowest low) / (highest high - lowest low) over n bars; unknown for a flat range.
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: stoch_k(14) < 20

stoch_d(n, m)

Stochastic %D: %K smoothed.

  • Result: Percent.
  • Formula: sma(stoch_k(n), m).
  • Price series: split-adjusted.
  • Warm-up: n + m - 2 bars.
  • Example: stoch_k(14) > stoch_d(14, 3)

cci(n)

Commodity channel index of the typical price.

  • Result: Number.
  • Formula: with tp = (high + low + close) / 3: (tp - sma(tp, n)) / (0.015 * mean deviation of tp).
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: cci(20) < -100

mfi(n)

Money flow index: a volume-weighted RSI, 0 to 100.

  • Result: Percent.
  • Formula: typical price times volume, counted as up or down flow as the typical price rises or falls; 100 - 100 / (1 + up / down) over n bars, 50 with neither.
  • Price series: split-adjusted.
  • Warm-up: n bars.
  • Example: mfi(14) < 20

ppo(x, fast, slow)

Percentage price oscillator: the gap between a fast and a slow average, as a percent of the slow one.

  • Result: Percent.
  • Formula: 100 * (ema(x, fast) - ema(x, slow)) / ema(x, slow).
  • Price series: split-adjusted.
  • Warm-up: slow - 1 bars.
  • Example: ppo(close, 12, 26) > 0

trix(x, n)

TRIX: the one-bar percent change of a triple-smoothed average.

  • Result: Percent.
  • Formula: 100 * (t / t[1] - 1) where t = ema(ema(ema(x, n), n), n).
  • Price series: total return.
  • Warm-up: 3n - 2 bars.
  • Example: trix(close, 15) > 0

cmo(x, n)

Chande momentum oscillator, -100 to 100.

  • Result: Percent.
  • Formula: 100 * (up - down) / (up + down) over the last n changes, where up and down add up the rises and the falls; 0 when nothing changed.
  • Price series: total return.
  • Warm-up: n bars.
  • Example: cmo(close, 14) > 50

stoch_rsi(x, n)

Stochastic RSI, 0 to 100: where the rsi sits in its own range of the last n bars.

  • Result: Percent.
  • Formula: 100 * (r - lowest(r, n)) / (highest(r, n) - lowest(r, n)) with r = rsi(x, n); unknown when that range is flat.
  • Price series: total return.
  • Warm-up: 2n - 1 bars.
  • Example: stoch_rsi(close, 14) < 20

williams_r(n)

Williams %R, -100 to 0: how far the close sits below the top of the last n bars' range.

  • Result: Percent.
  • Formula: -100 * (highest(high, n) - close) / (highest(high, n) - lowest(low, n)); unknown for a flat range.
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: williams_r(14) < -80

cmf(n)

Chaikin money flow, -1 to 1: whether the volume traded nearer the high or the low.

  • Result: Ratio.
  • Formula: sum(m * volume) / sum(volume) over the last n bars, with m = ((close - low) - (high - close)) / (high - low), 0 for a flat bar.
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: cmf(20) > 0.1

force_index(n)

Force index: price change times volume, smoothed, in dollars.

  • Result: Dollars.
  • Formula: ema((close - close[1]) * volume, n).
  • Price series: split-adjusted.
  • Warm-up: n bars.
  • Example: force_index(13) > $0

percent_rank(x, n)

Where today's value ranks among the previous n, 0 to 100.

  • Result: Percent.
  • Formula: 100 * (number of the previous n values below today's) / n.
  • Price series: total return.
  • Warm-up: n bars.
  • Example: percent_rank(close, 100) > 95

ibs()

Internal bar strength: where the close sits in today's range, 0 to 1.

  • Result: Ratio.
  • Formula: (close - low) / (high - low); unknown when high equals low.
  • Price series: as traded.
  • Warm-up: none.
  • Example: ibs() < 0.2

Volatility, ranges and bands

true_range()

Today's range, including any gap from yesterday's close.

  • Result: Price.
  • Formula: max(high, close[1]) - min(low, close[1]).
  • Price series: split-adjusted.
  • Warm-up: 1 bar.
  • Example: true_range() > 2 * atr(20)

atr(n)

Average true range with Wilder smoothing.

  • Result: Price.
  • Formula: seeded with the mean of the first n true ranges, then (atr * (n - 1) + true range) / n.
  • Price series: split-adjusted.
  • Warm-up: n bars.
  • Example: atr(14) / close < 3%

stdev(x, n)

Population standard deviation of the last n values.

  • Result: the unit of x.
  • Formula: the square root of the mean squared distance from sma(x, n).
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: stdev(close, 20) / close < 2%

zscore(x, n)

How many standard deviations today's value is from its n-bar mean.

  • Result: Ratio.
  • Formula: (x - sma(x, n)) / stdev(x, n).
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: zscore(close, 20) < -2

volatility(n)

Annualised volatility of daily returns.

  • Result: Ratio.
  • Formula: the sample standard deviation of the last n daily log returns, times the square root of 252.
  • Price series: total return.
  • Warm-up: n bars.
  • Example: volatility(60) < 30%

highest(x, n)

The highest value of the last n bars, today included.

  • Result: the unit of x.
  • Formula: max(x[n-1], ..., x).
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: close >= highest(close, 252)

lowest(x, n)

The lowest value of the last n bars, today included.

  • Result: the unit of x.
  • Formula: min(x[n-1], ..., x).
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: close <= lowest(close, 20)

donchian_high(n)

The highest high of the last n bars.

  • Result: Price.
  • Formula: highest(high, n); use [1] to leave today out.
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: close > donchian_high(55)[1]

donchian_low(n)

The lowest low of the last n bars.

  • Result: Price.
  • Formula: lowest(low, n).
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: close < donchian_low(20)[1]

bollinger_upper(x, n, k)

The upper Bollinger band.

  • Result: the unit of x.
  • Formula: sma(x, n) + k * stdev(x, n).
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: close > bollinger_upper(close, 20, 2)

bollinger_lower(x, n, k)

The lower Bollinger band.

  • Result: the unit of x.
  • Formula: sma(x, n) - k * stdev(x, n).
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: close < bollinger_lower(close, 20, 2)

keltner_upper(n, k)

The upper Keltner band.

  • Result: Price.
  • Formula: ema(close, n) + k * atr(n).
  • Price series: split-adjusted.
  • Warm-up: n bars.
  • Example: close > keltner_upper(20, 2)

keltner_lower(n, k)

The lower Keltner band.

  • Result: Price.
  • Formula: ema(close, n) - k * atr(n).
  • Price series: split-adjusted.
  • Warm-up: n bars.
  • Example: close < keltner_lower(20, 2)

skew(x, n)

Skewness of the last n values, 0 when they are symmetric.

  • Result: Ratio.
  • Formula: m3 / m2^1.5, where m2 and m3 are the mean squared and cubed deviations from the mean; unknown for a flat window.
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: skew(roc(close, 1), 60) < -0.5

ulcer(x, n)

Ulcer index, in percent: how deep and how long the last n values sat under their running high.

  • Result: Percent.
  • Formula: sqrt(mean(dd^2)), where dd = 100 * (v - peak) / peak and peak is the highest value so far in the window.
  • Price series: total return.
  • Warm-up: n - 1 bars.
  • Example: ulcer(close, 14) < 5

max_drawdown(x, n)

The deepest fall inside the last n values, as a fraction (0 or negative).

  • Result: Ratio.
  • Formula: the lowest v / peak - 1, where peak is the highest value so far in the window.
  • Price series: total return.
  • Warm-up: n - 1 bars.
  • Example: max_drawdown(close, 60) > -10%

correl(x, y, n)

Correlation of two series' daily changes over the last n days, -1 to 1.

  • Result: Ratio.
  • Formula: Pearson correlation of x / x[1] - 1 and y / y[1] - 1; unknown if either is flat.
  • Price series: total return.
  • Warm-up: n bars.
  • Example: correl(close, close("SPY"), 60) < 0.5

beta(x, y, n)

How much x moves for each unit y moves, from their daily changes over the last n days.

  • Result: Ratio.
  • Formula: the covariance of the two daily changes over the variance of y's.
  • Price series: total return.
  • Warm-up: n bars.
  • Example: beta(close, close("SPY"), 252) < 0.8

Trend

macd_line(x, fast, slow, signal)

MACD line.

  • Result: the unit of x.
  • Formula: ema(x, fast) - ema(x, slow).
  • Price series: split-adjusted.
  • Warm-up: slow - 1 bars.
  • Example: macd_line(close, 12, 26, 9) > 0

macd_signal(x, fast, slow, signal)

MACD signal line.

  • Result: the unit of x.
  • Formula: ema(macd_line, signal).
  • Price series: split-adjusted.
  • Warm-up: slow + signal - 2 bars.
  • Example: crosses_above(macd_line(close, 12, 26, 9), macd_signal(close, 12, 26, 9))

macd_hist(x, fast, slow, signal)

MACD histogram.

  • Result: the unit of x.
  • Formula: macd_line - macd_signal.
  • Price series: split-adjusted.
  • Warm-up: slow + signal - 2 bars.
  • Example: macd_hist(close, 12, 26, 9) > 0

adx(n)

Average directional index: how strong the trend is, whichever way, 0 to 100.

  • Result: Percent.
  • Formula: Wilder's directional movement: +DI and -DI below, DX = 100 * |+DI - -DI| / (+DI + -DI), smoothed like atr.
  • Price series: split-adjusted.
  • Warm-up: 2n - 1 bars.
  • Example: adx(14) > 25

plus_di(n)

Plus directional indicator: the share of the range that was upward movement, 0 to 100.

  • Result: Percent.
  • Formula: 100 * smoothed(+DM) / smoothed(true range), where +DM is today's rise in the high when it beats the fall in the low, smoothed like atr.
  • Price series: split-adjusted.
  • Warm-up: n bars.
  • Example: plus_di(14) > minus_di(14)

minus_di(n)

Minus directional indicator: the share of the range that was downward movement, 0 to 100.

  • Result: Percent.
  • Formula: 100 * smoothed(-DM) / smoothed(true range), where -DM is today's fall in the low when it beats the rise in the high.
  • Price series: split-adjusted.
  • Warm-up: n bars.
  • Example: minus_di(14) < 20

aroon_up(n)

How recently the highest high of the last n + 1 bars happened, 0 to 100.

  • Result: Percent.
  • Formula: 100 * (n - bars since that high) / n; the most recent of equal highs counts.
  • Price series: split-adjusted.
  • Warm-up: n bars.
  • Example: aroon_up(25) > 70

aroon_down(n)

How recently the lowest low of the last n + 1 bars happened, 0 to 100.

  • Result: Percent.
  • Formula: 100 * (n - bars since that low) / n.
  • Price series: split-adjusted.
  • Warm-up: n bars.
  • Example: aroon_down(25) < 30

slope(x, n)

The least-squares slope of the last n values: the change per bar.

  • Result: the unit of x.
  • Formula: the slope of the straight line fitted to x against the bar number.
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: slope(close, 90) / close > 0.1%

r_squared(x, n)

How well that straight line fits, 0 to 1.

  • Result: Ratio.
  • Formula: the coefficient of determination of the same fit; unknown for a flat window.
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: r_squared(close, 90) > 0.7

efficiency_ratio(x, n)

Net movement over the path travelled, 0 (choppy) to 1 (straight).

  • Result: Ratio.
  • Formula: |x - x[n]| / sum(|x[i] - x[i-1]|) over the last n changes.
  • Price series: total return.
  • Warm-up: n bars.
  • Example: efficiency_ratio(close, 10) > 0.5

supertrend(n, k)

Supertrend line: under the price in an uptrend, over it in a downtrend.

  • Result: Price.
  • Formula: bands (high + low) / 2 ± k * atr(n) that only tighten while the trend lasts; the lower band in an uptrend and the upper in a downtrend, flipping when the close crosses it.
  • Price series: split-adjusted.
  • Warm-up: n bars.
  • Example: close > supertrend(10, 3)

linreg(x, n)

Where the least-squares line through the last n values ends.

  • Result: the unit of x.
  • Formula: the fitted line's value at the newest bar; linreg(x, n) + slope(x, n) is the next bar's.
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: close > linreg(close, 50)

Bars and windows

lag(x, k)

The value k bars ago. x[k] means the same.

  • Result: the unit of x.
  • Formula: x[k].
  • Price series: that of its argument.
  • Warm-up: k bars.
  • Example: lag(close > open, 1) and close > open

change(x, k)

The change over k bars.

  • Result: the unit of x.
  • Formula: x - x[k].
  • Price series: split-adjusted.
  • Warm-up: k bars.
  • Example: change(close, 5) > 0

crosses_above(a, b)

True on the bar a moves from at or below b to above it.

  • Result: Bool.
  • Formula: a > b and a[1] <= b[1].
  • Price series: split-adjusted.
  • Warm-up: 1 bar.
  • Example: crosses_above(close, sma(close, 50))

crosses_below(a, b)

True on the bar a moves from at or above b to below it.

  • Result: Bool.
  • Formula: a < b and a[1] >= b[1].
  • Price series: split-adjusted.
  • Warm-up: 1 bar.
  • Example: crosses_below(close, sma(close, 50))

sum(x, n)

The sum of the last n values.

  • Result: the unit of x.
  • Formula: x[n-1] + ... + x.
  • Price series: split-adjusted.
  • Warm-up: n - 1 bars.
  • Example: sum(close - open, 5) < 0

count(c, n)

On how many of the last n bars a condition held.

  • Result: Count.
  • Formula: the number of true bars in the window; unknown if any bar is unknown.
  • Warm-up: n - 1 bars.
  • Example: count(close < close[1], 5) >= 4

Daily, weekly and monthly

daily(x)

Evaluate x on daily bars: the completed days plus today so far. On daily data it is x.

  • Result: the unit of x.
  • Formula: see Intraday bars.
  • Price series: that of its argument.
  • Warm-up: that of x, in days.
  • Example: close > daily(sma(close, 20))

weekly(x)

Evaluate x on weekly bars: the completed weeks plus this week so far.

  • Result: the unit of x.
  • Formula: see Weekly and monthly bars.
  • Price series: that of its argument.
  • Warm-up: that of x, in weeks.
  • Example: weekly(close) > weekly(sma(close, 10))

monthly(x)

Evaluate x on monthly bars: the completed months plus this month so far.

  • Result: the unit of x.
  • Formula: see Weekly and monthly bars.
  • Price series: that of its argument.
  • Warm-up: that of x, in months.
  • Example: monthly(roc(close, 9)) > 0

Across securities

rank_pct(x)

Percentile of x among the universe on each date, 0 to 100.

  • Result: Percent.
  • Formula: lowest 0, highest 100; ties share their average rank.
  • Price series: that of its argument.
  • Warm-up: that of x.
  • Example: rank_pct(roc(close, 60)) > 90

cs_zscore(x)

How many standard deviations x is from the universe's mean on each date.

  • Result: Ratio.
  • Formula: (x - mean) / population standard deviation across the universe.
  • Price series: that of its argument.
  • Warm-up: that of x.
  • Example: cs_zscore(roc(close, 20)) > 1

bucket(x, k)

Which of k equal-sized groups x falls in on each date, 1 (lowest) to k.

  • Result: Count.
  • Formula: sort the universe by x; ties go to the lower security id first.
  • Price series: that of its argument.
  • Warm-up: that of x.
  • Example: bucket(roc(close, 60), 10) == 10

Arithmetic

abs(x)

Absolute value.

  • Result: the unit of x.
  • Formula: abs(x).
  • Price series: that of its argument.
  • Warm-up: none.
  • Example: abs(roc(close, 1)) < 5%

min(a, b)

The smaller of two values, which need the same unit.

  • Result: the common unit of a and b.
  • Formula: min(a, b).
  • Price series: that of its argument.
  • Warm-up: none.
  • Example: min(open, close) > sma(close, 50)

max(a, b)

The larger of two values, which need the same unit.

  • Result: the common unit of a and b.
  • Formula: max(a, b).
  • Price series: that of its argument.
  • Warm-up: none.
  • Example: max(open, close) < sma(close, 50)

Your data

series("name")

Your own number per security and day, from series/name.csv of your data.

  • Result: Number.
  • Formula: the latest value dated on or before the day; see Your own data.
  • Price series: as traded.
  • Warm-up: none.
  • Example: series("pe_ratio") < 15

Lookback

Each script has a lookback: how many trading days before a signal its inputs can reach, with every param at the top of its range. Windows add up when nested (sma(rsi(close, 14), 20) reaches 34 days), x[k] adds k, and a weekly bar counts as five days and a monthly bar as 23. On intraday data a window reaches back in bars and daily, weekly or monthly in days, so rvw check --json gives the two parts as lookback_bars and lookback_days. The lockbox uses the lookback plus the holding period to size the gap it leaves before the held-back data.

Limits

  • Nesting is limited to 64 levels.
  • An expression or universe may hold at most 400 operators.
  • Whole numbers are limited to 2^53.
  • Counts (max positions, max bars, max adds, bars_held >= N, rebalance every N days, keep top N) go from 1 to 1,000,000.
  • A param's range holds at most 100,000 steps: 0..100000 step 1 is allowed, 0..100001 step 1 isn't.

Checking a script

The checker reports every problem it finds, not just the first. Each diagnostic has a line, a column, a severity, a code and a message.

CodeMeaning
UNEXPECTED_CHARa character that belongs to no token
UNTERMINATED_STRINGa string with no closing " on its line
BAD_NUMBERa number too large
BAD_TIMEa time not written HH:MM from 00:00 to 23:59
MISSING_VERSIONthe file doesn't start with language 1
UNSUPPORTED_VERSIONa language version other than 1
UNEXPECTED_TOKENany other syntax error; the message says what was expected
CHAINED_COMPARISONa < b < c; write a < b and b < c
BAD_OPTIONan unknown word after fill, entry or same_bar
TOO_COMPLEXnesting or operators over the limits
UNKNOWN_IDENTa name that is no binding, param or series
UNKNOWN_FUNCTIONa call to a name that is no function
DUPLICATE_BINDINGa name defined twice
RESERVED_NAMEa binding or param named like a keyword or built-in series
CYCLEbindings that depend on themselves
UNIT_MISMATCHunits that can't be combined
NOT_BOOLa number where a condition is needed
NOT_NUMERICa condition where a number is needed
ARITYthe wrong number of arguments
BAD_ARGan argument of the wrong kind or out of range
BAD_LAGan index that isn't a whole number from 0 to 10,000, written out
BAD_STRINGan empty label, or a ticker or series name that isn't valid
STRING_NOT_ALLOWEDa string where only a number or series makes sense
BARS_HELD_MISUSEbars_held anywhere but a bars_held >= N exit term
MISSING_UNIVERSE, MISSING_ENTRY, MISSING_SIZEa required statement is missing
DUPLICATE_STATEMENTa statement given twice
BAD_VALUEa percentage, count or amount out of range
SIZE_NEEDS_ATR_STOPsize risk without an ATR stop
CONFLICTING_MAX_BARSmax bars and bars_held >= N disagree
BAD_PARAM_RANGEa param range that is empty, uneven, has a step not above 0 or more than 100,000 steps, or doesn't hold the default
BAD_SETTING_PARAMa param that sets a statement but whose range doesn't suit it, or a binding where a statement needs a number
REBALANCE_CONFLICTrebalance with add when, scale out, or limit or stop entries
SCALE_OUT_BEYOND_TARGETscale out ... at a percentage not below the target
KEEP_BELOW_MAX_POSITIONSrebalance ..., keep top N with N below max positions
SCALE_OUT_LADDERa scale-out ladder whose levels don't rise, more than 8 steps, or max scale outs without scale out ... when
SCAN_STATEMENTa statement that trades, such as size or enter, in a scan
SCAN_COLUMN_NAMEa scan binding named like one of the scan's own columns, such as score or ticker
UNKNOWN_PARAM, BAD_PARAM_VALUEa run asked for a param the script lacks, or a value outside its range
DEFAULT_MAX_POSITIONSwarning: no max positions, so 10 is used
NO_EXITwarning: nothing closes a position before the data ends
SAME_CLOSE_APPROXwarning: fill same_close results are approximate
PARAM_OFF_GRIDwarning: a param default not on its grid
UNUSED_BINDING, UNUSED_PARAMwarning: something nothing refers to
MAX_ADDS_UNUSEDwarning: max adds without add when

In the editor

The editor helps while you write.

  • Completion. As you type a word, a list offers what fits there. At the start of a line it offers the statements, and after a statement's first word its other words (fill offers next_open, next_close and same_close). In an expression it offers your bindings and params first, then the functions with their signatures, the series and not. Inside quotes it offers names from the open data: tickers in close("…") and symbols("…"), index labels in members("…"), sectors, industries, and your series in series("…"); behind a sealed lockbox, none whose data starts after it. ↑ and ↓ choose, Return or Tab accepts, and Esc closes the list, or shows it when none is open. A new binding's name never turns into a statement: the list closes at the space after it.
  • The argument you're on. Inside a call's parentheses, a tip above the line shows the call with the argument you're typing in bold, and what that argument takes.
  • Quick Help. Rest the pointer on a word, ⌥-click it, or choose Editor ▸ Show Quick Help (⌃⌘?) for what it is: a function's formula, price series, warm-up and an example, with Open in Help for its entry on this page; a binding's definition, unit and its figures in the last run; a param's range; a statement's forms. A problem the check found there shows at the top.
  • Jump to Definition. ⌘-click a binding or param, or choose Editor ▸ Jump to Definition (⌃⌘J), to select the line that defines it. On a function it opens the function's entry here.

What each line did

Beside the script, the app shows what each statement did over the run. The figures count member-days: a security on a day of the run when it is in the universe, so a candidate for an entry that day.

StatementFigure
universesecurities in it per day: the average and the range
filterthe share of member-days it passed
A condition bindingthe share of member-days it was true
A number bindingits median, its 10th and 90th percentiles, and a histogram of its values on member-days
enter long when, enter short whensignals: the candidates the engine could place, before rank by picks among them
add whensignals
rank bycrowded days: days with more signals than open slots (max positions less the positions open at that day's close), when the ranking chose
size, max positionspositions open at the close: the average and the most
fillthe median entry fill against the signal day's close
Exits, stops and targetsthe trades each one closed, from the trades

A binding read only as part of another ticker's expression, such as c in c close("SPY") and regime c > sma(c, 200), shows nothing of its own. The binding that uses it shows the figure. Above 50,000 member-days, the median, percentiles and histogram come from an evenly spaced sample of them. The figures are the same whatever the number of threads, and they never change the run: rvw run --lines adds them to a run folder as lines.json.

Writing scripts with an AI agent

Rearview makes no AI calls of its own. If you use an AI coding agent, point it at this file and at the checker. Then it can write a script, check it and fix it without you.

  1. Give the agent this reference. In Rearview, File ▸ Save Language Reference to Project writes it into the project folder as rearview-language.md. An agent opened in that folder finds it next to the scripts.
  2. Give it the checker. The command-line tool is inside the app at Rearview.app/Contents/Helpers/rvw; Help ▸ Copy Command-Line Tool Path copies the full path.
  3. Let it loop: write the script, run rvw check <script.rvw> --json, fix every error, and check again until "ok" is true.

rvw check --json prints one object:

{
  "ok": false,
  "diagnostics": [
    {"severity": "ERROR", "code": "UNIT_MISMATCH",
     "message": "cannot compare Price with Percent; both sides need the same unit",
     "span": {"line": 4, "col": 19, "start": 59, "end": 80}}
  ],
  "strategy": null, "params": null, "lookback": null, "max_bars": null, "fill": null,
  "setting_params": null, "rebalance": null, "adds": null, "scale_out": null,
  "kind": "strategy"
}
  • The result: ok is true when no diagnostic is an error; warnings don't stop a run.
  • Where a problem is: line and col start at 1.
  • When ok is true: strategy is the script's name, params lists each param with its range, lookback is the lookback in trading days, and max_bars and fill are the holding limit and fill timing. setting_params lists the params that set a statement. rebalance gives the period and its length in trading days, adds the most adds per trade, and scale_out the share sold and its partial target (fractions); each is null when the script doesn't use it.
  • What the file is: kind is strategy or scan, from its header, even when the check fails.
  • Exit code: 0 when there are no errors, 1 otherwise.

To try a script, run it in its project:

rvw run --project <project folder> --script <script.rvw>

--script is the script's path in the project, such as rsi2_pullback.rvw. The run is the one Run makes in the app: on the project's data, with its run settings, the script's saved params and its lockbox. It goes into a new folder in the project's runs/, which holds the trades, the daily equity and a summary.json of the metrics. The run is also added to the project's tries log, so the luck figures count it like any other try (see Every try counted): a hundred variations an agent tries count as a hundred tries.

rvw search, rvw walkforward and rvw scan take --project the same way, and a search or walk-forward goes into searches/. The project decides the data, the settings, the params, the lockbox and where the folder goes, so --store, --out, --param, --config and --lockbox-start can't be given with it; a scan still needs --out, since it is no try. To see what the project has run:

rvw runs --project <project folder>

It lists the runs, newest first, each with its script, when it ran, and its CAGR, Sharpe ratio, deepest fall and trades.

A run outside a project, rvw run --store <data store> --script <script.rvw> --out <folder>, is counted nowhere. The data store of each library entry is under ~/Library/Application Support/Rearview/Data/<name>/store.

The command-line tool shares the app's licence and trial. rvw licence says which applies; rvw licence install <file> takes a licence as the app's Rearview ▸ Licence… does. Once the trial has ended without a licence, rvw run, rvw search, rvw walkforward, rvw scan, rvw signals and rvw eval say so and stop, while rvw check, reports and exports of saved runs keep working.

To see what an expression computes, day by day, for a few tickers:

rvw eval --store <data store> --expr "rsi2=rsi(close, 2)" --expr "trend=sma(close, 200)" --tickers AAPL,MSFT --out values.csv

It writes date,ticker,rsi2,trend with one row per ticker and day, and an empty value where the expression is unknown, such as during an indicator's warm-up. An expression must be a number; to look at a condition, look at the numbers it compares.

To see why a run did or didn't enter a security on a day, trace it:

rvw trace <run folder> --store <data store> --ticker AAPL

It prints, for each day of the run, whether the security was in the universe, each binding's value, each condition (true, false, or null when unknown), the rank score, and the days its entries could be placed and its exits were signalled. --ticker means the security holding that ticker on the run's last day, --id names one by its id, --strategy picks a portfolio's script, and --out writes the JSON to a file. It is what the app's Chart tab draws.

To see what the run did with each candidate at a close, ask for its choices:

rvw choices <run folder> --store <data store> --date 2025-06-12
rvw choices <run folder> --store <data store> --ticker AAPL

The first lists every entry candidate at that day's close: its place in the ranking, the free slots, and what became of it, with its order's status and fills when one was made (see The day's choices). The second gives the security's outcome at every close it was a candidate. Without --date, --id or --ticker it is the run's last close, the one the next-day orders came from. --summary adds every close up instead, to say why a run made few trades or none: how many candidates were dropped before ranking, ranked, ordered and not, and why, with a headline such as "All 412 candidates were sized to zero." The run is simulated again, which takes as long as the run did, and must make the orders its folder holds.