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 theqtsurfer-qtscript-strategyskill. 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 itselfjava.lang,java.util(includingjava.util.stream,java.util.function,java.util.regex,java.util.concurrent.atomic),java.mathjava.time(includingjava.time.format,java.time.temporal) —Duration,Instant,LocalDateetc. are fine to use, e.g. in.window(name, Duration.ofSeconds(n), listener)java.text—DecimalFormat/NumberFormatfor 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
acceptInstrumentis not unconditional — it gates on the strategy’s output currency /acceptCurrency. To accept every instrument unconditionally, override it explicitly withreturn 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().
Window listener pattern (recommended)
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 / closedgetEngineVersion()/getEngineVersionMajor()/getEngineVersionMinor()— the running engine version (see Engine version)this.instrument— current instrumentthis.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:
| Method | Where 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:
- Use
list_exchanges→list_instrumentsto pick a valid exchange and instrument. - Call
submit_backtestwithstrategyCode= the full Java source of your strategy class. - Poll
get_job_statusuntilCOMPLETED, 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 class | Source | Handler | Via submit_backtest |
|---|---|---|---|
AbstractTickerStrategy | Ticker (record) | update(Ticker) | ✅ primary, fully documented |
AbstractKlineStrategy | Kline (class) | update(Kline) | ✅ |
AbstractFundingRateStrategy | FundingRate (record) | update(FundingRate) | ⚠️ prepare only for now — a run or sweep is rejected (400) |
AbstractMultiSourceStrategy | Ticker + Kline + FundingRate | onTicker / onKline / onFundingRate | ⚠️ engine-only — not yet public |
AbstractKlineStrategyreceives candles. In a backtest the bar width is thecadencethe data was prepared at —1s,1m,5m,15m,30m,1h,4hor1d— whatevergetInterval()(aKlineInterval) returns, so one class runs at any of them. OHLCV only — order-book sizes, vwap, and percentage-change fields are absent on this path.Klineis a plain class, so use getters (kline.getInstrument(),kline.getCloseTime()), unlike theTickerrecord.AbstractFundingRateStrategyreceivesupdate(FundingRate)on each funding-rate update. Funding data can be prepared, but a backtest or a sweep over it is rejected with a400for now (funding data can be prepared but not executed yet) — it registers and compiles, and cannot yet be run throughsubmit_backtest.AbstractMultiSourceStrategydeclaresgetRequiredSources()→Set<MarketDataSource>(Ticker,KLine,FundingRate) and dispatches each toonTicker/onKline/onFundingRate; whenKLineis required,getKlineInterval()must be non-null. It compiles and registers in the engine but is not yet runnable via the publicsubmit_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()— usegetReadOnlyExisting()instead ofgetExisting()to prevent accidental state changes. - One
setupIndicatorsper strategy class — it is called once per instrument, not per tick. - Inner class vs lambda for listeners —
AbstractWindowListenergives access to helpers; prefer inner class over raw lambda. emitBuy(price)outside a window listener — that single-argument overload only exists onAbstractWindowListener; everywhere else (update(), helper methods) it’semitBuy(instrument, price)(see Signal emission).- Using JavaBean getters on Ticker —
Tickeris a record; useticker.last()notticker.getLast(),ticker.instrument()notticker.getInstrument(),ticker.timestamp()notticker.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.