Quantitative trading strategy backtesting platform.
Write trading strategies in Java, submit them to be compiled, run backtests against historical exchange data, and visualize results with millions of data points — all through a REST API.
qtsurfer.github.io — Interactive OpenAPI documentation
Strategy (Java) ──► Compile ──► Prepare Data ──┬─► Execute ──► Signals (Parquet) ──► Visualize
└─► Execute Sweep ──► Ranked trials
- Write a trading strategy in Java using the strategy SDK (indicators, signals, execution)
- Compile it via
POST /strategy— no build tools needed on the client - Prepare historical market data via
POST /backtest/{exchange}/{type}/prepare— returns ajobId - Execute either one backtest via
POST /backtest/{exchange}/{type}/executeor a parameter sweep viaPOST /backtest/{exchange}/{type}/executeSweep/{prepareJobId} - Inspect the result or ranked sweep trials; individual backtest signals are stored as Parquet files and loaded in-browser via DuckDB-WASM
import com.wualabs.qtsurfer.engine.strategy.AbstractTickerStrategy;
import com.wualabs.qtsurfer.engine.strategy.AbstractOnChangeListener;
import com.wualabs.qtsurfer.engine.strategy.event.signal.InfoStrategySignal;
import com.wualabs.qtsurfer.engine.indicators.helpers.WindowTimeRTIndicator.WindowTime;
import com.wualabs.qtsurfer.engine.indicators.helpers.group.InstrumentGroupRTIndicator;
import com.wualabs.qtsurfer.engine.core.state.StateStoreSupport;
public class EmaCrossStrategy extends AbstractTickerStrategy {
@Override
protected void setupIndicators(InstrumentGroupRTIndicator indicators) {
indicators
.addPrice()
.ema("fast", 20)
.ema("slow", 50)
.window("fast", WindowTime.s1, new CrossListener(indicators));
}
private class CrossListener extends AbstractOnChangeListener {
public CrossListener(InstrumentGroupRTIndicator indicators) {
super(EmaCrossStrategy.this, indicators);
}
@Override
public void onChange(StateStoreSupport s, double prev, double actual) {
initStore(s);
double price = indicators.getValue("price");
double fast = indicators.getValue("fast");
double slow = indicators.getValue("slow");
InfoStrategySignal signal = createInfoSignal();
signal.set("price", price);
signal.set("fast", fast);
signal.set("slow", slow);
boolean wasBullish = store.is("bullish");
boolean isBullish = fast > slow;
if (isBullish && !wasBullish) {
store.set("bullish");
signal.set("_m", "position", "belowBar", "shape", "arrowUp",
"color", "#26a69a", "text", "BUY");
} else if (!isBullish && wasBullish) {
store.unset("bullish");
signal.set("_m", "position", "aboveBar", "shape", "arrowDown",
"color", "#ef5350", "text", "SELL");
}
emitSignal(signal);
}
}
}All endpoints require JWT authentication (Authorization: Bearer <token>).
curl -X POST https://api.qtsurfer.com/v1/strategy \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: text/plain" \
--data-binary @MyStrategy.java
# → {"strategyId": "2ul144qe9tlwzu5anhwvc6"}curl -X POST https://api.qtsurfer.com/v1/backtest/binance/ticker/prepare \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"instrument":"BTC/USDT","from":"2026-03-14","to":"2026-03-15"}'
# → 202 {"jobId": "5ikYAMIO..."}Poll until completion:
curl https://api.qtsurfer.com/v1/backtest/binance/ticker/prepare/$PREPARE_JOB_ID \
-H "Authorization: Bearer $TOKEN"
# → {"status": "Completed", ...}curl -X POST https://api.qtsurfer.com/v1/backtest/binance/ticker/execute \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"prepareJobId":"5ikYAMIO...","strategyId":"2ul144qe9tlwzu5anhwvc6"}'
# → 202 {"jobId": "4GmNN0i9..."}curl https://api.qtsurfer.com/v1/backtest/binance/ticker/execute/$EXECUTE_JOB_ID \
-H "Authorization: Bearer $TOKEN"
# → {"state": {"status": "Completed", "completed": 85058},
# "results": {"pnlTotal": 42.75, "totalTrades": 156, "winRate": 58.33,
# "sharpeRatio": 1.245, "sortinoRatio": 1.872, "cagr": 0.1534,
# "maxDrawdown": 12.50, "maxDrawdownPercent": 8.75,
# "iops": 101346.81, "signalsUrl": "https://storage.qtsurfer.com/..."}}The response includes yield metrics (PnL, win rate, Sharpe, Sortino, CAGR, max drawdown) and a signalsUrl pointing to a Parquet file with all emitted signals, ready for visualization.
The sweep reuses the same prepared dataset. Its path requestId is the jobId returned by the existing prepare endpoint.
curl -X POST "https://api.qtsurfer.com/v1/backtest/binance/ticker/executeSweep/$PREPARE_JOB_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"strategyId": "2ul144qe9tlwzu5anhwvc6",
"sweep": {
"sampler": "grid",
"objective": "sharpe",
"params": {
"rsiPeriod": {"from": 7, "to": 28, "step": 1},
"useTrendFilter": {"values": [true, false]}
}
}
}'
# → 202 {"sweepId":"swp_95e47a7f0966ce11","requestId":"5ikYAMIO...",
# "totalRuns":44,"shards":1,"seed":487221,"queued":true}Poll the ranked leaderboard:
curl "https://api.qtsurfer.com/v1/backtest/binance/ticker/executeSweep/$PREPARE_JOB_ID/$SWEEP_ID?objective=sharpe&order=ranked" \
-H "Authorization: Bearer $TOKEN"
# → {"status":"RUNNING","progress":{"done":31,"total":44,...},
# "leaderboard":[{"runIx":12,"rank":1,"params":{...},"sharpe":1.84,...}]}Use order=natural to retrieve every available row in stable runIx order, without leaderboard truncation. The server returns the effective random seed so sampled sweeps can be reproduced exactly.
Identical prepare and execute requests are idempotent. Repeated sweeps return the same
sweepIdwithqueued: falseinstead of enqueueing duplicate work.
| Layer | Technology |
|---|---|
| Strategy runtime | Java |
| Signal storage | Apache Parquet, S3-compatible object storage |
| Visualization | svelte-timeseries (DuckDB-WASM + ECharts) |
| Type | Description |
|---|---|
ticker |
Real-time bid/ask/last/volume |
kline |
Candlestick OHLCV |
frate |
Funding rates (futures) |
| Repository | Description |
|---|---|
| svelte-timeseries | OSS Svelte component for time-series visualization |
Apache-2.0