Java strategies

Build QTSurfer strategies with indicators, window listeners, state, and signals.

A QTSurfer strategy is a plain Java class (no framework annotations required) that extends a strategy base class — most commonly AbstractTickerStrategy (see Strategy base classes for the kline, funding-rate, and multi-source siblings). It receives real-time market data, configures technical indicators, and emits buy/sell signals. The engine compiles strategies server-side — no local toolchain needed.

Also available: QTScript (.qtscript), in beta — a compact strategy language whose braced bodies are plain Java, covered by the qtsurfer-qtscript-strategy skill. It suits a strategy that is a handful of indicators and window bodies. Everything below stays the way to write a strategy with the full engine API — update(), cross-instrument logic, custom indicators, helper types — and is what QTScript expands into.

Minimal template

import com.wualabs.qtsurfer.engine.indicators.helpers.group.InstrumentGroupRTIndicator;
import com.wualabs.qtsurfer.engine.strategy.AbstractTickerStrategy;

public class MyStrategy extends AbstractTickerStrategy {

    @Override
    protected void setupIndicators(InstrumentGroupRTIndicator indicators) {
        // configure indicators here — called once per instrument on first tick
    }
}

Allowed imports

Strategy code can import from a fixed set of packages — importing anything outside it fails at execution time (not at compile time), with a bare <class> could not be found-style error and no indication of why. Allowed, by top-level package (every subpackage is included):

  • com.wualabs.qtsurfer.engine.* — the strategy/indicator API itself
  • java.lang, java.util (including java.util.stream, java.util.function, java.util.regex, java.util.concurrent.atomic), java.math
  • java.time (including java.time.format, java.time.temporal) — Duration, Instant, LocalDate etc. are fine to use, e.g. in .window(name, Duration.ofSeconds(n), listener)
  • java.text — DecimalFormat/NumberFormat for formatting values in signal messages or logs

Explicitly blocked regardless of package: System, Runtime, Thread, Executor/ExecutorService. java.io is blocked outright — a strategy has no business doing file or network I/O of its own; all market data and order execution goes through the engine API above.

acceptInstrument and getExecutionMode have sensible defaults (accept all instruments, LONG mode). Override only when needed:

import com.wualabs.qtsurfer.engine.core.instrument.Instrument;
import com.wualabs.qtsurfer.engine.strategy.execution.ExecutionMode;

@Override
public boolean acceptInstrument(Instrument instrument) {
    return instrument.base().equals("BTC"); // filter instruments here if needed
}

@Override
public ExecutionMode getExecutionMode(Instrument instrument) {
    return ExecutionMode.LONG; // LONG, SHORT, or LONG_MULTI
}

Note: the default acceptInstrument is not unconditional — it gates on the strategy’s output currency / acceptCurrency. To accept every instrument unconditionally, override it explicitly with return true.

Indicator setup

All indicators are defined in setupIndicators using the fluent builder on InstrumentGroupRTIndicator. Methods return this for chaining.

@Override
protected void setupIndicators(InstrumentGroupRTIndicator indicators) {
    indicators
        .addPrice()                     // source: close price
        .ema("emaFast", 9)             // 9-period EMA named "emaFast"
        .ema("emaSlow", 21)            // 21-period EMA named "emaSlow"
        .rsi(14)                        // 14-period RSI named "rsi14"
        .bollinger("bb", 20, 2.0)      // Bollinger Bands → "bb", "bbUpper", "bbLower"
        .window("emaFast", WindowTime.s1, new MyListener(this, indicators));
}

See the indicator catalogue for the full indicator catalogue.

WindowTime values

WindowTime.s1, s5, s10, s30, m1, m3, m5 Custom: Duration.ofSeconds(n) or Duration.ofMinutes(n)

Reading indicator values outside a listener

import com.wualabs.qtsurfer.engine.core.instrument.Instrument;
import com.wualabs.qtsurfer.engine.core.Ticker;

@Override
public void update(Ticker ticker) {
    Instrument instrument = ticker.instrument();
    updateInstrument(instrument, ticker.timestamp());
    var ind = updateIndicators(instrument, ticker);

    if (!ind.getExisting("emaSlow").isReady()) return; // wait for warmup

    double fast = ind.getValue("emaFast");
    double slow = ind.getValue("emaSlow");

    if (fast > slow) emitBuy(instrument, ticker.last());
    else             emitSell(instrument, ticker.last());
}

Ticker is an engine record — read fields with accessor methods: ticker.last(), ticker.bid(), ticker.ask(), ticker.instrument(), ticker.timestamp().

Listeners fire once per time window rather than on every tick. Prefer this over update() for strategies that react to bar closes.

import com.wualabs.qtsurfer.engine.strategy.AbstractWindowListener;
import com.wualabs.qtsurfer.engine.core.state.StateStore;
import com.wualabs.qtsurfer.engine.indicators.helpers.WindowTimeRTIndicator.WindowTime;

public class MyStrategy extends AbstractTickerStrategy {

    @Override
    protected void setupIndicators(InstrumentGroupRTIndicator indicators) {
        indicators
            .addPrice()
            .rsi(14)
            .window("rsi14", WindowTime.m1, new SignalListener(this, indicators));
    }

    private class SignalListener extends AbstractWindowListener {

        public SignalListener(AbstractTickerStrategy strategy,
                              InstrumentGroupRTIndicator indicators) {
            super(strategy, indicators);
        }

        @Override
        public void onChange(StateStore store, double prev, double actual) {
            long count = store.inc("bars");

            if (actual < 30) emitBuy(indicators.getValue("price"));
            if (actual > 70) emitSell(indicators.getValue("price"));
        }
    }
}

AbstractWindowListener gives you:

  • emitBuy(price) / emitSell(price) / emitSignal(signal)
  • getPrevInstant() / getCurrInstant() — when the window that just fired opened / closed
  • getEngineVersion() / getEngineVersionMajor() / getEngineVersionMinor() — the running engine version (see Engine version)
  • this.instrument — current instrument
  • this.indicators — indicator group

Crossover detection is a standalone helper, not a method on the listener — see Crossover detection helper below.

store arrives as onChange’s first parameter — already resolved, nothing to initialise. It’s the same store every listener on this instrument shares (see State management below); getPrevInstant()/getCurrInstant() only resolve when the listener is registered on a window (via .window(...), as above) — calling them on a listener attached to a plain indicator throws.

State management

StateStore is per-instrument and shared by every listener on that instrument’s indicator group (one store, not one per window). It’s created lazily — a window nobody listens to never touches it. Inside a window listener it arrives as onChange’s first parameter; outside a listener (e.g. in update()) reach it via getStateStore(instrument), which returns Optional<StateStore>:

@Override
public void update(Ticker ticker) {
    Instrument instrument = ticker.instrument();
    updateInstrument(instrument, ticker.timestamp());
    var ind = updateIndicators(instrument, ticker);

    StateStore store = getStateStore(instrument).orElseThrow();
    long ticks = store.inc("ticks");
    // ...
}

getStateStore(instrument) is inherited from the strategy base class — always present (never Optional.empty()) for the documented base classes, so .orElseThrow() is safe; it’s Optional because the underlying Strategy contract allows an implementation to not support per-instrument state at all. Calling it, unlike a window’s own store access, resolves the store immediately — it doesn’t wait for a listener.

store.inc("count")          // int counter, returns new value
store.dec("count")
store.set("inPosition")     // boolean flag → true
store.unset("inPosition")   // → false
store.is("inPosition")      // read boolean
store.add("pnl", delta)     // double accumulator, returns new value
store.setState("key", obj)  // arbitrary object
store.getState("key", def)  // with default

Configurable properties

@StrategyProperty(name = "rsi.period", description = "RSI period", defaultValue = "14")
private int rsiPeriod;

@StrategyProperty(name = "ema.fast", description = "Fast EMA period", defaultValue = "9")
private int fastPeriod;

The annotation and the field are the whole declaration — no getter, no setter. Properties are injected before setupIndicators is called, and the same is true of a submit_sweep parameter vector: it is written to the field directly.

The submit_sweep param-key is the annotation name (with dots), NOT the Java field name. In the example above the grid key is rsi.period / ema.fast, not rsiPeriod / fastPeriod:

Let defaultValue be the only place the default is written. A field initializer (private int fastPeriod = 9;) runs after the annotation’s default has been applied and overwrites it, so if the two ever disagree the strategy runs on the initializer while the platform records the annotation’s value against the results. Declaring the default once, on the annotation, removes the question.

Declare a JavaBean setter only when the property needs one — validation, clamping, or recomputing something derived from it. When a setter exists, every injection channel goes through it, so the guard is never bypassed. The field must not be static (its value would be shared across sweep trials running in parallel) or final (nothing can assign it after construction); either needs a setter, and a property with neither is reported as a notice rather than silently skipped.

min, max and step on the annotation are advisory range hints a sweep’s parameter grid can read — not validated against, just a suggested range for pre-filling one.

Receiving commands

A live run’s owner can tell it a command from outside — POST /live/{runId}/commands with {"command": "<text>"} — while it keeps running, without restarting it. To act on one, implement CommandRequestHandler:

import com.wualabs.qtsurfer.engine.strategy.event.request.CommandRequest;
import com.wualabs.qtsurfer.engine.strategy.event.request.CommandRequestHandler;

public class MyStrategy extends AbstractTickerStrategy implements CommandRequestHandler {

    @Override
    public void handle(CommandRequest request) {
        if ("flatten".equals(request.getCommand())) {
            // close the position, cancel pending orders, whatever "flatten" means for this strategy
        }
    }
}

handle runs on the same thread as update(), right before the market event the command targets, so it sees the strategy’s state exactly as it was at that point and can call anything update() can — read indicators, emit a signal, change internal fields. A RuntimeException it throws is caught and counted, the same as one from update(); an Error unwinds the run.

A command is always a plain string, and it is transient. It may also carry a properties object of your own choosing, alongside command in the request body — not params, which stays what a run starts with and PUT /live/{runId}/params changes. Each property lands as a top-level entry on CommandRequest’s own map, so read one straight off request by name — request.get("<key>") — no key is off limits, since the command’s own text is kept separately (getCommand() reads it, unaffected by any of it). A value keeps whatever JSON type it arrived as, so assigning it to a String field when the caller sent a number or an object throws a ClassCastException inside handle; QTScript’s $command.<key> sugar reads the same value but always widens it to a String instead. A command, and its properties, are not stored as part of the run: a replica that restarts replays only the last stretch of market data, and a command from before that window simply never reaches it.

A command has no instrument attached the way update() does; when its own properties name one, reach that instrument’s store with getStateStore(String):

@Override
public void handle(CommandRequest request) {
    String instrument = request.get("instrument");
    if (instrument != null) {
        getStateStore(instrument).set("flattened");
    }
}

Assigning a @StrategyProperty field from inside handle is not durable. It changes this replica’s in-memory value immediately, the same as any other field assignment, but nothing writes it to the run’s stored parameter set — a replica that restarts (or one that starts later, and never ran handle for that command) starts from whatever PUT /live/{runId}/params last set, not from what a command assigned. StateStore is no more durable: it is memory too, gone on a restart the same as a field. Nothing a command does from inside handle survives a restart on its own — the only durable write is a real PUT /live/{runId}/params call, from outside the run (a strategy cannot call its own REST API from inside handle).

A run whose strategy does not implement CommandRequestHandler answers every command with a 409 — implementing the interface is what makes POST /live/{runId}/commands do anything at all.

A QTScript strategy implements it too, through its own onCommand { } section (see the qtsurfer-qtscript-strategy skill) — the platform recognizes the generated class as CommandRequestHandler the same way it recognizes this one.

Signal emission

Two overloads, and which one is in scope depends on where you’re calling from — mixing them up fails to compile with a missing-method error, not a runtime one:

MethodWhere it’s available
emitBuy(instrument, price) / emitSell(instrument, price)Anywhere in the strategy class itself — update(), onChange() before it delegates, helper methods
emitBuy(price) / emitSell(price)Only inside a window listener (AbstractWindowListener.onChange, see below) — the instrument is implicit there
emitSignal(signal)Custom signal (BuySignal, SellSignal, InfoStrategySignal), either context

Data / analytics signals — InfoStrategySignal

For non-trading strategies that emit computed fields (analytics, metrics) rather than buy/sell, build an InfoStrategySignal and attach arbitrary key/values, then emitSignal. Two constructors, matching the emitBuy convention:

  • Top-level strategy (update()) — createInfoStrategySignal(instrument), instrument explicit:
InfoStrategySignal signal = createInfoStrategySignal(instrument);  // from AbstractTickerStrategy
signal.set("interval", "1m");
signal.set("zscore", z);
signal.set("vwap", vwap);
emitSignal(signal);
  • Inside a window listener (AbstractWindowListener.onChange) — createInfoSignal(), instrument implicit:
InfoStrategySignal signal = createInfoSignal();  // listener knows its instrument
signal.set("interval", "1m");
signal.set("zscore", z);
signal.set("vwap", vwap);
emitSignal(signal);

The listener form takes no instrument because the listener already knows it — the same convention as the emitBuy(price) / emitSell(price) sugar above. createInfoSignal() only exists inside the listener scope; on the top-level strategy use createInfoStrategySignal(instrument).

signal.set(...) also accepts a varargs market-data style for the _m chart marker, e.g. signal.set("_m", "position", "belowBar", "shape", "arrowUp", "color", "#26a69a", "text", "BUY").

Everything you set on a signal is its data, and in a live run it is published with the signal: whoever may read the run may read it, so on a public run it is public. Put nothing there you would not show a stranger. A signal whose data is over 8 KiB (8,192 bytes of its JSON) is not pushed on the WebSocket channel (GET /live/{runId}/signals still returns it whole), so keep it to the few fields a reader needs.

Subscribers read the fields with signal.get("key") / signal.has("key") and signal.getInstrument(). Prefix a field’s name with _ to keep it out of reporting metadata.

Crossover detection helper

import com.wualabs.qtsurfer.engine.strategy.CrossDetector;

private final CrossDetector fastSlowCross = new CrossDetector(); // one instance per pair watched

// In onChange or update:
CrossDetector.Cross cross = fastSlowCross.check(fast, slow);
if (cross.above()) emitBuy(price);
if (cross.below()) emitSell(price);

One check(left, right) call reports both directions together, so they always describe the same tick.

Compile and submit

Use an official SDK or API client for application integration. The MCP workflow is also available for agent-assisted backtests.

Submit via MCP

Download the MCP server from QTSurfer/mcp-java releases (native binary or fat JAR) and configure it in your agent. Once connected:

  1. Use list_exchanges → list_instruments to pick a valid exchange and instrument.
  2. Call submit_backtest with strategyCode = the full Java source of your strategy class.
  3. Poll get_job_status until COMPLETED, then read the results.

The engine compiles the strategy server-side — only the .java source is sent.

Strategy base classes

Every strategy extends one engine base class, chosen by the data source it consumes. The three single-source bases all extend AbstractSubscriptionStrategy<T> and share the same model this skill documents (indicator builder, window listeners, StateStore, signal emission) — only the update(...) payload differs. The examples here use Ticker, the most common source. Types live in com.wualabs.qtsurfer.engine.core.

Base classSourceHandlerVia submit_backtest
AbstractTickerStrategyTicker (record)update(Ticker)✅ primary, fully documented
AbstractKlineStrategyKline (class)update(Kline)✅
AbstractFundingRateStrategyFundingRate (record)update(FundingRate)⚠️ prepare only for now — a run or sweep is rejected (400)
AbstractMultiSourceStrategyTicker + Kline + FundingRateonTicker / onKline / onFundingRate⚠️ engine-only — not yet public
  • AbstractKlineStrategy receives candles. In a backtest the bar width is the cadence the data was prepared at — 1s, 1m, 5m, 15m, 30m, 1h, 4h or 1d — whatever getInterval() (a KlineInterval) returns, so one class runs at any of them. OHLCV only — order-book sizes, vwap, and percentage-change fields are absent on this path. Kline is a plain class, so use getters (kline.getInstrument(), kline.getCloseTime()), unlike the Ticker record.
  • AbstractFundingRateStrategy receives update(FundingRate) on each funding-rate update. Funding data can be prepared, but a backtest or a sweep over it is rejected with a 400 for now (funding data can be prepared but not executed yet) — it registers and compiles, and cannot yet be run through submit_backtest.
  • AbstractMultiSourceStrategy declares getRequiredSources() → Set<MarketDataSource> (Ticker, KLine, FundingRate) and dispatches each to onTicker / onKline / onFundingRate; when KLine is required, getKlineInterval() must be non-null. It compiles and registers in the engine but is not yet runnable via the public submit_backtest — don’t ship multi-source strategies for backtesting until it is exposed.

Cross-instrument (market-wide) strategies

A strategy instance sees every accepted instrument, each with its own indicator group. To compute something across instruments (a market-wide percentile, a relative-strength rank, a basket signal), override update(Ticker) and read other instruments’ indicators via getInstruments() / getRTIndicator(...). See strategy patterns → “Cross-instrument (market-wide) strategies”.

Engine version

Three accessors are available with no import, both on the strategy and inside a window listener:

@Override
public void update(Ticker ticker) {
    log.info("running on engine {}", getEngineVersion());  // e.g. "1.0.81"

    if (getEngineVersionMajor() >= 1) { /* ... */ }        // also getEngineVersionMinor()
}

The value is read from the loaded engine jar’s own metadata, so it reports the engine actually running rather than one baked in when the strategy was compiled. Nothing here throws — when the version cannot be determined getEngineVersion() returns EngineVersion.UNKNOWN ("unknown") and the numeric accessors return EngineVersion.UNKNOWN_COMPONENT (-1), so they are safe to call unguarded and a version gate fails closed instead of matching by accident. Major and minor resolve together: check one and you can trust the other.

For the patch component, import the engine class — it is deliberately not mirrored onto the sugar:

import com.wualabs.qtsurfer.engine.EngineVersion;

int patch = EngineVersion.getPatch();  // 81

Worth emitting (in an InfoStrategySignal, or logged on first tick) for strategies that are stored and re-run later: engine APIs do change between versions, and a stored strategy that suddenly misbehaves is far easier to diagnose when the engine it ran on is recorded alongside the result.

Common mistakes

  • Forgetting isReady() check — indicators need warmup periods. Always check before reading values.
  • Mutating indicators in update() — use getReadOnlyExisting() instead of getExisting() to prevent accidental state changes.
  • One setupIndicators per strategy class — it is called once per instrument, not per tick.
  • Inner class vs lambda for listeners — AbstractWindowListener gives access to helpers; prefer inner class over raw lambda.
  • emitBuy(price) outside a window listener — that single-argument overload only exists on AbstractWindowListener; everywhere else (update(), helper methods) it’s emitBuy(instrument, price) (see Signal emission).
  • Using JavaBean getters on Ticker — Ticker is a record; use ticker.last() not ticker.getLast(), ticker.instrument() not ticker.getInstrument(), ticker.timestamp() not ticker.getTimestamp().getTime().
  • Treating a command as stored state — a command is transient (see Receiving commands): it is not replayed to a replica across a restart. Anything that must survive one belongs in a parameter, set from inside handle, not in the command itself.