Introduction to QSL

Turn Your Trading Logic Into Code

Learn how QSL turns plain-language trading ideas into testable, optimizable strategies. A practical introduction to the three functions every strategy needs.

18 minIntermediate

Introduction

You have a trading idea. Maybe it is an EMA crossover. Maybe it is an RSI mean-reversion setup. You can describe it in a sentence, draw it on a chart, and you are fairly sure it works.

But until the idea is written in a form a machine can execute — with precise entry conditions, exact exit rules, and defined parameters — it cannot be backtested. And if it cannot be backtested, you have no evidence it works. Only a feeling.

QSL is the bridge between the idea in your head and a testable system. It is a lightweight strategy language built into Quanthop that lets you express trading logic as three simple functions. You do not need a computer science degree. If you can describe your strategy in plain English, you can write it in QSL.

This article covers the core concepts. For full API reference and every available indicator, see the documentation.

What QSL Is (and What It Is Not)

QSL stands for Quanthop Strategy Language. It is not a general-purpose programming language. You cannot build a website with it or query a database. It does exactly one thing: describe how a trading strategy should behave, bar by bar, across a price series.

A QSL strategy is a JavaScript file with three functions:

  • define() — Declare your parameters (the knobs you can tune).
  • init() — Declare your indicators (what to calculate from price data).
  • onBar() — Your trading logic (what to do on each candle).

That is the entire contract. Three functions. The backtesting engine handles everything else — fetching candle data, computing indicators, simulating fills, tracking positions, and calculating performance metrics.

Key idea: You describe what the strategy should do. The engine handles how it executes.

The Three Functions

Every QSL strategy follows the same lifecycle. Understanding this lifecycle is the single most important concept in QSL, because it determines when your code runs and what data is available at each stage.

1. define() runs once when the strategy loads. Its only job is to declare parameters — the values you want to adjust and optimize. Think of it as filling out a form: "My strategy has a fast EMA period, a slow EMA period, and a stop-loss multiplier."

function define(ctx) {
ctx.param('fastLength', {
  type: 'int', label: 'Fast EMA', default: 9,
  min: 5, max: 50, step: 5, optimize: true
});
ctx.param('slowLength', {
  type: 'int', label: 'Slow EMA', default: 21,
  min: 10, max: 200, step: 10, optimize: true
});
}

Each parameter has a type, a default value, and an allowed range. The optimize: true flag tells the engine this parameter should be included in optimization and Walk-Forward Analysis. For the full parameter API, see the define() reference.

2. init() runs once before the backtest begins. This is where you declare which indicators to compute. You do not calculate them yourself — you tell the engine what you need, and it pre-computes them across the entire price series.

function init(ctx) {
ctx.indicator('fastEma', 'EMA', {
  period: ctx.p.fastLength, source: 'close', display: true
});
ctx.indicator('slowEma', 'EMA', {
  period: ctx.p.slowLength, source: 'close', display: true
});
}

Notice that indicator periods reference ctx.p.fastLength — the parameter declared in define(). This connection is what makes optimization work: when the engine varies fastLength from 5 to 50, the EMA recalculates automatically. The full list of available indicators is in the indicator reference.

3. onBar() runs once per candle, from the first bar to the last. This is your trading logic — the part that decides whether to buy, sell, or do nothing.

function onBar(ctx, i) {
const { fastEma, slowEma } = ctx.ind;

if (q.isNaN(fastEma[i]) || q.isNaN(slowEma[i])) return;

if (q.crossOver(fastEma, slowEma, i)) {
  ctx.order.market('ASSET', 1, {
    signal: 'buy', reason: 'fast_crosses_above_slow'
  });
}
if (q.crossUnder(fastEma, slowEma, i)) {
  ctx.order.close('ASSET', {
    signal: 'sell', reason: 'fast_crosses_below_slow'
  });
}
}

The i parameter is the current bar index. You access indicator values with ctx.ind.fastEma[i] and price data with ctx.series.close[i]. The q namespace provides helper functions like crossOver, crossUnder, and isNaN. For the full order and series API, see the onBar() reference.

Key idea: define() sets the knobs. init() requests the data. onBar() makes the decisions. Everything else is handled by the engine.

Parameters: The Knobs You Tune

Parameters are what separate a rigid script from a strategy you can test properly. A hardcoded EMA period of 20 gives you one backtest. A parameter that ranges from 10 to 50 gives you a surface you can explore — and more importantly, validate.

In define(), each parameter declaration includes:

  • Typeint, float, string, or boolean. Most parameters are int or float.
  • Default — The starting value for a simple backtest.
  • Rangemin, max, and step define the optimization search space.
  • Optimize flag — Only parameters with optimize: true are included in Walk-Forward Analysis.

A common mistake is making every number a parameter. If your strategy has 8 optimizable parameters, the search space is enormous and overfitting becomes almost certain. Start with 2–3 core parameters. Add more only when the strategy is already profitable with the simple version.

Key idea: Parameters are not just convenience — they are the foundation of optimization and validation. Choose them deliberately.

Indicators: What the Engine Calculates

Indicators are declared in init() and computed by the engine before your onBar() function ever runs. This means you never write the EMA formula yourself — you tell the engine you need an EMA with a certain period, and it delivers a pre-computed array of values.

Why does this matter? Two reasons. First, the engine computes indicators in optimized native code (typed arrays, not JavaScript loops), which is significantly faster. Second, it eliminates an entire category of bugs — your indicator math is always correct because the engine handles it.

Available indicators include SMA, EMA, RSI, MACD, Bollinger Bands, ATR, Stochastic, Donchian Channels, and more. Each has a specific configuration. For example, MACD requires three periods:

ctx.indicator('macd', 'MACD', {
fastPeriod: 12, slowPeriod: 26, signalPeriod: 9,
source: 'close'
});

In onBar(), MACD exposes three values: ctx.ind.macd.macd[i], ctx.ind.macd.signal[i], and ctx.ind.macd.histogram[i]. The full indicator catalogue with configuration options is in the indicator reference.

The display: true flag tells the chart to overlay the indicator on your backtest results. It does not affect execution — it is purely visual.

Placing Orders

When your onBar() logic decides to trade, it places an order. QSL supports four order methods:

  • ctx.order.market(symbol, qty, meta) — Open a position at the next bar's open price. The most common order type. Use qty: 1 for a full long, 0.5 for a half position, or -1 for a full short.
  • ctx.order.close(symbol, meta) — Close the entire position at the next bar's open. No quantity needed — it closes everything.
  • ctx.order.reduce(symbol, fraction, meta) — Partially close a position by a fraction. reduce('ASSET', 0.5) closes half.
  • ctx.order.limit(symbol, qty, price, meta) — Buy only if price reaches a specified level. Expires unfilled if price never touches the limit.

A critical detail: orders placed on bar i fill on bar i+1. This is how real trading works — you see a signal on the current candle and act on the next one. The engine enforces this automatically, which prevents look-ahead bias. For the full execution model, see the order timing documentation.

Scaling in and out. These four methods combine to support sophisticated position management. A strategy can use market() with a fractional quantity to take a small pre-entry position on an early signal, then add to it with another market() when the full signal confirms. On the exit side, reduce() lets you take partial profit at one level while holding the remainder for a larger move. The built-in TDR strategy uses exactly this pattern — a partial preEntry allocation, a full entry on crossover confirmation, partial takeProfit exits, and a full exit on reversal.

The meta object is optional metadata — signal, reason, and type fields that appear in your trade log. Use them liberally. When you are reviewing 200 trades, knowing why each one triggered is invaluable.

// Partial entry on early signal
ctx.order.market('ASSET', 0.3, {
signal: 'preEntry', type: 'partial_entry',
reason: 'price_crosses_short_baseline'
});

// Full entry on confirmation
ctx.order.market('ASSET', 1, {
signal: 'buy', type: 'full_entry',
reason: 'ema_crossover_confirmed'
});

// Partial profit taking
ctx.order.reduce('ASSET', 0.5, {
signal: 'takeProfit', type: 'partial_exit',
reason: 'target_reached'
});

Key idea: You place the order. The engine fills it on the next bar. This one-bar delay is not a limitation — it is how you avoid the most common backtesting mistake.

Helper Functions

The q namespace provides helper functions that simplify common trading logic. You do not need to write crossover detection from scratch.

  • q.crossOver(a, b, i) — Returns true when series a crosses above series b at bar i.
  • q.crossUnder(a, b, i) — Returns true when series a crosses below series b at bar i.
  • q.isNaN(value) — Checks if a value is ready. Indicators return NaN during their warmup period (e.g., a 20-period EMA has no valid value for the first 19 bars).
  • q.highest(series, period, i) — The highest value in the last period bars.
  • q.lowest(series, period, i) — The lowest value in the last period bars.

The q.isNaN() check deserves special attention. Without it, your strategy will attempt to trade on invalid indicator values during the warmup period. Always guard your onBar() logic:

if (q.isNaN(ctx.ind.rsi[i])) return;

This single line prevents an entire class of early-bar errors.

A Complete Strategy

Here is a full RSI mean-reversion strategy in QSL. It buys when RSI exits oversold territory and the price is above a long-term EMA (trend filter). It sells when RSI reaches overbought levels.

function define(ctx) {
ctx.param('rsiPeriod', {
  type: 'int', label: 'RSI Period', default: 14,
  min: 7, max: 28, step: 1, optimize: true
});
ctx.param('oversold', {
  type: 'int', label: 'Oversold Threshold', default: 30,
  min: 15, max: 40, step: 5, optimize: true
});
ctx.param('overbought', {
  type: 'int', label: 'Overbought Threshold', default: 70,
  min: 60, max: 85, step: 5, optimize: true
});
}

function init(ctx) {
ctx.indicator('rsi', 'RSI', {
  period: ctx.p.rsiPeriod, source: 'close'
});
ctx.indicator('trendEma', 'EMA', {
  period: 200, source: 'close', display: true
});
}

function onBar(ctx, i) {
const rsi = ctx.ind.rsi[i];
const ema = ctx.ind.trendEma[i];
const close = ctx.series.close[i];

if (q.isNaN(rsi) || q.isNaN(ema)) return;

const prevRsi = ctx.ind.rsi[i - 1];

// Enter: RSI crosses above oversold, price above trend
if (prevRsi <= ctx.p.oversold && rsi > ctx.p.oversold
    && close > ema) {
  ctx.order.market('ASSET', 1, {
    signal: 'buy', reason: 'rsi_exits_oversold'
  });
}

// Exit: RSI reaches overbought
if (rsi >= ctx.p.overbought) {
  ctx.order.close('ASSET', {
    signal: 'sell', reason: 'rsi_overbought'
  });
}
}

Walk through it section by section:

  • define() declares three parameters with sensible defaults and optimization ranges. The step sizes are small enough to find good values but large enough to keep the search space manageable.
  • init() requests two indicators — an RSI that uses the optimizable period, and a 200-period EMA as a fixed trend filter.
  • onBar() checks the RSI crossing above the oversold level while price is above the trend EMA. It exits when RSI reaches overbought. The i - 1 comparison detects the crossing event (previous bar below threshold, current bar above).

This strategy is ready to backtest. It has clear entry and exit logic, defined parameters with reasonable ranges, and a trend filter to avoid counter-trend trades. You could run Walk-Forward Analysis on it immediately to validate whether the RSI and threshold parameters are robust across time.

Using State

Sometimes your strategy needs to remember something between bars. Maybe you want to track how many bars since the last signal, or store a high watermark. That is what ctx.state is for.

You initialize state variables in init():

function init(ctx) {
ctx.indicator('ema', 'EMA', { period: ctx.p.length, source: 'close' });
ctx.state.barsSinceEntry = 0;
ctx.state.entryPrice = 0;
}

Then read and write them in onBar():

function onBar(ctx, i) {
const pos = ctx.position('ASSET');

if (pos.qty > 0) {
  ctx.state.barsSinceEntry++;

  // Time-based exit: close after 10 bars
  if (ctx.state.barsSinceEntry >= 10) {
    ctx.order.close('ASSET', { signal: 'sell', reason: 'time_exit' });
    ctx.state.barsSinceEntry = 0;
  }
}
}

State is useful for time-based exits, trailing stops that need to track a running maximum, or any logic that depends on what happened in previous bars beyond what indicators capture.

Be judicious with state. Every piece of state is something you need to keep correct across hundreds of bars. When you can solve a problem with an indicator or a q.highest() helper instead, prefer that approach.

Common Mistakes

Most QSL bugs fall into a handful of categories. Knowing them in advance saves hours of debugging.

Forgetting the isNaN guard. Every indicator has a warmup period. A 50-period EMA produces NaN for the first 49 bars. If your onBar() compares NaN to a threshold, the result is always false — your strategy will never trigger during warmup, which is correct but accidental. Worse, arithmetic on NaN produces NaN, which can cascade through your entire logic. Always check q.isNaN() for every indicator before using it.

Trading on the current bar. If you see a signal on bar 10 and place an order, that order fills on bar 11. New traders sometimes try to check whether the fill price was good by looking at bar 10's close. It was not available at the time the order was placed. The engine enforces this, but your mental model should match.

Too many optimizable parameters. Three parameters with 10 steps each produce 1,000 combinations. Add a fourth and you have 10,000. The more combinations, the more likely that the "best" one exists by chance. Start with 2–3 parameters. If the strategy is not profitable with a few well-chosen knobs, adding more will not fix it — it will only disguise the problem.

Hardcoding values that should be parameters. If you might ever want to test different values for a number, declare it as a parameter. A hardcoded stop-loss of 2% cannot be optimized. A parameter called stopPct with a range of 1–5% can.

Not using meta on orders. Six months from now, you will review a strategy with 300 trades and wonder why trade #147 triggered. The signal and reason fields in order meta are free — use them.

Where to Go From Here

This article covered the core concepts — the three-function lifecycle, parameters, indicators, orders, helpers, and state. With these building blocks, you can express the vast majority of systematic strategies.

Your next steps:

  • Read the documentation — The strategy lifecycle page covers define(), init(), and onBar() in full detail. The indicator reference documents every available indicator and its configuration.
  • Start with a template — The Strategy IDE includes starter templates (EMA Crossover, RSI Mean Reversion, Bollinger Breakout, MACD Momentum). Open one, read through it, then modify it to match your idea.
  • Backtest immediately — Do not write the "perfect" strategy before testing. Write the simplest version of your idea, backtest it, and iterate based on results. Covered in the next article: Running Your First Backtest.
  • Keep parameters minimal — 2–3 optimizable parameters for your first strategy. You can always add complexity after proving the core logic works.

QSL is deliberately simple. The complexity in quantitative trading is not in the code — it is in choosing the right logic, validating it honestly, and trusting the results only when they survive out-of-sample testing. QSL stays out of your way so you can focus on what actually matters.

Related articles

Browse all learning paths