QTScript (beta)

A compact language for writing strategies — every section, windows, what's in scope, and how it compiles the same as Java.

QTScript is a compact way to write a strategy: you keep the part that is yours — indicators, windows, signals — and leave out the ceremony of a Java class (package, imports, class, base class, property annotations, listener boilerplate). Every { } body is plain Java, so everything the Java strategy API offers inside a body works unchanged. A QTScript file becomes exactly one Java class and goes through the same server-side compilation as a Java strategy.

It is in beta and will gain syntax. Java remains the established route, with the full engine API; anything QTScript cannot express is written in Java (see what it does not do).

Submit it like any strategy

There is no separate endpoint and no header to set: send the raw source to POST /strategy with Content-Type: text/plain, exactly as for Java. The platform tells the two apart by the text — a QTScript source starts with strategy, and whitespace or comments (//, /* */) before it are ignored, so a description can sit on top of the file. .qtscript is the conventional file extension; only the text is sent.

curl -X POST https://api.qtsurfer.net/v1/strategy 
  -H "Authorization: Bearer $TOKEN" 
  -H "Content-Type: text/plain" 
  --data-binary @rsi-reversion.qtscript

The answer is the same as for Java: a strategyId and the declaredProperties — every param you declared is listed there.

A whole strategy

This is the complete file: no imports, no class, no listener.

strategy "RSI reversion"

param rsiLow  = 30  "Oversold"
param rsiHigh = 70  "Overbought"

instruments */usdt

setup:
  rsi(14) window m1 {
    if (actual < rsiLow)  emitBuy(price);
    if (actual > rsiHigh) emitSell(price);
  }

It declares two parameters, accepts any base quoted in USDT, registers a 14-period RSI and watches it on a one-minute window, emitting a signal on each threshold.

The parts of a file

PartWhat it doesExample
strategyThe first line: the name and, optionally, the data source (ticker by default, kline, funding)strategy "RSI reversion" · strategy Bars kline
paramA configurable value. The type comes from the literal (9 → int, 0.5 → double, true → boolean, "text" → String). The name is the key that a run’s params and a sweep axis useparam rsiLow = 30 "Oversold"
init { }Optional. Plain Java run once when the strategy is built — engine settersinit { setPercentGain(0.5); }
instrumentsOptional. Which markets: pairs (BASE/QUOTE, either side may be *), regular expressions (~"...", matched against the whole symbol), or a Java body for full controlinstruments */usdt · instruments btc/usdt, eth/*
usesOptional. Brings the pro indicators into the file, so setup: can call them by their bare name. Several uses lines add upuses pro.* · uses pro.adx, pro.ichimoku
setup:The indicators, one builder call per line (the free catalogue of the builder a Java strategy uses, listed below). The lines under setup: are indented, and the first line back at column 0 ends the sectionema(12) · bollinger(20, 2)
WindowsWhere the logic goes — see belowrsi(14) window m1 { ... }
onCommand { }Optional, at most one per file. Runs when the run receives a command — see belowonCommand { if ("flatten".equals($command)) ... }

What goes under setup:

Each line is a call on the indicator builder that a Java strategy configures in setupIndicators, InstrumentGroupRTIndicator, written without the dot. These are the names that work:

add bollinger bollingerBandwidth cci clamp diff distance distanceMa ema equal gain greatOrEqual greatThan lessOrEqual lessThan loss macd max min mul notEqual percentChange rateChange round rsi sma stochRsi sum wma tema hma mma kama stdDev variance volatility percentVolatility bollingerPercentB atr

These are the forms of each name that a QTScript line can write: a String is the name of an indicator, either one it reads or the one it registers, and a Number is a whole number or a decimal. What each argument means is in the builder’s Javadoc.

IndicatorThe forms QTScript can write
addadd(String, String) · add(String, String, String)
bollingerbollinger() · bollinger(int) · bollinger(int, Number) · bollinger(String, int, Number)
bollingerBandwidthbollingerBandwidth(String)
ccicci(int) · cci(String, int)
clampclamp(String, Number, Number) · clamp(String, String, Number, Number)
diffdiff(String, String) · diff(String, String, String)
distancedistance(String, String) · distance(String, String, String)
distanceMadistanceMa(String) · distanceMa(String, String)
emaema(int) · ema(String, int) · ema(String, String, int)
equalequal(String, String, Number) · equal(String, String, Number, String) · equal(String, String, Number, String, String)
gaingain(String) · gain(String, String) · gain(String, String, boolean)
greatOrEqualgreatOrEqual(String, String, Number) · greatOrEqual(String, String, Number, String) · greatOrEqual(String, String, Number, String, String)
greatThangreatThan(String, String, Number) · greatThan(String, String, Number, String) · greatThan(String, String, Number, String, String)
lessOrEquallessOrEqual(String, String, Number) · lessOrEqual(String, String, Number, String) · lessOrEqual(String, String, Number, String, String)
lessThanlessThan(String, String, Number) · lessThan(String, String, Number, String) · lessThan(String, String, Number, String, String)
lossloss(String) · loss(String, String) · loss(String, String, boolean)
macdmacd() · macd(String) · macd(String, int, int) · macd(String, String, int, int)
maxmax(String) · max(String, String)
minmin(String) · min(String, String)
mulmul(String, Number) · mul(String, String) · mul(String, String, Number) · mul(String, String, String)
notEqualnotEqual(String, String, Number) · notEqual(String, String, Number, String) · notEqual(String, String, Number, String, String)
percentChangepercentChange(String) · percentChange(String, String)
rateChangerateChange(String) · rateChange(String, String, boolean)
roundround(String, int) · round(String, String, int)
rsirsi(int) · rsi(int, boolean) · rsi(String, int, boolean)
smasma(int) · sma(String, int) · sma(String, String, int) · sma(String, int, boolean)
stochRsistochRsi(int) · stochRsi(String, int)
sumsum(String) · sum(String, String)
wmawma(int) · wma(String, int)
tematema(int) · tema(String, int)
hmahma(int) · hma(String, int)
mmamma(int) · mma(String, int) · mma(String, String, int)
kamakama() · kama(int, int, int) · kama(String, int, int, int)
stdDevstdDev(int) · stdDev(String, int) · stdDev(String, String, int)
variancevariance(int) · variance(String, int) · variance(String, String, int)
volatilityvolatility(int) · volatility(String, int)
percentVolatilitypercentVolatility(int) · percentVolatility(String, int)
bollingerPercentBbollingerPercentB() · bollingerPercentB(int) · bollingerPercentB(int, Number) · bollingerPercentB(String, int, Number)
atratr() · atr(int) · atr(String, int)

A call whose arguments match none of them is rejected with the forms that do exist: ema(1, 2, 3) answers that its forms are ema(int), ema(String, int) and ema(String, String, int). The builder’s methods that take a function (predicate, fun, decorate, periodCount) and conditional, which takes a function and indicators as objects, can only be written in Java. To compare an indicator with a number, use equal, notEqual, greatThan, greatOrEqual, lessThan or lessOrEqual.

Called with no name of its own, the newer ones register atr14, wma5, tema4, hma6, mma7, kama10_2_30, stdDev5, variance5, vol5 (volatility), pvol5 (percentVolatility) and pctB20_2 (bollingerPercentB); a whole multiplier drops its decimal part, as in bollinger.

atr reads whole bars, like the pro indicators do: use it in a kline strategy. On a ticker strategy its values are not the bar’s and mean nothing.

A name that is not on the list, and that no uses line brings in (next section), is not an indicator you can use here, and the strategy is rejected with unknown indicator '<name>': zscore is among the names that answer this way, and so are adx and kurtosis unless the file brings them in with uses. The same goes for value("name"), which reads an indicator inside a { } body and has no meaning on a setup: line.

The pro indicators: uses

The pro indicators are not in the list above. A file brings them in with a uses line, and then calls them in setup: by their bare name, like any other indicator.

  • uses pro.* brings in all twelve. uses pro.adx, pro.ichimoku brings in only those. Several uses lines add up. Write them with param and instruments, before setup:.
  • Only the names that a uses line brings in are pro. With uses pro.obv, a call to adx(14) is still unknown indicator 'adx', reported on the line of the call. The builder’s own names (ema, rsi and the rest of the list above) are never pro.
  • pro is the only set there is. uses is a reserved word: a file cannot have a param or a section called uses.
  • Pro indicators read whole bars, so use them in a kline strategy. On a ticker strategy the values are not the bar’s and mean nothing, and validating or running the strategy returns an engine notice that says so.
  • A pro line is an ordinary setup: line: one call per line, with a window after it as usual.
  • Backtests, sweeps (walk-forward included), validate and live runs run the pro indicators.
IndicatorFormsRegisters, with the default argumentsAlso registers
adxadx() · adx(int) · adx(String, int)adx14adx14PlusDI · adx14MinusDI
superTrendsuperTrend() · superTrend(int, double) · superTrend(String) · superTrend(String, int, double)st10_3st10_3Upper · st10_3Lower
ichimokuichimoku() · ichimoku(int, int, int) · ichimoku(String) · ichimoku(String, int, int, int)ichi9_26_52, the Tenkan lineichi9_26_52Kijun · ichi9_26_52SenkouA · ichi9_26_52SenkouB · ichi9_26_52Chikou
obvobv() · obv(String)obv
vwapvwap() · vwap(String)vwap
mfimfi() · mfi(int) · mfi(String, int)mfi14
cmfcmf() · cmf(int) · cmf(String, int)cmf20
kurtosiskurtosis(int) · kurtosis(String, int)kurt20 (the period is required; it reads the close)
parkinsonparkinson(int) · parkinson(String, int) · parkinson(String, int, double)park20
garmanKlassgarmanKlass(int) · garmanKlass(String, int) · garmanKlass(String, int, double)gk20
rogersSatchellrogersSatchell(int) · rogersSatchell(String, int) · rogersSatchell(String, int, double)rs20
yangZhangyangZhang(int) · yangZhang(String, int) · yangZhang(String, int, double)yz20

The double of the four volatility estimators is the annualization factor. With other arguments the name follows the same pattern: mfi(20) registers mfi20, kurtosis(30) registers kurt30, ichimoku(7, 22, 44) registers ichi7_22_44, and superTrend(10, 2.5) registers st10_2.5 (a whole multiplier loses its decimal: superTrend(7, 3) registers st7_3). With a name as the first argument the indicator registers under that name, and the other indicators it registers take it as their prefix: adx("trend", 14) registers trend, trendPlusDI and trendMinusDI.

Some of them register more than one indicator under one name, as bollinger does (see Which indicator a window is on). A window written on such a line attaches to the main one, so actual is its value, and the body reads the others with value($indicator + "<suffix>"), using the suffixes of the last column. $indicator is the name the window is attached to, so it also works when you gave the indicator a name of your own. The main one of adx is the ADX, that of superTrend is the SuperTrend line, and that of ichimoku is the Tenkan line.

This reads the ADX with the two directional lines beside it:

strategy "ADX trend" kline
uses pro.adx

param adxLevel = 25 "ADX level"

setup:
  ema(20)
  atr(14)
  adx(14) window m1 {
    if (actual > adxLevel && value("adx14PlusDI") > value("adx14MinusDI")) emitBuy(close);
    if (actual > adxLevel && value("adx14PlusDI") < value("adx14MinusDI")) emitSell(close);
  }

And this one brings in all of them and uses bands and a cloud:

strategy "Cloud and bands" kline
uses pro.*

setup:
  superTrend(10, 3) window m5 {
    if (close > value($indicator + "Upper")) emitSell(close);
    if (close < value($indicator + "Lower")) emitBuy(close);
  }
  ichimoku() window m5 {
    if (close > value($indicator + "SenkouA") && close > value($indicator + "SenkouB")) emitBuy(close);
  }
  vwap()
  yangZhang(20)

A file that brings in only obv and then calls adx(14) does not compile. The line of the call is rejected with unknown indicator 'adx':

uses pro.obv

setup:
  adx(14)

A uses line that is not well formed is rejected, against your file like the other errors of QTScript, with one of these messages:

  • uses needs the indicators to bring in: uses pro.* or uses pro.adx, pro.ichimoku, when nothing follows uses.
  • expected pro.* or pro.<indicator>, found 'pro', for uses pro.
  • unknown set 'ta': the only set is pro, for a set that does not exist.
  • unknown pro indicator 'zscore': the pro indicators are adx, cmf, garmanKlass, ichimoku, kurtosis, mfi, obv, parkinson, rogersSatchell, superTrend, vwap, yangZhang, for a member that does not exist.
  • expected pro.* or pro.<indicator> here, for a comma with nothing after it, as in uses pro.adx,.
  • expected pro.* or pro.<indicator>, found 'pro.adx pro.obv', for two members with no comma between them.

The pro indicators and your plan

The pro indicators come with the Pro and Elite plans (your plan is the tier that GET /account returns). Registering a strategy that uses them is accepted on every plan; what a plan without them cannot do is run it.

  • POST /strategy says what the strategy needs. The answer carries requires, a list: ["indicators.pro"] when the strategy uses the pro indicators and [] when it does not. GET /strategy/{strategyId} does not show it.
  • A plan that does not include them is refused with 402 Payment Required, without a Retry-After header, since waiting does not change the plan. That is the answer to validating the strategy, to starting a backtest with it, to starting a sweep with it (walk-forward included) and to starting a live run with it. The message names the first pro indicator the strategy asks for, the line of your file and your plan (on the Basic plan it ends your plan: basic). The body is the usual error, with retryable set to false, so a client that retries on retryable does not retry this one. When the platform has a page where you change plan, the body also carries its address as upgradeUrl; no other error does:
{"code": 402, "message": "strategy uses pro indicators (pro.adx, QTScript line 7); your plan: free", "retryable": false, "upgradeUrl": "https://example.com/upgrade"}

Windows

A window fires when its period closes, not on every tick, and wraps one indicator. A period is one of s1 s5 s10 s30 m1 m3 m5, or a whole number of seconds (window 900 { ... }); omitted, it is s1. Five ways to write one:

setup:
  rsi(14) window m1 { ... }      // inline, on the indicator this line registers
  rsi(33) window Oversold        // same, with the body in a named section below
  window price m5 { ... }        // inline, on an indicator by name
  window ema12 Trend             // by name, body in a named section

window Oversold m1 { ... }       // a named section, at column 0
Trend s5 { ... }                 // `window` is optional here

A section called Main that nothing references watches the primary value (price, or rate on funding), which makes the shortest useful file:

strategy Simple

Main m1 {
  if (actual > prev) emitBuy(price);
}

The window gives you the previous value

A window fires when its period closes, so prev is the value the indicator had when the previous window closed and actual is its value now: the close[1] of other tools. A crossing, or anything else that compares one close with the one before, needs nothing kept in the store; the previous value is already in scope.

This enters when the RSI climbs back through a level, once, on the window where it happens:

strategy "RSI re-entry"

param level = 30 "Level to climb back through"

setup:
  rsi(14) window m1 {
    if (prev < level && actual >= level) emitBuy(price);
  }

On a kline strategy a window on close hands you the previous bar’s close as prev, which is what the true range needs next to the bar’s own high and low:

strategy "True range" kline

setup:
  window close m1 {
    double trueRange = Math.max(high - low, Math.max(Math.abs(high - prev), Math.abs(low - prev)));
    emitInfo("trueRange", trueRange);
  }

The first time a window fires there is no earlier window to compare with: prev is the value the indicator had when that first window opened. A rule that needs two real closes should let the first firing pass, and an indicator with a warm-up period, such as an RSI or an EMA, has not settled in its first windows either. Keep the store for what a window cannot hand you: a flag, a counter, a value from several windows ago.

Which indicator a window is on

Inside any window body, $indicator is the name of the indicator that window is attached to, as a String: "ema12" for a window written on the line ema(12), "price" on a Main ("rate" on funding). The $ marks a name QTScript provides; the names you declare cannot start with one. A named section attached to several indicators sees, each time it fires, the indicator that fired.

It is also how a body reaches an indicator that the same builder call registered beside the one it is on. bollinger(20, 2) registers three under one name: the middle band, blgr20_2, and the outer bands, that name followed by Upper and Lower. A window written on that line attaches to the middle band, so actual is the middle band’s value, and value($indicator + "Upper") and value($indicator + "Lower") read the other two:

setup:
  bollinger(20, 2) window m1 {
    if (price > value($indicator + "Upper")) emitSell(price);
    if (price < value($indicator + "Lower")) emitBuy(price);
  }

A window that names an indicator which is not registered, such as window nosuch m1 { ... }, is not rejected when you register the source. Whether the name exists can depend on your params (a param fast used as ema(fast) registers a different name for each value) and on indicators registered from Java, so it is only found when the strategy is validated, which then fails on the window’s line: QTScript line 4: unknown indicator 'nosuch'.

Inside a body

Your Java, plus what is already in scope — nothing needs importing:

In scopeWhat it is
actual, prevthe window’s new value and the one it had when the previous window closed — see above
price (ticker) · price open high low close volume (kline) · rate (funding)the current values, as plain variables
value("name")any other indicator’s current value
$indicatorthe name of the indicator this window is on, as a String — see above
storethe per-instrument state shared by every window of that instrument
emitBuy(price), emitSell(price), emitInfo(key, values…), emitSignal(signal)signal emission
every paramreadable by its name

A position flag in the store, emitting on the crossing only once:

strategy "EMA cross"

param fast = 9  "Fast EMA"
param slow = 21 "Slow EMA"

setup:
  ema(fast)
  ema(slow)
  window price s5 {
    double f = value("ema" + fast);
    double s = value("ema" + slow);
    if (f > s && !store.is("long")) {
      store.set("long");
      emitBuy(price);
    }
    if (f < s && store.is("long")) {
      store.unset("long");
      emitSell(price);
    }
  }

value("ema" + fast) reads any registered indicator by name; store is the same store every window of the instrument shares.

Candles

strategy … kline gives the bar’s fields as plain variables. A kline strategy never takes an interval: the bar width is the cadence you prepare the data at, so the same file runs at any of them.

strategy "Range breakout" kline

param minRange = 0.5 "Minimum range, percent"

setup:
  window close m1 {
    double range = (high - low) / low * 100.0;
    if (range < minRange) return;
    if (close > open) emitBuy(close);
    else              emitSell(close);
  }

Handling a command

onCommand { } is a special section, at most one per file, that runs when the platform delivers a command to a live run — POST /live/{runId}/commands, applied without restarting the strategy. It has no period and no indicator: it is not a window, it runs once per command, not once per market event, and a file has at most one of it, the same way it has at most one init { }.

Inside its body, $command is the command’s text, as a String — nothing else a window body has (actual, $indicator, value(...), the ambient store) is in scope, because a command is not tied to a market tick or, unlike a window, to one instrument already chosen for you. Every param is still readable and settable.

A command may carry a properties object of your own choosing, alongside command in the request body. Read a value from it with $command.<key> — a String, null when the command carried no such key. $command.<key> fires only when <key> is not itself a call, so $command.equals(...), $command.startsWith(...) and the rest still read as ordinary String methods on $command itself.

A window body gets its instrument’s store handed to it; onCommand does not, since a command names no instrument on its own — but getStateStore("<symbol>") takes one directly, so a command whose own properties name an instrument can still reach that instrument’s store:

strategy "Manual flatten"

onCommand {
  if ("flatten".equals($command)) {
    getStateStore($command.instrument).set("flattened");
  }
}

A strategy with no onCommand { } does not implement the engine’s CommandRequestHandler, so a command sent to one of its runs is rejected with 409 (see Commands). $command and $command.<key> are not visible outside onCommand’s body, the same way $indicator is not visible outside a window’s.

Setting a param or writing to a StateStore from inside onCommand both take effect immediately, but neither survives a restart: a StateStore is memory, gone on a restart the same as a field. Only PUT /live/{runId}/params writes something a restarted replica actually starts from. getStateStore(...) here is not about durability — it is how onCommand reaches the per-instrument state a window body already reads, since a command carries no instrument of its own.

A Java strategy implements the same contract directly, through CommandRequestHandler — see Coding Java strategies.

Sizing

A strategy says when and which way, not how much. emitBuy(price) and emitSell(price) carry a price and nothing else, and no call puts a quantity or a notional on a signal. The size of each entry is a setting of the run, percentAmountToLock: the percentage of the account’s free balance that an entry locks, above 0 and up to 100.

Where you give it depends on the run: baseConfig for a backtest or a sweep, the paper block for a paper live run. Left out, a backtest puts the whole free balance into each entry (all-in) and a live run’s paper account locks 10%. To see how a strategy behaves at another size, run it again with another percentAmountToLock; the file does not change.

Running it

A registered QTScript strategy is prepared, executed and swept like any other: prepare, execute and executeSweep, with the strategyId you got back. Sweep axes and params use the names of your param lines.

strategy …PrepareExecuteSweep
ticker (default)yesyesyes
klineyesyesyes
fundingyesnot yetnot yet

See Data sources for the details, including why a funding strategy can be registered and its data prepared but not yet run.

When something is wrong

Errors are reported against your file, never the generated Java. Registering a source with a mistake is a 400 whose message carries Line N, Column M: entries, for QTScript’s own errors (an unknown section, a bad period, a duplicate parameter, a malformed instrument pattern) and for Java errors from inside a body.

A 200 from POST /strategy means the source parsed and compiled, not that it will run. Registering does not set the strategy up, so what only shows when it does is found by validate, which reports it against your file the same way. The case to know is a window on an indicator that is not registered, window nosuch m1 { ... }: it registers, and validate ends failed on the window’s line, QTScript line 4: unknown indicator 'nosuch' (see Which indicator a window is on). Call validate before you run a strategy you have just written.

A failure while the strategy runs is recorded on the job the same way, on the line the body came from:

QTScript line 6: Index 2 out of bounds for length 1

The strategy id

POST /strategy returns the same strategyId for the same source. For QTScript the id comes from the text, because indentation is part of the grammar: a byte-order mark, the style of line endings, whitespace at the end of a line and blank lines before the first and after the last line are ignored; anything else — a comment, the indentation, a blank line in between — gives a different id. (Java strategies are more forgiving; see Strategies.)

What it does not do

By design, and each one is a reason to write the strategy in Java instead:

  • No update(). Windows are the model; a strategy that has to see every tick belongs in Java.
  • No cross-instrument logic — reading other instruments’ indicators needs the full class.
  • No custom indicator classes, no extra fields or methods beyond what the sections declare, and no multi-source strategies.
  • One class. Anything that wants helper types is a Java strategy.

Switching is never a dead end: a .qtscript file is a Java class with the ceremony left out.

Going further

The maintained qtsurfer-qtscript-strategy skill has the full language reference — every section, the instrument filter in detail, reserved names, and more examples — and works with agents that read skills:

npx skills add QTSurfer/strategy-skills --skill qtsurfer-qtscript-strategy

The bodies are written against the strategy API documented in the qtsurfer-java-strategy skill and in Coding Java strategies.