Quant Charts Strategy API Reference (Python and Rust)

Everything needed to write a backtestable Quant Charts strategy, Python and Rust.

Decorators

Top-level annotations that turn a Python file into an indicator, strategy, script, or day-start hoist.

@indicator

@indicator(name, overlay?, description?, data_mode?, timeframe?)

Mark a Python class as a chart indicator.

The @indicator decorator transforms a Python class into a chart indicator that receives price data and draws visual output. The class must implement a calculate(self, df) method that receives the chart DataFrame.

Call plot(), hline(), or fill() inside calculate() to draw on the chart. Access price data via the global series: close, open, high, low, volume.

Python indicators run on OHLC bars. For TBBO tick-level indicators, use a Rust .rs file with the #[indicator] macro instead.

Parameters

  • name (str, default required): Display name shown in the indicator panel
  • overlay (bool, default False): If True, draws on price chart. If False, separate pane
  • description (str, default None): Optional tooltip description
  • data_mode (str, default 'ohlc'): Always 'ohlc' for Python indicators. Use Rust for tick.
  • timeframe (Timeframe, default None): Target timeframe for the indicator

Returns

None. Use plot() inside calculate() to draw output.

Example

from quant_charts import indicator, input, plot, close

@indicator("SMA", overlay=True)
class SMA:
    period = input.int(20, "Period", min=1, max=500)
    color = input.color("#2962FF", "Color")

    def calculate(self, df):
        result = close.rolling(self.period).mean()
        plot(result, f"SMA({self.period})", color=self.color)

Notes

  • calculate() is called every time the chart data updates.
  • Use input.* class variables to create user-configurable parameters.
  • Multiple plot() calls create multiple series on the same indicator.

@strategy

@strategy(name, overlay?, timeframe?, description?, data_mode?, uses_sltp?, emit_sltp?, required_columns?)

Mark a Python class as a backtestable trading strategy.

The @strategy decorator creates a trading strategy that returns entry/exit signals for backtesting. The class must implement calculate(self, df) which returns a dict of boolean signal Series.

The backtester uses these signals to simulate trades and compute equity curves, win rates, drawdowns, and other performance metrics.

Python strategies run on OHLC bars only. For tick-level TBBO strategies use a Rust .rs file.

Parameters

  • name (str, default required): Display name shown in the strategy panel
  • overlay (bool, default True): If True, draws on the price chart
  • timeframe (Timeframe, default None): Default execution timeframe
  • description (str, default None): Optional tooltip description
  • data_mode (str, default 'ohlc'): Always 'ohlc' for Python strategies
  • uses_sltp (bool, default False): If True, the strategy returns sl_long/tp_long/sl_short/tp_short arrays
  • emit_sltp (str, default 'entry_only'): 'entry_only' (set once at entry) or 'per_tick' (re-evaluated each bar)
  • required_columns (list[str], default auto): Columns the strategy reads. SHM only ships these to workers.

Returns

A dict with keys: "entry_long", "exit_long", "entry_short", "exit_short". With uses_sltp=True, also "sl_long", "tp_long", "sl_short", "tp_short".

Example

from quant_charts import strategy, input, indicators, cross_above, cross_below, Timeframe

@strategy("MA Cross", overlay=True, timeframe=Timeframe.M1)
class MACross:
    fast = input.int(10, "Fast", min=1, max=100)
    slow = input.int(20, "Slow", min=2, max=200)

    def calculate(self, df):
        fast_ma = indicators.sma(period=self.fast, color="#26A69A")
        slow_ma = indicators.sma(period=self.slow, color="#EF5350")

        return {
            'entry_long': cross_above(fast_ma, slow_ma),
            'exit_long': cross_below(fast_ma, slow_ma),
            'entry_short': cross_below(fast_ma, slow_ma),
            'exit_short': cross_above(fast_ma, slow_ma),
        }

Notes

  • Use indicators.sma() / indicators.ema() to add visual overlays alongside your strategy.
  • Signal Series must be boolean. Use cross_above() / cross_below() for crossovers.
  • Conflicting signals on the same bar: exit is resolved before entry.
  • Hoist param-independent work into a @day_start method for fast optimization sweeps.

@day_start

@day_start(method)

Mark a method to run once per day per worker, before the parameter sweep.

Hoists parameter-independent computation out of the per-combo calculate() body. The decorated method runs once per trading day per worker process; results stored on self are visible inside calculate() for every parameter combination.

Use this for any heavy work that does not depend on swept params: full-series ta calls with literal periods, day-level summary stats, regime detection, cached indicator outputs.

Parameters

  • method (callable, default required): Method on the strategy/indicator class with signature (self, df) -> None

Returns

None. Store hoisted arrays as self.<name> for use inside calculate().

Example

from quant_charts import strategy, day_start, input, ta, close, cross_above, cross_below

@strategy("Hoisted EMA Cross")
class HoistedCross:
    fast = input.int(10, "Fast", min=2, max=50)
    slow = input.int(30, "Slow", min=5, max=200)

    @day_start
    def prep(self, df):
        # hoisted: param-independent, runs once per day not once per combo
        self.atr = ta.atr(df['high'], df['low'], df['close'], 14)

    def calculate(self, df):
        fast_ma = close.rolling(self.fast).mean()
        slow_ma = close.rolling(self.slow).mean()
        return {
            'entry_long': cross_above(fast_ma, slow_ma),
            'exit_long':  cross_below(fast_ma, slow_ma),
            'entry_short': cross_below(fast_ma, slow_ma),
            'exit_short':  cross_above(fast_ma, slow_ma),
        }

Notes

  • Critical for fast optimization sweeps: a 100-combo sweep with hoisted ATR runs the ATR once instead of 100 times.
  • Anything assigned to self.* inside @day_start is visible in calculate().
  • Calls inside @day_start should use literal periods, not swept parameters.

@script

@script(name, description?, version?)

Mark a Python class as a utility script for data analysis. Used in .py files only (not notebooks).

The @script decorator creates a standalone script that runs once when executed. Unlike indicators and strategies, scripts do not draw on the chart. Instead they process data, export files, or produce analysis output.

Note: Scripts are standalone .py files executed from the file explorer. For notebook-based analysis, use regular Python cells instead.

Scripts have access to script_helpers for exporting data, reading chart state, and accessing indicator values.

Parameters

  • name (str, default required): Display name for the script
  • description (str, default None): Optional description
  • version (str, default None): Optional version string

Returns

Optional dict, displayed in the output panel.

Example

from quant_charts import script, input
from quant_charts.script_helpers import get_chart_data, export_csv

@script("Data Export")
class DataExporter:
    filename = input.string("export.csv", "Filename")

    def execute(self, df):
        df.to_csv(self.filename)
        return {"status": "success", "rows": len(df)}

Output

>>> Output
{'status': 'success', 'rows': 14523}

Notes

  • Scripts implement execute(self, df) instead of calculate(self, df).
  • Import from quant_charts.script_helpers for data access and export.

Signal Helpers

Crossover detection, threshold checks, streak counting, and order-flow primitives (cvd, imbalance, vwap_band).

cross_above

cross_above(series1, series2)

Detect where series1 crosses above series2.

Returns a boolean Series that is True on bars where series1 crosses above series2. A crossover occurs when series1 was at or below series2 on the previous bar and is now above it.

Parameters

  • series1 (Series, default required): The series crossing up
  • series2 (Series, default required): The reference level (series or number)

Returns

numpy boolean array

Example

from quant_charts import cross_above, close
fast_ma = close.rolling(10).mean()
slow_ma = close.rolling(20).mean()
buy_signal = cross_above(fast_ma, slow_ma)

Notes

  • Equivalent to: (s1 > s2) & (s1.shift(1) <= s2.shift(1))

cross_below

cross_below(series1, series2)

Detect where series1 crosses below series2.

Returns a boolean Series that is True on bars where series1 crosses below series2.

Parameters

  • series1 (Series, default required): The series crossing down
  • series2 (Series, default required): The reference level (series or number)

Returns

numpy boolean array

Example

sell_signal = cross_below(fast_ma, slow_ma)

Notes

  • Equivalent to: (s1 < s2) & (s1.shift(1) >= s2.shift(1))

above

above(series, value)

True where series is strictly above value.

Element-wise comparison. Works with both numpy arrays and pandas Series. value can be a number or another series.

Parameters

  • series (array-like, default required): Input data
  • value (number | array, default required): Threshold value or series

Returns

numpy boolean array

Example

from quant_charts import above, ta, close
rsi = ta.rsi(close, 14)
is_overbought = above(rsi, 70)

Notes

  • Strict comparison: series > value

below

below(series, value)

True where series is strictly below value.

Parameters

  • series (array-like, default required): Input data
  • value (number | array, default required): Threshold value or series

Returns

numpy boolean array

Example

is_oversold = below(rsi, 30)

Notes

  • Strict comparison: series < value

between

between(series, lower, upper)

True where lower <= series <= upper (inclusive).

Parameters

  • series (array-like, default required): Input data
  • lower (number, default required): Lower bound (inclusive)
  • upper (number, default required): Upper bound (inclusive)

Returns

numpy boolean array

Example

is_neutral = between(rsi, 30, 70)

Notes

  • Inclusive on both ends: lower <= series <= upper

rising

rising(series, length)

True if series has been rising for N consecutive bars.

Parameters

  • series (array-like, default required): Input data
  • length (int, default required): Number of consecutive rising bars required

Returns

numpy boolean array

Example

from quant_charts import rising, close
uptrend = rising(close, 3)  # 3 consecutive higher closes

falling

falling(series, length)

True if series has been falling for N consecutive bars.

Parameters

  • series (array-like, default required): Input data
  • length (int, default required): Number of consecutive falling bars required

Returns

numpy boolean array

Example

downtrend = falling(close, 3)

barssince

barssince(condition)

Count bars since condition was last True.

Returns an array where each element is the number of bars since condition was last True. Values before the first True are NaN.

Parameters

  • condition (bool array, default required): Boolean condition array

Returns

numpy float array (NaN before first occurrence)

Example

from quant_charts import barssince, cross_above
bars_since_buy = barssince(cross_above(fast, slow))

valuewhen

valuewhen(condition, source, occurrence?)

Get the value of source when condition was last True.

Returns the value of source at the most recent bar where condition was True. occurrence=0 is the most recent, occurrence=1 is the one before that.

Parameters

  • condition (bool array, default required): Boolean condition array
  • source (array-like, default required): Values to sample from
  • occurrence (int, default 0): 0 = most recent, 1 = previous, etc.

Returns

numpy float array

Example

from quant_charts import valuewhen, cross_above, close
entry_price = valuewhen(cross_above(fast, slow), close, 0)

imbalance

imbalance(bid_size, ask_size)

Order-book imbalance: bid / (bid + ask).

Returns a value in [0, 1] where 0.5 is balanced, > 0.5 is bid-heavy (buy pressure), < 0.5 is ask-heavy. Safe on zero volume.

Parameters

  • bid_size (array-like, default required): Bid-side size series
  • ask_size (array-like, default required): Ask-side size series

Returns

numpy array of floats in [0, 1]

Example

from quant_charts import imbalance, bid_vol, ask_vol
imb = imbalance(bid_vol, ask_vol)
buy_pressure = imb > 0.6

cvd

cvd(delta)

Cumulative volume delta: running sum of signed volume.

Trends up when buyers lift the offer, down when sellers hit the bid.

Parameters

  • delta (array-like, default required): Per-bar signed delta (e.g. delta series)

Returns

numpy array of cumulative delta

Example

from quant_charts import cvd, delta
cumdelta = cvd(delta)

vwap_band

vwap_band(vwap, atr, mult?)

VWAP envelope: (upper, lower) at mult * ATR.

Parameters

  • vwap (array-like, default required): VWAP series
  • atr (array-like, default required): ATR series (same length as vwap)
  • mult (float, default 1.0): Band width as multiple of ATR

Returns

(upper, lower) tuple of numpy arrays

Example

from quant_charts import vwap_band, vwap, ta, high, low, close
atr = ta.atr(high, low, close, 14)
upper, lower = vwap_band(vwap, atr, mult=1.5)

Backtest Results

Load and analyze prior backtest runs from notebook scripts.

runner.run

runner.run(strategy, data, date_range, params, *, timeframe=None, starting_equity=100000, position_mode="sequential", slippage=0)

Run a strategy across a date range with one parameter set. Works for .py and .rs.

Runs the same engine the analyzer uses, returns a full BacktestResults with all analytics. Call from any Python context: editor Run, notebook cell. Works on Rust TBBO strategies too: you analyze the trades in Python while the strategy logic stays in Rust.

Runs through the optimization worker thread (separate Python subprocess), so it doesn't block the chart, the editor pool, or each other when called from inside an editor Run.

Parameters

  • strategy (str): Path to a .py or .rs file (relative to workspace strategies/ folder, or absolute)
  • data (str): Path to a .parquet file (relative to workspace data/ folder, or absolute)
  • date_range ((str, str)): Inclusive ISO date tuple, e.g. ("2025-01-01", "2025-01-31")
  • params (dict): Strategy parameter values. Names must match the strategy file declarations.

Returns

BacktestResults

Example

from quant_charts import runner
r = runner.run(
    strategy='strategies/ma_cross.py',
    data='ES.parquet',
    date_range=('2025-01-01', '2025-01-31'),
    params={'fast': 10, 'slow': 30},
)
print(r.summary())
r.plot_equity()
import matplotlib.pyplot as plt; plt.show()

Notes

  • For .rs strategies the analyzer engine is invoked exactly as the chart-attached path; trade results are identical.
  • Sharing _runLock with the analyzer: if the Analyzer is mid-optimize, runner.run() blocks until it finishes (a "runner: waiting for analyzer" log line is printed while waiting).
  • Worked TBBO/Rust example: notebooks/built-in/multi_day.ipynb.

runner.optimize

runner.optimize(strategy, data, date_range, grid, *, metric="sharpe", timeframe=None)

Run a parameter grid sweep across a date range. Returns SweepResults.

The analyzer's full grid sweep, callable from Python. Each grid axis must be uniformly spaced (use list(range(...)) or numpy.linspace). Works for .py and .rs strategies; Rust strategies are routed through the same worker pool the Analyzer uses.

Parameters

  • strategy (str): Path to a .py or .rs file
  • data (str): Path to a .parquet data file
  • date_range ((str, str)): Inclusive ISO date tuple
  • grid (dict[str, list]): Param-name to uniformly-spaced value list
  • metric (str, default "sharpe"): "sharpe", "profit_factor", "total_pnl", "win_rate", "avg_snr", "median_snr", "recovery_factor", "payoff_ratio"

Returns

SweepResults

Example

from quant_charts import runner
sweep = runner.optimize(
    strategy='strategies/momentum.rs',
    data='ES_TBBO.parquet',
    date_range=('2025-01-01', '2025-01-31'),
    grid={
        'period': list(range(10, 31, 2)),
        'threshold': [0.3, 0.5, 0.7],
    },
    metric='sharpe',
)
print(sweep.results_df.head())
sweep.scatter_3d('period', 'threshold', 'sharpeRatio')
sweep.best().plot_equity()

Notes

  • Grid axes MUST be uniformly spaced. The engine sweeps min/max/step; arbitrary value lists are rejected.
  • sweep.best() lazily fetches trades for the top-scoring combo from the in-memory trade store.

qc.run / qc.optimize / qc.wfa

qc.run / qc.optimize / qc.wfa(strategy, data, date_range, params|grid, ...)

Run a fresh backtest, sweep, or WFA inline from a notebook. Returns a BacktestResults (run) or SweepResults (optimize / wfa).

Inline backtest entry points. No UI involvement. Same engine the Analyzer uses; results are bit-for-bit identical.

For sweeps, grid axes must be uniformly spaced (use range(...) or numpy.linspace). For WFA, pass in_sample_days and out_of_sample_days plus a grid.

Returns

BacktestResults | SweepResults

Example

import quant_charts as qc

r = qc.run('strategies/foo.py',
           data='ES.parquet',
           date_range=('2026-04-15', '2026-04-15'),
           params={'fast': 9, 'slow': 21})
r.summary()

opt = qc.optimize('strategies/foo.py',
                  data='ES.parquet',
                  date_range=('2026-04-15', '2026-04-15'),
                  grid={'fast': range(5, 21), 'slow': range(15, 50)})
opt.top(10)

qc.runs / load_run / save_run / delete_run

qc.runs / load_run / save_run / delete_run() | (name)

Saved-runs registry. List, load, save, or delete named exports.

Saved runs persist with an index.json registry. Names are alphanumeric plus _-.. Saved runs are explicit and never overwritten by future analyzer runs; call delete_run(name) to clean up.

Returns

pd.DataFrame | BacktestResults | dict | None

Example

import quant_charts as qc

qc.runs()                       # DataFrame: name, strategy, dateRange, kind, nTrades, savedAt
r = qc.load_run('ema_cross_a')
qc.save_run('my_combo')         # persist whatever\'s in memory
qc.delete_run('my_combo')

qc.cancel

qc.cancel()

Cancel the in-flight runner call. Safe from a signal handler or another thread.

Sends a cancel signal to the runner bridge. The currently blocked qc.run / qc.optimize / qc.wfa call raises RunnerCancelled on its next poll. No-op when no call is in flight.

Use case: Jupyter cell that started a long sweep; press the stop button (which triggers cancel) without waiting for the worker to finish naturally.

Returns

None

Example

import quant_charts as qc
import threading

def stop_after(seconds):
    import time
    time.sleep(seconds)
    qc.cancel()

threading.Thread(target=stop_after, args=(60,), daemon=True).start()
# Will be cancelled after 60s if still running:
opt = qc.optimize('strategies/foo.py', data='ES.parquet',
                  date_range=('2026-04-01', '2026-04-30'),
                  grid={'fast': range(5, 30), 'slow': range(20, 60)})

load_csv

load_csv(name_or_path?)

Load a CSV trade export into a BacktestResults for analysis.

Loads trades from a CSV file exported via the Analyzer's Export button. The CSV is parsed back into a full BacktestResults with equity curve, so all analysis methods (summary, plot_equity, monte_carlo, filtering) work the same as for an inline qc.run().

With no argument, loads the most recent CSV in the exports/ folder.

Parameters

  • name_or_path (str, default None): CSV filename (e.g. "my_backtest.csv"), full path, or None for most recent

Returns

BacktestResults

Example

from quant_charts.results import load_csv
r = load_csv()                     # most recent export
r = load_csv("MA_Cross_2024.csv")  # specific file
r.summary()
r.longs().plot_equity()
r.monte_carlo(1000).plot()

Notes

  • CSV files are saved to the exports/ folder when you click Export in the Analyzer top bar.
  • Tags are preserved. r.by_tag("morning") works on CSV-loaded results.
  • Use list_exports() to see available CSV files.

list_exports

list_exports()

List available CSV trade exports with dates and sizes.

Prints a formatted table of all CSV files in the exports/ folder, sorted by modification time (newest first). Returns a list of filenames.

Returns

list[str]

Example

from quant_charts.results import list_exports
files = list_exports()

Output

>>> list_exports()
Filename                                 Modified             Size
----------------------------------------------------------------------
MA_Cross_2024-01-01_to_2024-06-30.csv   2024-07-15 14:32       12.3K
RSI_Scalper_2024-03-01_to_2024-03-31.csv 2024-07-14 09:18        4.1K

r.summary

r.summary()

Print comprehensive stats table.

Displays total trades, win rate, PnL, profit factor, Sharpe ratio, max drawdown, MAE/MFE, streaks, average duration, and more.

Returns

None (prints table)

Example

r.summary()

r.trades

All trades as a pandas DataFrame.

Columns: id, side, entryTime, exitTime, entryPrice, exitPrice, pnl, pnlPercent, mae, mfe, snr, duration, exitReason, tags, stopLoss, takeProfit. Also has entryTime_et / exitTime_et (Eastern Time).

Returns

pandas DataFrame

Example

# Standard pandas operations on trades
r.trades.groupby('side')['pnl'].describe()
r.trades[r.trades['duration'] < 60000]
r.trades.query('exitReason == "sl_hit"')['pnl'].hist()

r.winners / r.losers

r.winners / r.losers()

Filter by PnL sign. Returns a new BacktestResults.

All filter methods return a new BacktestResults. The original is never modified. The filtered result has its own rebuilt equity curve.

Returns

BacktestResults

Example

r.winners().summary()
r.losers().plot_equity()

r.longs / r.shorts

r.longs / r.shorts()

Filter by trade side.

Returns

BacktestResults

Example

r.longs().winners().summary()  # winning longs

r.by_tag

r.by_tag(tag_name)

Filter to trades that have a specific tag.

Parameters

  • tag_name (str, default required): Tag name to filter by

Returns

BacktestResults

Example

r.by_tag("morning").summary()

r.where

r.where(**kwargs)

Django-style field lookups for flexible filtering.

Operators: field=value (exact), field__gt (>), field__lt (<), field__gte (>=), field__lte (<=), field__ne (!=), field__in (list), field__contains (string), field__hour (ET hour), field__dayofweek (0=Mon).

Three-part accessor for timestamps: entryTime__hour__gte=9

Returns

BacktestResults

Example

# Morning longs that won
r.longs().where(
    entryTime__hour__gte=9,
    entryTime__hour__lte=11,
    pnl__gt=0
).summary()

r.plot_equity

r.plot_equity(by_trade?)

Plot the equity curve.

Parameters

  • by_trade (bool, default True): True = x-axis is trade number, False = time

Returns

matplotlib Figure

Example

r.plot_equity()
r.winners().plot_equity()

r.monte_carlo

r.monte_carlo(n?, method?)

Monte Carlo simulation for risk assessment.

Shuffles or bootstraps trade PnL to estimate range of outcomes. Returns a MonteCarloResult with .plot(), .summary(), .percentile(), .ruin_probability(), and .confidence_interval() methods.

Parameters

  • n (int, default 1000): Number of simulations
  • method (str, default "bootstrap"): "bootstrap" or "shuffle"

Returns

MonteCarloResult

Example

mc = r.monte_carlo(1000)
mc.plot(ci=0.95)
mc.summary()
mc.ruin_probability(5000)

r.simulate_sl_tp

r.simulate_sl_tp(sl, tp)

Simulate different SL/TP using MAE/MFE data (instant, no re-run).

Parameters

  • sl (float, default required): Stop loss in dollars
  • tp (float, default required): Take profit in dollars

Returns

BacktestResults

Example

sim = r.simulate_sl_tp(sl=30, tp=15)
print(f"Original: ${r.trades.pnl.sum():.2f}")
print(f"Simulated: ${sim.trades.pnl.sum():.2f}")

r.monthly_returns

r.monthly_returns()

Year x Month pivot table of PnL.

Returns

pandas DataFrame

Example

r.monthly_returns()

Strategy API

#[strategy], the Strategy trait, SignalOutput shape, SL/TP emission modes, #[param], #[tag] declarations.

#[strategy]

#[strategy](name, description?, data_mode, timeframe?, emit_sltp?, execution?, uses_vp?, columns?)

Metadata attribute. Marks a struct as a Rust strategy (TBBO ticks or OHLC bars).

Stripped before compilation; the parser at electron-side reads these kwargs to register the strategy with the UI and pick the right runner (tick vs bar). The struct must #[derive(Default)] and implement either the Strategy trait (tick) or the OhlcStrategy trait (bar).

data_mode is REQUIRED. There is no default. "tick" feeds raw TBBO and uses the tick runner; "ohlc" feeds an OHLC bar buffer (native parquet OR TBBO aggregated to timeframe) and uses the bar runner. The choice is enforced at metadata parse time: omitting it throws a clear error.

timeframe is required when data_mode = "ohlc" (e.g. "1m", "5m"). Ignored for data_mode = "tick".

uses_vp = true enables the per-bar Volume Profile snapshot pre-compute and requires data_mode = "tick" with execution = "bar". Use it together with the VpStrategy trait for VP-zone style strategies.

columns = [...] is a UI hint about extra TBBO columns the strategy reads via data.col("name"). Undeclared columns still work; declaring them just surfaces the list in the inspector.

Parameters

  • name (str, default "Unnamed Strategy"): Display name in the strategy panel
  • description (str, default ""): Tooltip description
  • data_mode (str, default required): "tick" (raw TBBO) or "ohlc" (bar input)
  • timeframe (str, default none): Bar timeframe ("1m", "5m", ...). Required when data_mode = "ohlc".
  • emit_sltp (str, default "per_tick"): "per_tick" (default; SL/TP re-evaluated each tick) or "entry_only" (locked at entry)
  • execution (str, default auto): "tick" or "bar". Auto-set to "bar" when data_mode = "ohlc". For data_mode = "tick" you can opt into bar pacing.
  • uses_vp (bool, default false): Enables per-bar VP snapshot pre-compute. Requires data_mode = "tick" + execution = "bar".
  • columns ([&str], default []): Extra TBBO columns this strategy reads (UI hint)

Example

// Tick (TBBO) strategy
#[strategy(
    name = "Microprice Reversion",
    description = "Mean-revert against microprice deviation",
    data_mode = "tick",
    emit_sltp = "per_tick",
    columns = ["volume", "delta"],
)]
#[derive(Default)]
pub struct MicropriceReversion { /* fields */ }

// OHLC bar strategy
#[strategy(
    name = "EMA Cross",
    description = "Fast/slow EMA crossover at bar close",
    data_mode = "ohlc",
    timeframe = "1m",
    emit_sltp = "entry_only",
)]
#[derive(Default)]
pub struct EmaCross { /* fields */ }

Notes

  • data_mode = "ohlc" implies execution = "bar". You cannot have OHLC input with tick pacing.
  • uses_vp = true cannot be combined with data_mode = "ohlc"; VP snapshots are built from raw ticks.
  • OHLC strategies implement OhlcStrategy. Tick strategies implement Strategy. The same struct cannot do both.

#[param]

#[param](default, min?, max?, step?, label?, tooltip?)

Field attribute. Marks a struct field as a swept parameter.

Only fields annotated with #[param] are filled from the UI. Other fields use Default::default() and act as per-combo scratch memory.

Parameters

  • default (literal, default required): Default value (int, float, or string)
  • min (literal, default auto): Minimum allowed value
  • max (literal, default auto): Maximum allowed value
  • step (literal, default 1 or 0.1): Slider step
  • label (str, default field name): Display label in the params panel
  • tooltip (str, default ""): Hover tooltip

Example

#[param(default = 14, min = 2, max = 200, label = "Period")]
pub period: usize,

#[param(default = 2.0, min = 0.5, max = 5.0, step = 0.25, label = "ATR Mult",
        tooltip = "Stop distance as multiple of ATR")]
pub atr_mult: f64,

#[tag]

#[tag](name, label?, color?, description?)

Struct attribute. Declares UI metadata for a tag the strategy emits.

Tags are free-form. You can attach any tag name in calculate() via .with_tag(name, values) and it will work. #[tag] declarations exist purely so the UI shows nice labels and colors instead of auto-generated ones.

Parameters

  • name (str, default required): Tag identifier (matches with_tag() key)
  • label (str, default name): Display label
  • color (str, default auto): Hex color for the tag badge
  • description (str, default ""): Tooltip description

Example

#[tag(name = "morning", label = "Morning Session", color = "#7aa2f7", description = "9:30am-11am ET")]
#[tag(name = "trend", label = "Trend Filter", color = "#26A69A")]

Strategy trait (tick path)

Strategy trait (tick path)impl Strategy for MyStrategy

Two methods: prepare() (per-day hoisted work) and calculate() (per-combo tick signal generation). Implemented when data_mode = "tick".

Implement Strategy on your struct when data_mode = "tick". prepare() is optional and defaults to DayPrep::empty(). calculate() is required and returns a SignalOutput whose arrays have length equal to the tick count.

For data_mode = "ohlc" use the OhlcStrategy trait instead. The metadata parser uses your data_mode kwarg to pick the right runner; you only implement the matching trait.

Example

impl Strategy for MyStrategy {
    fn prepare(data: &TickData) -> DayPrep {
        let mut prep = DayPrep::empty();
        prep.insert_f64("atr", ta::atr_bid_ask(&data.bid, &data.ask, 30));
        prep
    }

    fn calculate(&self, data: &TickData, prep: &DayPrep) -> SignalOutput {
        let n = data.len();
        let atr = prep.f64("atr").unwrap_or(&[]);
        let mut entry_long = vec![false; n];
        let mut sl_long = vec![f64::NAN; n];

        for i in self.period..n {
            if data.mid[i] < data.bid[i] {
                entry_long[i] = true;
                if let Some(a) = atr.get(i) {
                    sl_long[i] = data.mid[i] - a * 2.0;
                }
            }
        }

        SignalOutput::new(entry_long, vec![false; n], vec![false; n], vec![false; n])
            .with_sl_long(sl_long)
            .with_entry_only_sltp()
    }
}

OhlcStrategy trait (bar path)

OhlcStrategy trait (bar path)impl OhlcStrategy for MyStrategy

Bar-input sibling of Strategy. Use when data_mode = "ohlc". Receives BarData; returns BarSignalOutput.

Implement OhlcStrategy when data_mode = "ohlc". The runner projects bars-as-ticks at write time, so each bar becomes one row in the bundle. prepare() is optional (default empty); calculate() is required.

Key shape differences vs Strategy:

  • Receives &BarData (open / high / low / close / volume series) instead of &TickData.
  • Returns BarSignalOutput (constructed via BarSignalOutput::for_bars(n)) instead of SignalOutput::new(...).
  • All output arrays have length data.len() (bar count).

Example

use qc_strategy_api::prelude::*;

#[derive(Default)]
pub struct EmaCross { pub fast: usize, pub slow: usize }

impl OhlcStrategy for EmaCross {
    fn calculate(&self, data: &BarData, _prep: &DayPrep) -> BarSignalOutput {
        let fast = ta::ema(&data.close, self.fast);
        let slow = ta::ema(&data.close, self.slow);
        let mut out = BarSignalOutput::for_bars(data.len());
        for i in 1..data.len() {
            if fast[i-1] <= slow[i-1] && fast[i] > slow[i] {
                out.entry_long[i] = true;
            }
            if fast[i-1] >= slow[i-1] && fast[i] < slow[i] {
                out.exit_long[i] = true;
            }
        }
        out
    }
}

SignalOutput

SignalOutputSignalOutput::new(entry_long, exit_long, entry_short, exit_short)

Return type from calculate(). Builder pattern for SL/TP, tags, and emission mode.

Each of the four bool Vecs has length equal to the tick count. Optional SL/TP arrays are added via .with_sl_long(), .with_tp_long(), .with_sl_short(), .with_tp_short(). Emission mode defaults to per-tick; call .with_entry_only_sltp() to lock SL/TP at entry instead.

Free-form tags via .with_tag(name, values). UI metadata via .with_tag_config(name, label, color) (or via the #[tag] macro).

Parameters

  • entry_long (Vec<bool>, default required): True on bars where a long entry fires
  • exit_long (Vec<bool>, default required): True on bars where a long position should close
  • entry_short (Vec<bool>, default required): True on bars where a short entry fires
  • exit_short (Vec<bool>, default required): True on bars where a short position should close

Returns

SignalOutput (use builder methods to attach SL/TP, tags, emission mode)

Example

SignalOutput::new(entry_long, exit_long, entry_short, exit_short)
    .with_sl_long(sl_long)
    .with_tp_long(tp_long)
    .with_sl_short(sl_short)
    .with_tp_short(tp_short)
    .with_entry_only_sltp()
    .with_tag("morning", morning_mask)
    .with_tag("trend",   trend_mask)

with_size_long / with_size_short

with_size_long / with_size_short.with_size_long(Vec<f64>) / .with_size_short(Vec<f64>)

Per-trade dynamic position sizing.

Size at each entry tick where entry_long[i] == true (or entry_short[i] == true). Length must equal the tick count. When unset, the engine uses the UI-configured default size (current behavior).

Use cases: volatility-scaled sizing (size = target_dollar_risk / atr[i]), Kelly-criterion sizing, equity-fraction sizing.

Parameters

  • sizes (Vec<f64>, default required): Length-N vector. Read at the entry tick; ignored on non-entry ticks. NaN/Inf -> use default.

Example

let target_risk = 100.0; // dollars
let sizes: Vec<f64> = atr.iter().map(|a| target_risk / a.max(0.5)).collect();

SignalOutput::new(entry_long, exit_long, entry_short, exit_short)
    .with_size_long(sizes.clone())
    .with_size_short(sizes)

Notes

  • Composes with fill_model(): a VolumeWeightedFillModel can clip the requested size based on available bid/ask depth.
  • Defaults to the UI size when None or when entry slot has NaN/Inf.

with_slippage_per_tick

with_slippage_per_tick.with_slippage_per_tick(Vec<f64>)

Variable slippage per tick (overrides the UI scalar).

When set, the engine reads slippage_per_tick[i] at fill time instead of the UI scalar. Use this to model wider slippage at market open / news events / wide-spread regimes.

Length must equal the tick count. Composes with fill_model() - the FillModel receives the per-tick slippage in its FillContext.

Parameters

  • slips (Vec<f64>, default required): Per-tick slippage (price units). NaN -> use scalar.

Example

// 4x slippage in the 30-second window around 8:30 ET news drops
let mut slips = vec![scalar; n];
let news_start = ts_for("08:29:30");
let news_end   = ts_for("08:30:30");
for i in 0..n {
    if data.timestamp[i] >= news_start && data.timestamp[i] <= news_end {
        slips[i] = scalar * 4.0;
    }
}

SignalOutput::new(...).with_slippage_per_tick(slips)

without_sl_ratchet

without_sl_ratchet.without_sl_ratchet()

Disable the auto-ratchet on SL (longs only rise, shorts only fall).

Default behavior: once a long SL has been raised by the strategy, the engine clamps subsequent values so SL never drops below the running max. This protects against accidental SL widening.

Call .without_sl_ratchet() for chandelier-style trailing exits where the SL CAN retreat (e.g. ATR widening pulls SL further from price during a volatility spike). The engine then takes whatever the strategy emits at each tick.

Example

// chandelier exit: SL widens with ATR
let chandelier_sl: Vec<f64> = (0..n).map(|i| close[i] - atr[i] * 3.0).collect();
SignalOutput::new(...)
    .with_sl_long(chandelier_sl)
    .without_sl_ratchet()

fill_model

fill_modelfn fill_model(&self) -> Option<Box<dyn FillModel>>

Optional Strategy method. Override the default ask/bid fill behavior.

By default the engine fills longs at ask + slippage and shorts at bid - slippage (AskBidFillModel). Override fill_model() on your Strategy impl to plug in a different model.

The FillModel trait sees a FillContext with bid/ask/mid/sizes/side/action and returns a FillResult with price, filled_size (can be partial), and effective slippage.

Built-in implementations:

  • AskBidFillModel { slippage: f64 } (default).
  • MidpointFillModel { slippage: f64 } - fills at (bid + ask) / 2. Useful for limit-order modeling.
  • VolumeWeightedFillModel { max_consume_pct: f64 } - clips fill size based on available bid/ask size; partial fills are returned.

Custom impls: implement FillModel for MyModel with fn fill(&self, ctx: &FillContext) -> FillResult. Anything goes (limit-with-offset, volume-aware, regime-aware, etc.).

Example

use qc_strategy_api::prelude::*;

impl Strategy for MyStrategy {
    fn fill_model(&self) -> Option<Box<dyn FillModel>> {
        Some(Box::new(VolumeWeightedFillModel { max_consume_pct: 0.10 }))
    }

    fn calculate(&self, data: &TickData, prep: &DayPrep) -> SignalOutput {
        // ... your logic ...
    }
}

Notes

  • Engine pre-baseline tests assert byte-identical results when fill_model() returns None.
  • MidpointFillModel is the simplest non-default; great for limit-order backtests.
  • VolumeWeightedFillModel returns partial filled_size when available size is below requested - integrate with with_size_long/short for proper sizing semantics.

SL/TP emission modes

PerTick (default) re-evaluates SL/TP each tick. EntryOnly locks them at entry.

SltpEmission::PerTick (default): the bracket engine reads sl_long[i] / tp_long[i] on every tick of an open position. Use this for trailing stops or any pattern where the bracket should adapt mid-trade.

SltpEmission::EntryOnly via .with_entry_only_sltp(): the engine reads SL/TP only on the entry tick and ignores all subsequent values. Use this for fixed brackets (the common case).

The difference matters for performance and semantics: per-tick is more flexible but the SL/TP arrays must be filled meaningfully on every in-position tick, not just at entry.

Example

// Trailing stop: per-tick re-evaluation
SignalOutput::new(...)
    .with_sl_long(trailing_sl)  // sl_long[i] updates each tick
    // emit_sltp defaults to PerTick

// Fixed bracket: locks at entry
SignalOutput::new(...)
    .with_sl_long(entry_sl)
    .with_tp_long(entry_tp)
    .with_entry_only_sltp()
Machine-readable: full API at /llms-full.txt · examples on GitHub.