Everything needed to write a backtestable Quant Charts strategy, Python and Rust.
Quant Charts Strategy API Reference (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, defaultrequired): Display name shown in the indicator paneloverlay(bool, defaultFalse): IfTrue, draws on price chart. IfFalse, separate panedescription(str, defaultNone): Optional tooltip descriptiondata_mode(str, default'ohlc'): Always'ohlc'for Python indicators. Use Rust for tick.timeframe(Timeframe, defaultNone): 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, defaultrequired): Display name shown in the strategy paneloverlay(bool, defaultTrue): IfTrue, draws on the price charttimeframe(Timeframe, defaultNone): Default execution timeframedescription(str, defaultNone): Optional tooltip descriptiondata_mode(str, default'ohlc'): Always'ohlc'for Python strategiesuses_sltp(bool, defaultFalse): IfTrue, the strategy returnssl_long/tp_long/sl_short/tp_shortarraysemit_sltp(str, default'entry_only'):'entry_only'(set once at entry) or'per_tick'(re-evaluated each bar)required_columns(list[str], defaultauto): 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_startmethod 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, defaultrequired): 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_startis visible incalculate(). - Calls inside
@day_startshould 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, defaultrequired): Display name for the scriptdescription(str, defaultNone): Optional descriptionversion(str, defaultNone): 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 ofcalculate(self, df). - Import from
quant_charts.script_helpersfor 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, defaultrequired): The series crossing upseries2(Series, defaultrequired): 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, defaultrequired): The series crossing downseries2(Series, defaultrequired): 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, defaultrequired): Input datavalue(number | array, defaultrequired): 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, defaultrequired): Input datavalue(number | array, defaultrequired): 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, defaultrequired): Input datalower(number, defaultrequired): Lower bound (inclusive)upper(number, defaultrequired): 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, defaultrequired): Input datalength(int, defaultrequired): 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, defaultrequired): Input datalength(int, defaultrequired): 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, defaultrequired): 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, defaultrequired): Boolean condition arraysource(array-like, defaultrequired): Values to sample fromoccurrence(int, default0): 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, defaultrequired): Bid-side size seriesask_size(array-like, defaultrequired): 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, defaultrequired): Per-bar signed delta (e.g.deltaseries)
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, defaultrequired): VWAP seriesatr(array-like, defaultrequired): ATR series (same length as vwap)mult(float, default1.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 filedata(str): Path to a .parquet data filedate_range((str, str)): Inclusive ISO date tuplegrid(dict[str, list]): Param-name to uniformly-spaced value listmetric(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, defaultNone): CSV filename (e.g."my_backtest.csv"), full path, orNonefor 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, defaultrequired): 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, defaultTrue):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, default1000): Number of simulationsmethod(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, defaultrequired): Stop loss in dollarstp(float, defaultrequired): 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 paneldescription(str, default""): Tooltip descriptiondata_mode(str, defaultrequired): "tick" (raw TBBO) or "ohlc" (bar input)timeframe(str, defaultnone): 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, defaultauto): "tick" or "bar". Auto-set to "bar" when data_mode = "ohlc". For data_mode = "tick" you can opt into bar pacing.uses_vp(bool, defaultfalse): 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, defaultrequired): Default value (int, float, or string)min(literal, defaultauto): Minimum allowed valuemax(literal, defaultauto): Maximum allowed valuestep(literal, default1 or 0.1): Slider steplabel(str, defaultfield name): Display label in the params paneltooltip(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, defaultrequired): Tag identifier (matcheswith_tag()key)label(str, defaultname): Display labelcolor(str, defaultauto): Hex color for the tag badgedescription(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 viaBarSignalOutput::for_bars(n)) instead ofSignalOutput::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>, defaultrequired): True on bars where a long entry firesexit_long(Vec<bool>, defaultrequired): True on bars where a long position should closeentry_short(Vec<bool>, defaultrequired): True on bars where a short entry firesexit_short(Vec<bool>, defaultrequired): 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>, defaultrequired): 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(): aVolumeWeightedFillModelcan 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>, defaultrequired): 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. MidpointFillModelis the simplest non-default; great for limit-order backtests.VolumeWeightedFillModelreturns partialfilled_sizewhen available size is below requested - integrate withwith_size_long/shortfor 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()