Templates

Every indicator and strategy template that ships with Quant Charts. Scaffolds are blank skeletons. Built-ins are the canonical reference implementations. Examples are showcase patterns. All are MIT-style permissive: copy any one to your workspace and modify freely.

31 templates, 3,564 lines of code. Click any file name to expand its full source.

Scaffolds

indicator_template.pypython250 lines

Python indicator skeleton with QC disclaimer header.

indicator_template.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""
{FILENAME}
Created: {TIMESTAMP}

{DESCRIPTION}

================================================================================
HOW TO CREATE AN INDICATOR - A BEGINNER'S GUIDE
================================================================================

This template shows you how to create indicators for Quant Charts.
It's designed to be super easy - if you know basic Python, you're ready!

STEP 1: Import what you need
STEP 2: Add the @indicator decorator
STEP 3: Define parameters as class attributes
STEP 4: Write your calculation in calculate(self, df)
STEP 5: Plot the result

Let's go through each step below...

NOTE ON OPTIMIZATION SWEEPS:
Indicators used inside a strategy are also called once per param per day during
a sweep. When an indicator does param-independent work (e.g. a rolling window on
`close` with a literal period, heavy mask building), hoist it the same way the
strategy template does: put param-independent setup in a `prep(self, df)` method
(it runs once per day and is passed to `calculate` as `prep`), and keep only the
`self.*`-dependent work in `calculate`.
"""

# =============================================================================
# STEP 1: IMPORT WHAT YOU NEED
# =============================================================================
# These are the building blocks for your indicator.
#
# - indicator: The decorator that makes your class into an indicator
# - input: Create adjustable parameters (period, color, etc.)
# - plot: Draw lines on the chart
# - close: Access closing prices (this is a pandas Series!)
# - hline: Draw horizontal reference lines (optional)
# - fill: Fill area between two lines (optional)

from quant_charts import indicator, input, plot, close


# =============================================================================
# STEP 2: ADD THE @indicator DECORATOR
# =============================================================================
# The decorator tells Quant Charts this is an indicator.
#
# Parameters:
#   - First arg: The name shown in the UI (e.g., "SMA", "RSI")
#   - overlay=True: Draw ON the price chart (like moving averages)
#   - overlay=False: Draw in a SEPARATE pane (like RSI, MACD)
#
# Example for an oscillator that needs its own pane:
#   @indicator("RSI", overlay=False)

@indicator("{NAME}", overlay=True)  # Python indicators run on OHLC bars; for TBBO tick indicators use a Rust .rs file
class {CLASS_NAME}:
    # =========================================================================
    # STEP 3: DEFINE PARAMETERS
    # =========================================================================
    # Each parameter becomes a slider/input in the settings panel.
    # Users can adjust these without editing your code!
    #
    # Format: name = input.type(default, "Label", options...)
    #
    # Available types:
    #   input.int()    - Whole numbers (periods, lookbacks)
    #   input.float()  - Decimals (multipliers, thresholds)
    #   input.bool()   - True/False toggle
    #   input.color()  - Color picker
    #   input.string() - Text or dropdown menu

    period = input.int(20, "Period", min=1, max=500)
    color = input.color("#2962FF", "Line Color")

    def calculate(self, df):
        """
        {NAME} Indicator

        This is your indicator's description.
        Write a brief explanation of what it does.
        """

        # =====================================================================
        # STEP 4: WRITE YOUR CALCULATION
        # =====================================================================
        # The 'close' variable is a pandas Series containing all closing prices.
        # Use 'self.period' and 'self.color' to access your parameters.
        #
        # You can use ANY pandas operations on it:
        #
        #   close.rolling(20).mean()     - Simple moving average
        #   close.ewm(span=20).mean()    - Exponential moving average
        #   close.diff()                  - Price change from previous bar
        #   close.pct_change()            - Percentage change
        #   close.rolling(20).std()       - Standard deviation (volatility)
        #   close.shift(1)                - Previous bar's value
        #   close > close.shift(1)        - True if price went up
        #
        # Example: Simple Moving Average
        result = close.rolling(self.period).mean()

        # =====================================================================
        # STEP 5: PLOT THE RESULT
        # =====================================================================
        # Use plot() to draw your indicator on the chart.
        #
        # Parameters:
        #   - series: Your calculated data (pandas Series)
        #   - name: Label shown in the legend
        #   - color: Hex color (e.g., "#2962FF" is blue)
        #   - linewidth: Line thickness (1-5, default is 2)

        plot(result, f"{NAME}({self.period})", color=self.color, linewidth=2)


# =============================================================================
# THAT'S IT! Your indicator is ready.
# =============================================================================
# Save this file and it will automatically appear in your indicator list.
# The period and color can be adjusted in the settings panel.


# =============================================================================
# BONUS: MORE EXAMPLES
# =============================================================================
#
# --- Example: RSI with tags (overbought/oversold) ---
# from quant_charts import indicator, input, plot, hline, close, define_tag
#
# @indicator("RSI", overlay=False)
# class RSI:
#     period = input.int(14, "Period", min=2, max=100)
#     overbought = input.int(70, "Overbought", min=50, max=100)
#     oversold = input.int(30, "Oversold", min=0, max=50)
#
#     def calculate(self, df):
#         delta = close.diff()
#         gain = delta.where(delta > 0, 0).rolling(self.period).mean()
#         loss = (-delta.where(delta < 0, 0)).rolling(self.period).mean()
#         rs = gain / loss
#         rsi_value = 100 - (100 / (1 + rs))
#
#         plot(rsi_value, "RSI", color="#9C27B0")
#         hline(self.overbought, "Overbought", color="#EF5350", linestyle="dashed")
#         hline(self.oversold, "Oversold", color="#26A69A", linestyle="dashed")
#
#         define_tag("overbought", f"RSI > {self.overbought}", color="#DC2626")
#         define_tag("oversold", f"RSI < {self.oversold}", color="#16A34A")
#
#         return {
#             "overbought": rsi_value > self.overbought,
#             "oversold": rsi_value < self.oversold,
#         }
#
#
# --- Example: Bollinger Bands with fill ---
# from quant_charts import indicator, input, plot, fill, close
#
# @indicator("Bollinger Bands", overlay=True)
# class BollingerBands:
#     period = input.int(20, "Period")
#     std_dev = input.float(2.0, "Std Dev", min=0.5, max=5.0)
#     color = input.color("#2962FF", "Color")
#
#     def calculate(self, df):
#         middle = close.rolling(self.period).mean()
#         std = close.rolling(self.period).std()
#         upper = middle + (std * self.std_dev)
#         lower = middle - (std * self.std_dev)
#
#         plot(middle, "Middle", color=self.color)
#         plot(upper, "Upper", color=self.color, linewidth=1)
#         plot(lower, "Lower", color=self.color, linewidth=1)
#         fill("Upper", "Lower", color=self.color, opacity=10)
#
#
# --- Example: Two-line crossover (Fast/Slow MA) ---
# from quant_charts import indicator, input, plot, close
#
# @indicator("MA Crossover", overlay=True)
# class MACrossover:
#     fast = input.int(10, "Fast Period", min=1, max=100)
#     slow = input.int(20, "Slow Period", min=1, max=200)
#
#     def calculate(self, df):
#         fast_ma = close.rolling(self.fast).mean()
#         slow_ma = close.rolling(self.slow).mean()
#
#         plot(fast_ma, f"Fast({self.fast})", color="#26A69A")
#         plot(slow_ma, f"Slow({self.slow})", color="#EF5350")


# =============================================================================
# QUICK REFERENCE CHEAT SHEET
# =============================================================================
#
# PRICE DATA (all are pandas Series):
#   close      - Closing prices
#   open       - Opening prices
#   high       - Highest prices
#   low        - Lowest prices
#   volume     - Trading volume
#   hl2        - (High + Low) / 2
#   hlc3       - (High + Low + Close) / 3
#   ohlc4      - (Open + High + Low + Close) / 4
#
# INPUT TYPES:
#   input.int(default, "Label", min=X, max=Y, step=Z)
#   input.float(default, "Label", min=X, max=Y, step=Z)
#   input.bool(default, "Label")
#   input.color("#RRGGBB", "Label")
#   input.string("default", "Label", options=["A", "B", "C"])
#   input.source(Source.CLOSE, "Label")
#   input.timeframe("tick", "Label")
#
# PLOTTING:
#   plot(series, "Name", color="#HEX", linewidth=2)
#   hline(value, "Name", color="#HEX", linestyle="dashed")
#   fill("Series1 Name", "Series2 Name", color="#HEX", opacity=20)
#
# COMMON COLORS:
#   #2962FF - Blue (default)
#   #26A69A - Green (bullish)
#   #EF5350 - Red (bearish)
#   #FF9800 - Orange
#   #9C27B0 - Purple
#   #787B86 - Gray
#
# PANDAS OPERATIONS (use on close, open, high, low, volume):
#   .rolling(N).mean()  - Simple moving average over N bars
#   .ewm(span=N).mean() - Exponential moving average
#   .rolling(N).std()   - Standard deviation
#   .rolling(N).max()   - Highest value in N bars
#   .rolling(N).min()   - Lowest value in N bars
#   .diff()             - Change from previous bar
#   .pct_change()       - Percentage change
#   .shift(1)           - Previous bar's value
#   .abs()              - Absolute value
script_template.pypython66 lines

Minimal Python script (Jupyter-runnable) skeleton.

script_template.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""
{FILENAME}
Created: {TIMESTAMP}

{DESCRIPTION}
"""

from quant_charts import script, input


@script(
    name="{NAME}",
    description="Enter description here",
)
class {CLASS_NAME}:
    """
    Your generic script class

    Use scripts for data processing, analysis, exports, or any custom logic
    that doesn't fit the indicator or strategy paradigm.
    """

    # Define your parameters here
    output_file = input.string(
        default="output.csv",
        label="Output File",
        tooltip="Name of the output file"
    )

    threshold = input.float(
        default=1.0,
        label="Threshold",
        min=0.0,
        max=100.0,
        tooltip="Threshold value for processing"
    )

    def run(self, df):
        """
        Main entry point for the script

        Args:
            df: pandas DataFrame with OHLCV data (if available)

        Returns:
            Any value or None
        """
        # Your script logic here
        print(f"Running {self.__class__.__name__}")
        print(f"Output file: {self.output_file}")
        print(f"Threshold: {self.threshold}")

        # Example: Process data
        if df is not None:
            print(f"DataFrame shape: {df.shape}")
            # Do something with df...

        return None
strategy_template.pypython51 lines

Python OHLC strategy skeleton with entry/exit signals.

strategy_template.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""
{FILENAME}
Created: {TIMESTAMP}

{DESCRIPTION}
"""

import numpy as np
import quant_charts as qc


# The model in one sentence: your signals describe the position you WANT, and
# the engine holds one position at a time and executes it faithfully. There are
# no execution "modes":
#   - entry_long while flat            -> open long
#   - entry_short while long           -> REVERSE (close long + open short)
#   - exit_long while long             -> close to flat
#   - entry_long again while long      -> stay long (a level, no churn)
# Return `size` (scalar or per-bar array) to set contracts per position; omit it
# for unit size. See the EMA Flip / Trail SL/TP / VP Limit built-ins for more.
@qc.strategy(name="{NAME}", timeframe="1m")
class {CLASS_NAME}:
    fast_period = qc.input.int(10, "Fast Period", min=2, max=100)
    slow_period = qc.input.int(20, "Slow Period", min=2, max=200)

    # Optional: hoist param-independent work into prep(). It runs once per day
    # and its return dict is passed to calculate() as `prep`. Delete it (and the
    # `prep` arg below) if calculate() is cheap enough to run per combination.
    def prep(self, df):
        return {"close": np.asarray(df["close"], dtype=np.float64)}

    def calculate(self, df, prep):
        close = prep["close"]
        fast = qc.ta.sma(close, self.fast_period)
        slow = qc.ta.sma(close, self.slow_period)

        return {
            "entry_long":  qc.cross_above(fast, slow),
            "exit_long":   qc.cross_below(fast, slow),
            "entry_short": qc.cross_below(fast, slow),
            "exit_short":  qc.cross_above(fast, slow),
            # "size": 1,   # contracts per position (scalar broadcasts; array sizes per entry)
        }
strategy_template.rsrust110 lines

Rust TBBO strategy skeleton with #[strategy] macro.

strategy_template.rs

expand
//! qc-api: 1.0.8
// DISCLAIMER: This software is for educational and informational purposes only and does not constitute
// financial advice, investment advice, or trading advice. Past performance is not indicative of future
// results. Trading futures and other financial instruments involves substantial risk of loss. You are
// solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
// losses incurred. All rights reserved. (c) Quant Charts LLC

use qc_strategy_api::prelude::*;

/// [YOUR STRATEGY NAME]
///
/// Rust TBBO strategies run per-tick across every parameter combination in parallel.
/// You have full access to bid/ask/mid/spread/bid_size/ask_size on every tick, plus
/// any extra columns the parquet emitted via `data.col("name")`. Missing columns return
/// `None` so your strategy stays robust even on files that lack optional fields.
///
/// - `columns = [...]` (optional): a hint for the UI + loader. Undeclared columns still
///   work via `data.col("name")`; declaring them just surfaces the list in the inspector.
/// - `#[tag(...)]`  (optional): per-tag label/color metadata. Undeclared tag names still
///   emit; they just get auto-generated labels.
#[strategy(
    name = "My Strategy",
    description = "Strategy description",
    columns = ["volume", "delta"],
)]
#[tag(name = "trend_up", label = "Trend Up Entry", color = "#26A69A")]
#[tag(name = "trend_dn", label = "Trend Down Entry", color = "#EF5350")]
#[derive(Default)]
pub struct MyStrategy {
    // Declare each parameter with its range and default. The optimizer sweeps these
    // across the combinations you configure in the UI. Only fields annotated with
    // `#[param(...)]` are filled from the UI; any other field on this struct is
    // initialized via Default::default() so you can keep per-combo caches or state.
    #[param(default = 20, min = 5, max = 100, label = "Period")]
    pub period: usize,

    #[param(default = 2.0, min = 0.5, max = 5.0, step = 0.25, label = "Multiplier")]
    pub multiplier: f64,

    // Custom state (non-param): seed it in Default or compute it lazily in calculate().
    // It is re-built per combo, so treat it as scratch memory.
    #[allow(dead_code)]
    pub scratch: Vec<f64>,
}

impl Strategy for MyStrategy {
    // Optional per-day precomputation. Runs ONCE per day, before the combo sweep.
    // Cache heavy parameter-independent work here (ATR, bucket aggregations, etc.).
    fn prepare(data: &TickData) -> DayPrep {
        let mut prep = DayPrep::empty();
        let atr = ta::atr_bid_ask(&data.bid, &data.ask, 30);
        prep.insert_f64("atr", atr);
        prep
    }

    fn calculate(&self, data: &TickData, prep: &DayPrep) -> SignalOutput {
        let n = data.len();

        // Column access is free-form: any string the parquet has works.
        //   let vol   = data.col_or("volume", &[]);
        //   let delta = data.col_or("delta", &[]);
        //   let custom = data.col("my_custom_col");  // Option<&[f64]>
        // qc_log! writes to the Rust worker's stderr, which surfaces in the VSC output:
        //   qc_log!("loaded {} ticks, has_volume={}", n, data.has_volume());

        let (upper, middle, lower) = ta::bollinger(&data.mid, self.period, self.multiplier);
        let atr = prep.f64("atr").unwrap_or(&[]);

        let mut entry_long = vec![false; n];
        let mut exit_long = vec![false; n];
        let mut entry_short = vec![false; n];
        let mut exit_short = vec![false; n];
        let mut sl_long = vec![f64::NAN; n];
        let mut sl_short = vec![f64::NAN; n];
        // Tag arrays: same length as ticks, true when the condition holds.
        let mut trend_up = vec![false; n];
        let mut trend_dn = vec![false; n];

        for i in self.period..n {
            if data.mid[i] < lower[i] {
                entry_long[i] = true;
                if let Some(a) = atr.get(i) {
                    sl_long[i] = data.mid[i] - a * 2.0;
                }
                trend_up[i] = true;
            }
            if data.mid[i] > upper[i] {
                entry_short[i] = true;
                if let Some(a) = atr.get(i) {
                    sl_short[i] = data.mid[i] + a * 2.0;
                }
                trend_dn[i] = true;
            }
            if middle[i].is_finite() {
                exit_long[i] = data.mid[i] >= middle[i];
                exit_short[i] = data.mid[i] <= middle[i];
            }
        }

        SignalOutput::new(entry_long, exit_long, entry_short, exit_short)
            .with_sl_long(sl_long)
            .with_sl_short(sl_short)
            .with_entry_only_sltp()
            // Free-form tags: any name works. Declare them via #[tag(...)] above
            // for UI label/color, or leave them undeclared and the system will auto-assign.
            .with_tag("trend_up", trend_up)
            .with_tag("trend_dn", trend_dn)
    }
}

Built-in Indicators (Python)

order_flow.pypython58 lines

Per-bar delta histogram + CVD line with bid/ask imbalance tags (OHLC subpane).

workspace/indicators/built-in/python/order_flow.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""Order-flow: per-bar delta histogram + cumulative volume delta.

`delta_series` resolves the per-bar signed volume via the fallback chain
`delta -> ask_vol - bid_vol -> sign(close - open)`. `cvd()` accumulates it.
"""

import numpy as np
import quant_charts as qc


@qc.indicator("Order Flow", overlay=False)
class OrderFlow:
    tags = {
        "delta_positive":     qc.Tag("Per-bar buy-side delta",   "#26A69A"),
        "delta_negative":     qc.Tag("Per-bar sell-side delta",  "#EF5350"),
        "heavy_buy_pressure": qc.Tag("Heavy bid imbalance",      "#26A69A"),
        "heavy_sell_pressure":qc.Tag("Heavy ask imbalance",      "#EF5350"),
    }

    imbalance_threshold = qc.input.float(0.65, "Imbalance Threshold", min=0.5, max=1.0, step=0.01)
    pos_color = qc.input.color("#26A69A", "Buy Pressure Color")
    neg_color = qc.input.color("#EF5350", "Sell Pressure Color")

    def calculate(self, df):
        n = len(df)

        delta_arr = qc.delta_series(df)
        cvd_arr   = qc.cvd(delta_arr)

        delta_colors = np.where(delta_arr >= 0, self.pos_color, self.neg_color)
        qc.plot_histogram_colored(delta_arr, "Delta", color=self.pos_color, colors=delta_colors)
        qc.hline(0.0, "Zero", color="#63636e", linestyle="dashed")
        qc.plot(cvd_arr, "CVD", color="#7AA2F7", linewidth=2)

        bid_vol_arr = qc.df_col_or(df, "bid_vol")
        ask_vol_arr = qc.df_col_or(df, "ask_vol")
        if bid_vol_arr is not None and ask_vol_arr is not None:
            imb = qc.imbalance(bid_vol_arr, ask_vol_arr)
            heavy_buy  = imb > self.imbalance_threshold
            heavy_sell = imb < (1.0 - self.imbalance_threshold)
        else:
            heavy_buy  = np.zeros(n, dtype=bool)
            heavy_sell = np.zeros(n, dtype=bool)

        return {
            "delta_positive":      delta_arr > 0,
            "delta_negative":      delta_arr < 0,
            "heavy_buy_pressure":  heavy_buy,
            "heavy_sell_pressure": heavy_sell,
        }
rsi.pypython50 lines

RSI oscillator subpane with hline reference levels and overbought / oversold / neutral tags.

workspace/indicators/built-in/python/rsi.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""RSI oscillator in its own pane, with overbought / oversold / neutral tags.

The smallest demo of a non-overlay indicator: `overlay=False` puts it in a
separate pane, `qc.ta.rsi` does the Wilder smoothing, and `qc.hline` draws the
reference levels. The three tag arrays let the analyzer split stats by regime
(and a strategy can gate entries on them via `qc.use_indicator`).
"""

import numpy as np
import quant_charts as qc


@qc.indicator("RSI", overlay=False)
class Rsi:
    tags = {
        "overbought": qc.Tag("RSI above the overbought level", "#EF5350"),
        "oversold":   qc.Tag("RSI below the oversold level",   "#26A69A"),
        "neutral":    qc.Tag("RSI between the two levels",      "#A1A1AA"),
    }

    period     = qc.input.int(14, "RSI Period", min=2, max=10000)
    overbought = qc.input.int(70, "Overbought", min=50, max=100)
    oversold   = qc.input.int(30, "Oversold",   min=0,  max=50)
    line_color = qc.input.color("#BB9AF7", "RSI Color")

    def calculate(self, df):
        rsi = qc.ta.rsi(df.close, self.period)

        qc.plot(rsi, f"RSI({self.period})", color=self.line_color, linewidth=2)
        qc.hline(float(self.overbought), "Overbought", color="#EF5350", linestyle="dashed")
        qc.hline(float(self.oversold),   "Oversold",   color="#26A69A", linestyle="dashed")
        qc.hline(50.0,                   "Midline",    color="#63636e", linestyle="dotted")

        finite = np.isfinite(rsi)
        overbought = (rsi > self.overbought) & finite
        oversold   = (rsi < self.oversold) & finite
        neutral    = finite & ~overbought & ~oversold
        return {
            "overbought": overbought,
            "oversold":   oversold,
            "neutral":    neutral,
        }
swing_labels.pypython116 lines

Fractal swing high / low pivot labels drawn with the custom_layer canvas API (zig-zag, triangles, price text).

workspace/indicators/built-in/python/swing_labels.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""Swing Labels - draw your own marks on the chart with the custom canvas API.

`plot` / `hline` / `plotshape` cover the common cases. When you want something
they cannot express (a zig-zag, a filled marker, a price text label), reach for
`custom_layer`: an imperative 2D canvas you draw on directly, replayed through
the chart's renderer with pan / zoom respected.

This indicator finds fractal swing highs / lows and, for each, draws:
- a zig-zag polyline connecting the pivots in order,
- a small filled triangle pointing at the bar,
- a price text label.

The canvas builder chains: `.style(...)` sets a sticky style, `.begin()` opens a
path, `.move_to()` / `.line_to()` trace it, `.close()` / `.fill()` / `.stroke()`
render it, and `.text()` draws a label. `.emit()` registers the layer (you do not
need to return it). `z="top"` keeps the marks above the candles.

Pivots are confirmed using bars on BOTH sides, so a fresh pivot only appears once
the chart has printed `strength` more bars past it.
"""

import numpy as np
import pandas as pd
import quant_charts as qc


@qc.indicator("Swing Labels", overlay=True)
class SwingLabels:
    tags = {
        "swing_high": qc.Tag("Bar is a confirmed fractal swing high", "#F7768E"),
        "swing_low":  qc.Tag("Bar is a confirmed fractal swing low",  "#73DACA"),
    }

    strength   = qc.input.int(3, "Pivot Strength", min=1, max=100,
                              tooltip="Bars required on each side of a pivot to confirm it.")
    high_color = qc.input.color("#F7768E", "Swing High Color")
    low_color  = qc.input.color("#73DACA", "Swing Low Color")
    show_labels = qc.input.bool(True, "Price Labels")

    def calculate(self, df):
        high = np.asarray(df["high"], dtype=np.float64)
        low = np.asarray(df["low"], dtype=np.float64)
        ts = np.asarray(df["timestamp"], dtype=np.float64)
        n = len(high)
        if n == 0:
            return {"swing_high": np.zeros(0, bool), "swing_low": np.zeros(0, bool)}

        # Fractal pivots: the window is centered, so it reads `strength` bars on
        # either side. NaN edges compare False, so the first/last bars never mark.
        win = 2 * self.strength + 1
        is_high = (pd.Series(high) == pd.Series(high).rolling(win, center=True).max()).to_numpy()
        is_low = (pd.Series(low) == pd.Series(low).rolling(win, center=True).min()).to_numpy()

        # Vertical breathing room for marks / labels, scaled to recent range so
        # they sit just off the bar at any price.
        atr = qc.ta.atr(df["high"], df["low"], df["close"], 14)
        off = np.nanmedian(atr)
        if not np.isfinite(off) or off <= 0:
            off = float(np.nanmedian(high - low)) or 1.0
        diffs = np.diff(ts)
        bar_ms = float(np.median(diffs[diffs > 0])) if np.any(diffs > 0) else 60_000.0
        half = bar_ms * 0.4

        hi_idx = np.flatnonzero(is_high)
        lo_idx = np.flatnonzero(is_low)

        layer = qc.custom_layer("swing-labels", z="top")

        # Zig-zag: one stroked path through every pivot in chronological order.
        pivots = sorted(
            [(ts[i], high[i]) for i in hi_idx] + [(ts[i], low[i]) for i in lo_idx],
            key=lambda p: p[0],
        )
        if len(pivots) >= 2:
            layer.style(stroke="#565f8988", line_width=1.0, dash=[2, 3])
            layer.begin()
            layer.move_to(pivots[0])
            for pt in pivots[1:]:
                layer.line_to(pt)
            layer.stroke()

        # Swing highs: down-triangle above the bar, label on top.
        layer.style(fill=self.high_color, stroke=self.high_color, line_width=1.0,
                    dash=[], font="11px Inter", text_align="center", text_baseline="bottom")
        for i in hi_idx:
            top = high[i] + off * 0.7
            layer.begin()
            layer.move_to((ts[i] - half, top))
            layer.line_to((ts[i] + half, top))
            layer.line_to((ts[i], high[i] + off * 0.15))
            layer.close().fill().stroke()
            if self.show_labels:
                layer.text((ts[i], top + off * 0.1), f"{high[i]:.2f}")

        # Swing lows: up-triangle below the bar, label underneath.
        layer.style(fill=self.low_color, stroke=self.low_color, text_baseline="top")
        for i in lo_idx:
            bot = low[i] - off * 0.7
            layer.begin()
            layer.move_to((ts[i] - half, bot))
            layer.line_to((ts[i] + half, bot))
            layer.line_to((ts[i], low[i] - off * 0.15))
            layer.close().fill().stroke()
            if self.show_labels:
                layer.text((ts[i], bot - off * 0.1), f"{low[i]:.2f}")

        layer.emit()
        return {"swing_high": is_high, "swing_low": is_low}
volume_profile.pypython213 lines

Session-anchored OHLC Volume Profile: POC, value area, HVN / LVN tags.

workspace/indicators/built-in/python/volume_profile.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""Volume Profile - anchored profile with POC, value area, and HVN/LVN tags.

For true per-print fidelity (every tick binned, not just bar typical price),
use the Rust `volume_profile.rs` indicator. This Python version is bar-level: fast and
portable, but coarser on heavy-trading bars.
"""

import numpy as np
import quant_charts as qc


@qc.indicator("Volume Profile")
class VolumeProfile:
    tags = {
        "at_poc":            qc.Tag("Close near POC",              "#E0AF68"),
        "inside_value_area": qc.Tag("Close between VAL and VAH",   "#73DACA"),
        "above_vah":         qc.Tag("Close above VAH",             "#26A69A"),
        "below_val":         qc.Tag("Close below VAL",             "#EF5350"),
        "near_hvn":          qc.Tag("Close near a high-volume node", "#7AA2F7"),
        "near_lvn":          qc.Tag("Close near a low-volume node",  "#F7768E"),
    }

    anchor_kind     = qc.input.string("aligned_hour", "Anchor",
                                      options=["session", "aligned_hour", "aligned_30m", "aligned_15m"])
    use_completed_vp = qc.input.bool(True, "Use Completed VP")
    row_size        = qc.input.float(0.25, "Row Size", min=0.01, max=100.0, step=0.01)
    value_area_pct  = qc.input.float(0.70, "Value Area %", min=0.10, max=0.99, step=0.01)
    hvn_threshold   = qc.input.float(0.85, "HVN Threshold", min=0.30, max=1.00, step=0.05)
    lvn_threshold   = qc.input.float(0.20, "LVN Threshold", min=0.01, max=0.50, step=0.01)
    proximity_ticks = qc.input.int(2, "Proximity (ticks)", min=0, max=100)
    poc_color       = qc.input.color("#E0AF68", "POC Color")
    va_color        = qc.input.color("#73DACA", "Value Area Color")
    render_histogram   = qc.input.bool(True, "Render Histogram")
    histogram_color    = qc.input.color("#7AA2F7", "Histogram Color")
    histogram_align    = qc.input.string("right_of_range", "Histogram Align",
                                         options=["right_of_range", "left_of_range", "over_range", "pinned_right", "pinned_left"])
    histogram_width_px = qc.input.int(80, "Histogram Width (px)", min=20, max=400, step=5)

    _ANCHOR_MS = {"aligned_hour": 3_600_000, "aligned_30m": 1_800_000, "aligned_15m": 900_000}

    def _build_period_vp(self, high, low, close, vol):
        finite = (np.isfinite(high) & np.isfinite(low) & np.isfinite(close)
                  & np.isfinite(vol) & (vol > 0.0))
        if not finite.any():
            return None
        h, l, v = high[finite], low[finite], vol[finite]
        lo, hi = float(np.min(l)), float(np.max(h))
        if not (np.isfinite(lo) and np.isfinite(hi)) or hi <= lo:
            return None

        bin_count = max(1, int(np.ceil((hi - lo) / self.row_size)) + 1)
        b_lo = np.clip(((l - lo) / self.row_size).astype(np.int64), 0, bin_count - 1)
        b_hi = np.clip(((h - lo) / self.row_size).astype(np.int64), 0, bin_count - 1)
        share = v / (b_hi - b_lo + 1).astype(np.float64)
        diff = np.zeros(bin_count + 1, dtype=np.float64)
        np.add.at(diff, b_lo, share)
        np.add.at(diff, b_hi + 1, -share)
        rows = np.cumsum(diff[:-1])
        total = float(rows.sum())
        if total <= 0.0:
            return None

        poc_bin = int(np.argmax(rows))
        poc_price = lo + (poc_bin + 0.5) * self.row_size
        poc_vol = float(rows[poc_bin])

        target = total * self.value_area_pct
        cum = poc_vol
        lo_b = hi_b = poc_bin
        while cum < target and (lo_b > 0 or hi_b < bin_count - 1):
            left  = rows[lo_b - 1] if lo_b > 0 else -1.0
            right = rows[hi_b + 1] if hi_b < bin_count - 1 else -1.0
            if right >= left:
                hi_b += 1; cum += rows[hi_b]
            else:
                lo_b -= 1; cum += rows[lo_b]
        val_p = lo + (lo_b + 0.5) * self.row_size
        vah_p = lo + (hi_b + 0.5) * self.row_size

        hvn_levels, lvn_levels = [], []
        for i in range(1, bin_count - 1):
            if i == poc_bin:
                continue
            r = rows[i]
            if r >= self.hvn_threshold * poc_vol and r >= rows[i - 1] and r >= rows[i + 1]:
                hvn_levels.append(lo + (i + 0.5) * self.row_size)
            elif r <= self.lvn_threshold * poc_vol and r <= rows[i - 1] and r <= rows[i + 1] and r > 0:
                lvn_levels.append(lo + (i + 0.5) * self.row_size)

        return {"poc_price": poc_price, "vah": vah_p, "val": val_p,
                "hvn_levels": hvn_levels, "lvn_levels": lvn_levels,
                "bins": rows, "price_min": lo, "total_volume": total}

    def calculate(self, df):
        high  = np.asarray(df["high"],  dtype=np.float64)
        low   = np.asarray(df["low"],   dtype=np.float64)
        close = np.asarray(df["close"], dtype=np.float64)
        vol   = qc.volume_series(df)
        n = len(close)
        if n == 0:
            return {}

        ts = np.asarray(df["timestamp"], dtype=np.int64) if "timestamp" in df.columns else None

        if self.anchor_kind == "session" or ts is None:
            anchor_idx = np.zeros(n, dtype=np.int64)
            bucket_starts = np.array([int(ts[0]) if ts is not None and ts.size > 0 else 0], dtype=np.int64)
        else:
            period_ms = self._ANCHOR_MS.get(self.anchor_kind, 3_600_000)
            bucket = (ts // period_ms) * period_ms
            bucket_starts, anchor_idx = np.unique(bucket, return_inverse=True)

        num_periods = int(bucket_starts.size)
        period_vps = [None] * num_periods
        period_anchor_ts = [int(bucket_starts[p]) for p in range(num_periods)]
        period_last_ts = [0] * num_periods
        for p in range(num_periods):
            mask = anchor_idx == p
            if not mask.any():
                continue
            res = self._build_period_vp(high[mask], low[mask], close[mask], vol[mask])
            if res is None:
                continue
            period_vps[p] = res
            if ts is not None:
                p_ts = ts[mask]
                if p_ts.size > 0:
                    period_last_ts[p] = int(p_ts[-1])

        prox = self.proximity_ticks * self.row_size
        at_poc    = np.zeros(n, dtype=bool)
        inside_va = np.zeros(n, dtype=bool)
        above_vah = np.zeros(n, dtype=bool)
        below_val = np.zeros(n, dtype=bool)
        near_hvn_arr = np.zeros(n, dtype=bool)
        near_lvn_arr = np.zeros(n, dtype=bool)

        ref_for_period = [None] * num_periods
        for p in range(num_periods):
            base = p - 1 if self.use_completed_vp else p
            while base >= 0 and period_vps[base] is None:
                base -= 1
            ref_for_period[p] = period_vps[base] if base >= 0 else None

        for i in range(n):
            ref = ref_for_period[int(anchor_idx[i])]
            if ref is None:
                continue
            c = close[i]
            if abs(c - ref["poc_price"]) <= prox: at_poc[i] = True
            if c >= ref["val"] and c <= ref["vah"]: inside_va[i] = True
            if c > ref["vah"]: above_vah[i] = True
            if c < ref["val"]: below_val[i] = True
            for lvl in ref["hvn_levels"]:
                if abs(c - lvl) <= prox: near_hvn_arr[i] = True; break
            for lvl in ref["lvn_levels"]:
                if abs(c - lvl) <= prox: near_lvn_arr[i] = True; break

        last_completed = num_periods - 1
        if self.use_completed_vp:
            last_completed -= 1
        while last_completed >= 0 and period_vps[last_completed] is None:
            last_completed -= 1
        if last_completed >= 0:
            r = period_vps[last_completed]
            qc.hline(r["poc_price"], "POC", color=self.poc_color, linestyle="solid")
            qc.hline(r["vah"], "VAH", color=self.va_color, linestyle="dashed")
            qc.hline(r["val"], "VAL", color=self.va_color, linestyle="dashed")

        if self.render_histogram and ts is not None:
            max_visual = num_periods - 1 if self.use_completed_vp else num_periods
            for p in range(max_visual):
                v = period_vps[p]
                if v is None:
                    continue
                anchor_ts = period_anchor_ts[p]
                end_ts = period_anchor_ts[p + 1] if p + 1 < num_periods else period_last_ts[p]
                if self.use_completed_vp:
                    line_anchor = end_ts
                    line_end = period_anchor_ts[p + 2] if p + 2 < num_periods else int(ts[-1])
                    if line_end < line_anchor: line_end = line_anchor
                else:
                    line_anchor = anchor_ts
                    line_end = end_ts
                qc.vp_visual(
                    anchor_ts=int(anchor_ts), end_ts=int(end_ts),
                    price_min=float(v["price_min"]), price_step=float(self.row_size),
                    bins=v["bins"],
                    poc_price=float(v["poc_price"]), vah=float(v["vah"]), val=float(v["val"]),
                    color=self.histogram_color, poc_color=self.poc_color,
                    value_area_color=self.va_color, opacity=60,
                    width_px=int(self.histogram_width_px), align=self.histogram_align,
                    style="bars",
                    hvn_levels=v["hvn_levels"] if v["hvn_levels"] else None,
                    lvn_levels=v["lvn_levels"] if v["lvn_levels"] else None,
                    draw_level_lines=True,
                    level_lines_anchor_ts=int(line_anchor),
                    level_lines_end_ts=int(line_end),
                )

        return {
            "at_poc": at_poc, "inside_value_area": inside_va,
            "above_vah": above_vah, "below_val": below_val,
            "near_hvn": near_hvn_arr, "near_lvn": near_lvn_arr,
        }
vwap.pypython58 lines

Session VWAP with deviation bands and above / inside / below tags.

workspace/indicators/built-in/python/vwap.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""Session VWAP with deviation bands and above/inside/below tags.

`qc.vwap_series` handles the column-priority fallback: precomputed `vwap` column
when present, else session-cumulative VWAP from typical price * volume.
"""

import numpy as np
import quant_charts as qc


@qc.indicator("VWAP", continuous=True)
class Vwap:
    tags = {
        "above_vwap_band":  qc.Tag("Close above VWAP + N sigma", "#26A69A"),
        "below_vwap_band":  qc.Tag("Close below VWAP - N sigma", "#EF5350"),
        "inside_vwap_band": qc.Tag("Close within +/- N sigma of VWAP", "#A1A1AA"),
    }

    band_mult  = qc.input.float(2.0, "Band Multiplier (x sigma)", min=0.0, max=10.0, step=0.05)
    line_color = qc.input.color("#7AA2F7", "VWAP Color")
    band_color = qc.input.color("#73DACA", "Band Color")

    def calculate(self, df):
        vwap     = qc.vwap_series(df)
        vol_safe = qc.volume_series(df)
        close_arr = np.asarray(df.close, dtype=np.float64)

        diff = close_arr - vwap
        cum_var_num = np.cumsum((diff * diff) * vol_safe)
        cum_vol     = np.cumsum(vol_safe)
        var   = cum_var_num / np.where(cum_vol > 0.0, cum_vol, np.nan)
        sigma = np.sqrt(np.maximum(var, 0.0))

        upper = vwap + self.band_mult * sigma
        lower = vwap - self.band_mult * sigma

        qc.plot(vwap,  "VWAP",        color=self.line_color, linewidth=2)
        qc.plot(upper, "VWAP Upper",  color=self.band_color, linewidth=1, opacity=60)
        qc.plot(lower, "VWAP Lower",  color=self.band_color, linewidth=1, opacity=60)
        qc.fill("VWAP Upper", "VWAP Lower", color=self.band_color, opacity=8)

        finite = np.isfinite(upper) & np.isfinite(lower)
        above_band  = (close_arr > upper) & finite
        below_band  = (close_arr < lower) & finite
        inside_band = finite & ~above_band & ~below_band
        return {
            "above_vwap_band":  above_band,
            "below_vwap_band":  below_band,
            "inside_vwap_band": inside_band,
        }

Built-in Indicators (Rust)

atr.rsrust105 lines

Wilder ATR over bars with low / medium / high regime tags (OHLC subpane).

workspace/indicators/built-in/rust/atr.rs

expand
//! qc-api: 1.0.8
// DISCLAIMER: This software is for educational and informational purposes only and does not constitute
// financial advice, investment advice, or trading advice. Past performance is not indicative of future
// results. Trading futures and other financial instruments involves substantial risk of loss. You are
// solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
// losses incurred. All rights reserved. (c) Quant Charts LLC

//! ATR (Rust / OHLC bars).
//!
//! Required columns: high, low, close. Data mode: OHLC bars.
//!
//! Wilder ATR over `period` bars. The chart timeframe pill picks the bar
//! cadence (1m, 5m, ...). Three regime tags fire based on ATR vs the day's
//! median bar-ATR. Renders only in OHLC chart views.

use qc_strategy_api::prelude::*;

#[indicator(
    name = "ATR",
    description = "Wilder ATR with low/medium/high regime tags. OHLC-mode.",
    overlay = false,
    data_mode = "ohlc",
    timeframe = "1m"
)]
#[tag(name = "low_atr",    label = "Low ATR",    color = "#7AA2F7", description = "Bar ATR below low_mult x median")]
#[tag(name = "medium_atr", label = "Medium ATR", color = "#A1A1AA", description = "Bar ATR between low_mult and high_mult x median")]
#[tag(name = "high_atr",   label = "High ATR",   color = "#F7768E", description = "Bar ATR above high_mult x median")]
#[derive(Default)]
pub struct Atr {
    #[param(default = 14, min = 2, max = 500, label = "ATR Period (bars)",
            tooltip = "ATR lookback in bars at the indicator's declared timeframe.")]
    pub period: usize,

    #[param(default = 0.7, min = 0.0, max = 10.0, step = 0.05, label = "Low Threshold (x median)",
            tooltip = "Bar ATR below this many medians = low_atr.")]
    pub low_mult: f64,

    #[param(default = 1.4, min = 0.0, max = 10.0, step = 0.05, label = "High Threshold (x median)",
            tooltip = "Bar ATR above this many medians = high_atr.")]
    pub high_mult: f64,
}

impl OhlcIndicator for Atr {
    fn calculate(&self, data: &BarData, _prep: &DayPrep) -> IndicatorOutput {
        let n = data.len();
        if n == 0 || self.period < 2 {
            return IndicatorOutput::new().with_overlay(false);
        }

        // Wilder ATR over bar high/low/close.
        let mut atr = vec![f64::NAN; n];
        if n > self.period {
            let mut tr = vec![0.0; n];
            tr[0] = (data.high[0] - data.low[0]).max(0.0);
            for i in 1..n {
                let h = data.high[i];
                let l = data.low[i];
                let pc = data.close[i - 1];
                tr[i] = (h - l).max((h - pc).abs()).max((l - pc).abs());
            }
            let p = self.period as f64;
            let seed: f64 = tr[..self.period].iter().sum::<f64>() / p;
            atr[self.period - 1] = seed;
            for i in self.period..n {
                atr[i] = (atr[i - 1] * (p - 1.0) + tr[i]) / p;
            }
        }

        // Regime thresholds from the day's median bar ATR.
        let mut finite: Vec<f64> = atr.iter().copied().filter(|v| v.is_finite()).collect();
        let median_atr = if finite.is_empty() {
            f64::NAN
        } else {
            finite.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
            finite[finite.len() / 2]
        };
        let low_thresh = median_atr * self.low_mult;
        let high_thresh = median_atr * self.high_mult;

        let mut low_atr = vec![false; n];
        let mut medium_atr = vec![false; n];
        let mut high_atr = vec![false; n];
        for i in 0..n {
            let v = atr[i];
            if !v.is_finite() {
                continue;
            }
            if v < low_thresh {
                low_atr[i] = true;
            } else if v > high_thresh {
                high_atr[i] = true;
            } else {
                medium_atr[i] = true;
            }
        }

        IndicatorOutput::new()
            .with_overlay(false)
            .plot_line("ATR", atr, "#E0AF68")
            .with_tag("low_atr", low_atr)
            .with_tag("medium_atr", medium_atr)
            .with_tag("high_atr", high_atr)
    }
}
moving_averages.rsrust84 lines

Fast / slow / trend EMA overlay with bullish / bearish / mixed stack tags (OHLC).

workspace/indicators/built-in/rust/moving_averages.rs

expand
//! qc-api: 1.0.8
// DISCLAIMER: This software is for educational and informational purposes only and does not constitute
// financial advice, investment advice, or trading advice. Past performance is not indicative of future
// results. Trading futures and other financial instruments involves substantial risk of loss. You are
// solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
// losses incurred. All rights reserved. (c) Quant Charts LLC

//! Moving Averages (Rust / OHLC bars).
//!
//! Required columns: close. Data mode: OHLC bars.
//!
//! Fast / slow / trend EMAs drawn ON the price chart (`overlay = true`), the
//! canonical price-pane `OhlcIndicator`. Three stack-order tags fire from the
//! relative position of the three lines: a clean bullish stack (fast > slow >
//! trend), a clean bearish stack (fast < slow < trend), or anything in between.
//! Clone this file and swap `ta::ema` for `ta::sma` (or mix the two) to build
//! your own overlay.

use qc_strategy_api::prelude::*;

#[indicator(
    name = "Moving Averages",
    description = "Fast / slow / trend EMA overlay with bullish / bearish / mixed stack tags. OHLC-mode.",
    overlay = true,
    data_mode = "ohlc",
    timeframe = "1m"
)]
#[tag(name = "bull_stack",  label = "Bullish Stack",  color = "#26A69A", description = "fast EMA > slow EMA > trend EMA")]
#[tag(name = "bear_stack",  label = "Bearish Stack",  color = "#EF5350", description = "fast EMA < slow EMA < trend EMA")]
#[tag(name = "mixed_stack", label = "Mixed",          color = "#A1A1AA", description = "EMAs not cleanly stacked either way")]
#[derive(Default)]
pub struct MovingAverages {
    #[param(default = 9, min = 2, max = 5000, label = "Fast EMA (bars)",
            tooltip = "Fast EMA lookback in bars at the indicator's declared timeframe.")]
    pub fast: usize,

    #[param(default = 21, min = 2, max = 5000, label = "Slow EMA (bars)",
            tooltip = "Slow EMA lookback in bars.")]
    pub slow: usize,

    #[param(default = 50, min = 2, max = 5000, label = "Trend EMA (bars)",
            tooltip = "Trend EMA lookback in bars. The slowest of the three; defines the stack baseline.")]
    pub trend: usize,
}

impl OhlcIndicator for MovingAverages {
    fn calculate(&self, data: &BarData, _prep: &DayPrep) -> IndicatorOutput {
        let n = data.len();
        if n == 0 {
            return IndicatorOutput::new().with_overlay(true);
        }

        let fast = ta::ema(&data.close, self.fast);
        let slow = ta::ema(&data.close, self.slow);
        let trend = ta::ema(&data.close, self.trend);

        let mut bull = vec![false; n];
        let mut bear = vec![false; n];
        let mut mixed = vec![false; n];
        for i in 0..n {
            let (f, s, t) = (fast[i], slow[i], trend[i]);
            if !(f.is_finite() && s.is_finite() && t.is_finite()) {
                continue;
            }
            if f > s && s > t {
                bull[i] = true;
            } else if f < s && s < t {
                bear[i] = true;
            } else {
                mixed[i] = true;
            }
        }

        IndicatorOutput::new()
            .with_overlay(true)
            .plot_line("Fast EMA", fast, "#7AA2F7")
            .plot_line("Slow EMA", slow, "#E0AF68")
            .plot_line("Trend EMA", trend, "#BB9AF7")
            .with_tag("bull_stack", bull)
            .with_tag("bear_stack", bear)
            .with_tag("mixed_stack", mixed)
    }
}
order_flow.rsrust143 lines

Tick-level smoothed bid/ask imbalance and CVD slope tags (TBBO).

workspace/indicators/built-in/rust/order_flow.rs

expand
//! qc-api: 1.0.8
// DISCLAIMER: This software is for educational and informational purposes only and does not constitute
// financial advice, investment advice, or trading advice. Past performance is not indicative of future
// results. Trading futures and other financial instruments involves substantial risk of loss. You are
// solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
// losses incurred. All rights reserved. (c) Quant Charts LLC

//! Order-Flow (Rust / TBBO).
//!
//! Required columns: bid, ask, bid_size, ask_size (TBBO native).
//! Optional columns: delta (used by CVD; falls back to ask-bid sign if absent).
//! Data mode: TBBO (tick).
//!
//! Pure imbalance + CVD across the day. Tags fire from the smoothed
//! imbalance (bid_dominant / ask_dominant) and from the CVD slope vs
//! `cvd_lookback` ticks ago (cvd_rising / cvd_falling). Use these as
//! filter / record tags on a TBBO strategy or for analyzer membership
//! filters.
//!
//! Pair with `volume_profile.rs` to read this order flow against VP zones.
//!
//! Note: this indicator runs on a separate normalized pane (overlay = false),
//! so it intentionally does NOT plot price-scale series. Mixing a [0, 1]
//! imbalance histogram with a ~$20,000 price line collapses the y-axis.

use qc_strategy_api::prelude::*;

#[indicator(
    name = "Order Flow",
    description = "CVD + bid/ask dominance tags from TBBO sizes",
    overlay = false,
    data_mode = "tick",
    // Per-tick output (smoothed CVD over raw ticks). In OHLC view the chart
    // can't align ~700k tick points to ~390 bars meaningfully, so the UI
    // hides this indicator there. Switch to tick view to render it.
    cross_view = false
)]
#[tag(name = "bid_dominant",  label = "Bid Dominant",  color = "#26A69A", description = "Imbalance > 0.5 + threshold (heavy bids)")]
#[tag(name = "ask_dominant",  label = "Ask Dominant",  color = "#EF5350", description = "Imbalance < 0.5 - threshold (heavy asks)")]
#[tag(name = "cvd_rising",    label = "CVD Rising",    color = "#73DACA", description = "Smoothed CVD higher than `cvd_lookback` ticks ago")]
#[tag(name = "cvd_falling",   label = "CVD Falling",   color = "#F7768E", description = "Smoothed CVD lower than `cvd_lookback` ticks ago")]
#[derive(Default)]
pub struct OrderFlow {
    #[param(default = 0.20, min = 0.0, max = 0.49, step = 0.01, label = "Imbalance Threshold",
            tooltip = "Distance from 0.5 that qualifies as 'dominant' pressure. 0 = always-on; \
                       0.45 = only the most extreme one-sided ticks.")]
    pub imbalance_threshold: f64,

    #[param(default = 500, min = 1, max = 200000, label = "Smoothing Window (ticks)",
            tooltip = "Rolling-mean window applied to the imbalance histogram, in ticks. \
                       ~500 ticks ~ 15s on MNQ TBBO; 1 = no smoothing.")]
    pub smoothing: usize,

    #[param(default = 5000, min = 2, max = 1000000, label = "CVD Lookback (ticks)",
            tooltip = "Window for the CVD-rising / CVD-falling direction tags. \
                       ~5000 ticks ~ 2.5min on MNQ; tighter = noisier flips.")]
    pub cvd_lookback: usize,
}

impl Indicator for OrderFlow {
    fn prepare(data: &TickData) -> DayPrep {
        let mut prep = DayPrep::empty();
        // CVD is param-independent; compute once per day.
        if let Some(delta) = data.col("delta") {
            prep.insert_f64("cvd", cvd(delta));
        }
        prep
    }

    fn calculate(&self, data: &TickData, prep: &DayPrep) -> IndicatorOutput {
        let n = data.len();

        // Imbalance + smoothed. This pane is normalized [0, 1] - keep all
        // plotted series in that range so the y-axis isn't blown out by a
        // price-scale value bleeding into the chart.
        let raw_imb = imbalance(&data.bid_size, &data.ask_size);
        let imb_smooth = if self.smoothing > 1 {
            rolling_mean(&raw_imb, self.smoothing)
        } else {
            raw_imb.clone()
        };

        // Tag arrays.
        let upper = 0.5 + self.imbalance_threshold;
        let lower = 0.5 - self.imbalance_threshold;
        let mut bid_dominant = vec![false; n];
        let mut ask_dominant = vec![false; n];
        for i in 0..n {
            let v = imb_smooth[i];
            if !v.is_finite() { continue; }
            if v > upper { bid_dominant[i] = true; }
            else if v < lower { ask_dominant[i] = true; }
        }

        // CVD direction tags. CVD itself is hoisted in prepare(); only the
        // rising/falling comparison runs here (param-dependent on cvd_lookback).
        // Take an owned Vec from either path so the borrow checker stays happy.
        let mut cvd_rising = vec![false; n];
        let mut cvd_falling = vec![false; n];
        let cvd_owned: Option<Vec<f64>> = if let Some(c) = prep.f64("cvd") {
            Some(c.to_vec())
        } else {
            data.col("delta").map(|d| cvd(d))
        };
        if let Some(cv) = cvd_owned.as_ref() {
            let lk = self.cvd_lookback.min(n.saturating_sub(1));
            for i in lk..n {
                let now = cv[i];
                let then = cv[i - lk];
                if now.is_finite() && then.is_finite() {
                    if now > then { cvd_rising[i] = true; }
                    else if now < then { cvd_falling[i] = true; }
                }
            }
        }

        // Per-bar coloring on the imbalance histogram: green when bid-dominant,
        // red when ask-dominant, default cyan otherwise. Single pass; the
        // dominance booleans are reused below for the tag arrays so this is
        // free.
        let mut bar_colors: Vec<Option<String>> = Vec::with_capacity(n);
        for i in 0..n {
            if bid_dominant[i] { bar_colors.push(Some("#26A69A".to_string())); }
            else if ask_dominant[i] { bar_colors.push(Some("#EF5350".to_string())); }
            else { bar_colors.push(None); }
        }

        // Output: imbalance histogram + threshold hlines, all on [0, 1] scale.
        // Tags fire the dominance + CVD-direction signals downstream into the
        // analyzer's tag filter expression and per-tag stats.
        IndicatorOutput::new()
            .with_overlay(false)
            .plot_histogram_colored("Imbalance", imb_smooth, "#73DACA", bar_colors)
            .hline("Balanced", 0.5, "#63636e")
            .hline("Bid Threshold", upper, "#26A69A")
            .hline("Ask Threshold", lower, "#EF5350")
            .with_tag("bid_dominant", bid_dominant)
            .with_tag("ask_dominant", ask_dominant)
            .with_tag("cvd_rising", cvd_rising)
            .with_tag("cvd_falling", cvd_falling)
    }
}
volume_profile.rsrust285 lines

Tick-fidelity Volume Profile (POC, value area, HVN, LVN) with location tags (TBBO).

workspace/indicators/built-in/rust/volume_profile.rs

expand
//! qc-api: 1.0.8
// DISCLAIMER: This software is for educational and informational purposes only and does not constitute
// financial advice, investment advice, or trading advice. Past performance is not indicative of future
// results. Trading futures and other financial instruments involves substantial risk of loss. You are
// solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
// losses incurred. All rights reserved. (c) Quant Charts LLC

//! VP (Rust / TBBO).
//!
//! Required columns: bid, ask, bid_size, ask_size (TBBO native).
//! Data mode: TBBO (tick).
//!
//! Baseline tick-fidelity Volume Profile. Renders the profile as a right-side
//! horizontal histogram and emits six tags for membership-based trade
//! filtering and analysis.
//!
//! Tags (all per-tick bool):
//!   at_poc            - mid within proximity of POC
//!   inside_value_area - mid between VAL and VAH
//!   above_vah         - mid above VAH (price discovery up)
//!   below_val         - mid below VAL (price discovery down)
//!   near_hvn          - mid within proximity of any HVN level
//!   near_lvn          - mid within proximity of any LVN level
//!
//! Defaults are tuned for an MNQ-like session: session-anchored, every-second
//! refresh, 0.25-tick row size, 70% value area. Override via the parameters
//! below. For bid/ask imbalance and CVD-direction tags alongside these zones,
//! pair with `order_flow.rs`.

use qc_strategy_api::prelude::*;

#[indicator(
    name = "Volume Profile",
    description = "Tick-fidelity Volume Profile with POC / value area / HVN / LVN tags",
    overlay = true,
    data_mode = "tick",
    // VP zones survive bar aggregation - keep rendering when the chart is
    // switched to OHLC view so users can see multi-minute VP overlays.
    cross_view = true
)]
#[tag(name = "at_poc",            label = "At POC",           color = "#E0AF68", description = "Mid within proximity_ticks of POC")]
#[tag(name = "inside_value_area", label = "Inside Value Area",color = "#73DACA", description = "Mid between VAL and VAH")]
#[tag(name = "above_vah",         label = "Above VAH",        color = "#26A69A", description = "Mid above VAH (price discovery up)")]
#[tag(name = "below_val",         label = "Below VAL",        color = "#EF5350", description = "Mid below VAL (price discovery down)")]
#[tag(name = "near_hvn",          label = "Near HVN",         color = "#7AA2F7", description = "Mid within proximity of any HVN level")]
#[tag(name = "near_lvn",          label = "Near LVN",         color = "#F7768E", description = "Mid within proximity of any LVN level")]
#[derive(Default)]
pub struct Vp {
    #[param(default = "aligned_hour", label = "Anchor",
            options = ["session", "aligned_hour", "aligned_30m", "aligned_15m"],
            tooltip = "When a fresh VP starts. session = ET futures session boundary; \
                       aligned_* = wall-clock ET boundaries.")]
    pub anchor_kind: String,

    #[param(default = true, label = "Use Completed VP",
            tooltip = "When on, tags and rendered level lines reflect the LAST FULLY \
                       COMPLETED anchor period (e.g. the previous hour with aligned_hour) \
                       instead of the live in-progress profile. Strict anti-lookahead: \
                       no fires until the first period has archived (typically 1 hour \
                       into the session with aligned_hour).")]
    pub use_completed_vp: bool,

    #[param(default = 0.25, min = 0.01, max = 100.0, step = 0.01, label = "Row Size",
            tooltip = "Vertical bin size in price units. 0.25 = MNQ tick. \
                       Smaller = finer detail; larger = coarser zones.")]
    pub row_size: f64,

    #[param(default = 0.70, min = 0.10, max = 0.99, step = 0.01, label = "Value Area %",
            tooltip = "Cumulative share of total volume contained between VAL and VAH.")]
    pub value_area_pct: f64,

    #[param(default = 0.70, min = 0.0, max = 1.0, step = 0.01, label = "HVN Threshold",
            tooltip = "Min bin volume relative to POC (0-1) to count as a high-volume node.")]
    pub hvn_threshold: f64,

    #[param(default = 0.20, min = 0.0, max = 1.0, step = 0.01, label = "LVN Threshold",
            tooltip = "Max bin volume relative to POC (0-1) for a low-volume node candidate.")]
    pub lvn_threshold: f64,

    #[param(default = 2, min = 0, max = 100, label = "Proximity Ticks",
            tooltip = "Mid within this many `row_size` units of a level counts as 'at' / 'near'.")]
    pub proximity_ticks: usize,
}

impl Indicator for Vp {
    fn calculate(&self, data: &TickData, _prep: &DayPrep) -> IndicatorOutput {
        let n = data.len();
        if n == 0 {
            return IndicatorOutput::new().with_overlay(true);
        }

        let params = self.vp_params();
        let mut vp = params.build(data);

        let mut at_poc = vec![false; n];
        let mut inside_va = vec![false; n];
        let mut above_vah = vec![false; n];
        let mut below_val = vec![false; n];
        let mut near_hvn = vec![false; n];
        let mut near_lvn = vec![false; n];

        let prox = self.proximity_ticks as f64 * self.row_size;

        // Hot loop: O(1) amortized per tick. Snapshot reads are cheap because
        // VpParams::build sets up a lazy-recompute cadence under the hood.
        for i in 0..n {
            let mid = data.mid[i];
            let bs = if i < data.bid_size.len() { data.bid_size[i] } else { 0.0 };
            let as_ = if i < data.ask_size.len() { data.ask_size[i] } else { 0.0 };
            vp.on_tick(data.timestamp[i], mid, bs, as_);

            if !mid.is_finite() {
                continue;
            }

            // Snapshot source: live in-progress (default off) vs. last fully
            // archived (default on). Mirrors VpZoneRules::use_completed_vp in
            // trader.rs so an indicator paired with a strategy can show the
            // exact zones the strategy is deciding against. last_completed_*
            // takes &self; current() takes &mut self for lazy materialization;
            // NLL keeps either branch usable inside the if let.
            let snap_opt: Option<&VpSnapshot> = if self.use_completed_vp {
                vp.last_completed_snapshot()
            } else {
                vp.current()
            };
            if let Some(snap) = snap_opt {
                if (mid - snap.poc_price).abs() <= prox {
                    at_poc[i] = true;
                }
                if mid >= snap.val && mid <= snap.vah {
                    inside_va[i] = true;
                }
                if mid > snap.vah {
                    above_vah[i] = true;
                }
                if mid < snap.val {
                    below_val[i] = true;
                }
                for h in &snap.hvn {
                    if (mid - h.price).abs() <= prox {
                        near_hvn[i] = true;
                        break;
                    }
                }
                for l in &snap.lvn {
                    if (mid - l.price).abs() <= prox {
                        near_lvn[i] = true;
                        break;
                    }
                }
            }
        }
        // When use_completed_vp is on, never push the live in-progress period
        // into history: it would render a histogram for a profile the indicator
        // never actually used for tag decisions, and would shift the visual
        // "active VP" indicator a period ahead of where the user reads it.
        if !self.use_completed_vp {
            vp.finalize();
        }

        // Render: one right-of-range histogram per archived period plus the
        // live one. Default colors (Tokyo Night palette) keep the indicator
        // visually distinct from a strategy-owned VP on the same chart.
        const PRIMARY_BIN: &str = "#7AA2F7";
        const POC_COLOR: &str = "#E0AF68";
        const VA_COLOR: &str = "#73DACA";

        let history = vp.history();
        let last_data_ts = data.timestamp.last().copied();
        let mut vp_visuals: Vec<VpVisualSpec> = Vec::with_capacity(history.len());
        for (idx, snap) in history.iter().enumerate() {
            if snap.total_volume <= 0.0 {
                continue;
            }
            let end_ts = if idx + 1 < history.len() {
                history[idx + 1].anchor_ts
            } else {
                snap.last_ts
            };
            // Where the POC / VAH / VAL / HVN / LVN lines are drawn.
            // - use_completed_vp off: lines cover the period the histogram was
            //   built in, same span as the bars (default behaviour).
            // - use_completed_vp on: lines forward-project into the NEXT period,
            //   because that is when this completed VP is driving tag decisions.
            //   The user can read off the lines at any moment in hour N+1 and
            //   know exactly which zones the indicator is testing against.
            let (line_anchor, line_end) = if self.use_completed_vp {
                let next_end = if idx + 2 < history.len() {
                    history[idx + 2].anchor_ts
                } else {
                    last_data_ts.unwrap_or(end_ts).max(end_ts)
                };
                (Some(end_ts), Some(next_end))
            } else {
                (Some(snap.anchor_ts), Some(end_ts))
            };
            // Trim leading / trailing zero bins. snap.bins is sized for the
            // whole day's price range, so an aligned_hour period that only
            // touched a ~20-point band still serializes ~800 f64s of zero
            // padding at 0.25 row_size. With 20+ periods this is 100KB+ of
            // wasted IPC + an equivalent waste of renderer work. Trimming
            // shifts price_min forward by `first` * price_step; the renderer
            // already paints each bin at price_min + i * price_step so the
            // visual is identical.
            let bins_full = &snap.bins;
            let len = bins_full.len();
            let mut first = 0usize;
            while first < len && bins_full[first] == 0.0 {
                first += 1;
            }
            let mut last = len;
            while last > first && bins_full[last - 1] == 0.0 {
                last -= 1;
            }
            let trimmed_bins: Vec<f64> = bins_full[first..last].to_vec();
            let trimmed_price_min = snap.price_min + (first as f64) * snap.price_step;
            vp_visuals.push(VpVisualSpec {
                anchor_ts: snap.anchor_ts,
                end_ts,
                price_min: trimmed_price_min,
                price_step: snap.price_step,
                bins: trimmed_bins,
                poc_price: snap.poc_price,
                vah: snap.vah,
                val: snap.val,
                color: PRIMARY_BIN.to_string(),
                poc_color: POC_COLOR.to_string(),
                value_area_color: VA_COLOR.to_string(),
                opacity: 35,
                width_px: 80,
                align: HistogramAlign::RightOfRange,
                style: VpStyle::Bars,
                bin_colors: vec![],
                hvn_levels: snap.hvn.iter().map(|h| h.price).collect(),
                lvn_levels: snap.lvn.iter().map(|l| l.price).collect(),
                poc_trail: vec![],
                value_area_trail: vec![],
                level_lines_anchor_ts: line_anchor,
                level_lines_end_ts: line_end,
            });
        }

        let mut out = IndicatorOutput::new()
            .with_overlay(true)
            .with_tag("at_poc", at_poc)
            .with_tag("inside_value_area", inside_va)
            .with_tag("above_vah", above_vah)
            .with_tag("below_val", below_val)
            .with_tag("near_hvn", near_hvn)
            .with_tag("near_lvn", near_lvn);
        out.vp_visuals = vp_visuals;
        out
    }
}

impl Vp {
    fn vp_params(&self) -> VpParams {
        VpParams {
            anchor_kind: self.anchor_kind.clone(),
            period_ms: 0,
            period_ticks: 0,
            window_kind: "until_next_anchor".to_string(),
            window_ms: 0,
            window_ticks: 0,
            update_kind: "every_ms".to_string(),
            update_ms: 1000,
            update_n_ticks: 0,
            warmup_ms: 0,
            row_size: self.row_size,
            volume_source: "tick_count".to_string(),
            hvn_threshold: self.hvn_threshold,
            lvn_threshold: self.lvn_threshold,
            neighbourhood: 3,
            shelf_lo: 0.30,
            shelf_hi: 0.55,
            shelf_min_bins: 5,
            merge_within_bins: 2,
            value_area_pct: self.value_area_pct,
            track_zone_trail: false,
            max_history: None,
        }
    }
}

Built-in Strategies (Python)

ema_reversal.pypython73 lines

Always-in EMA reversal: the smallest target-position model demo with the size key.

workspace/strategies/built-in/python/ema_reversal.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""EMA Flip - always-in reversal on the EMA cross, with position sizing.

The smallest possible demo of the target-position model AND the `size` key.
Live-tradeable as shown.

THE MODEL (read this once and it explains every built-in):
Your booleans describe the position you WANT; the engine is a faithful
executor that holds one position at a time. Behaviour emerges from what you
emit, there are no execution "modes":

- flat, `entry_long` fires           -> open long
- long held, `entry_long` again      -> stay long (a level, no churn / re-entry)
- long held, `entry_short` fires     -> REVERSE: close long + open short, same bar
- long held, `exit_long` fires       -> close to flat
- `entry_long` AND `entry_short`     -> flat (contradictory, resolved to neutral)

This strategy emits ONLY the two entries (no exits), so every cross flips the
position: long above the cross, short below it, always in the market. The
close-then-open reversal pays the spread twice and is microstructure-accurate
(long closes at the bid, the new short opens at the bid).

POSITION SIZE (`size` key, replaces the old global Contracts knob):
Return `size` to set contracts per position. A scalar broadcasts to every bar.
It is read at the entry bar and held for that position's life (no mid-trade
rescale, no pyramiding). PnL, equity, and the per-trade fee all scale by it, so
a `size = 3` trade pays 3x the fee. Sweep `contracts` to study sizing.

To test the INVERSE strategy, swap `entry_long` and `entry_short` below.
"""

import quant_charts as qc


@qc.strategy("EMA Reversal", timeframe="1m")
class EmaFlip:
    # Tag metadata (label + color) for the analyzer's per-tag stats.
    tags = {
        "long_cross":  qc.Tag("Long opened on fast-over-slow cross",  "#26A69A"),
        "short_cross": qc.Tag("Short opened on fast-under-slow cross", "#EF5350"),
    }

    fast      = qc.input.int(9,  "Fast EMA", min=2, max=10000)
    slow      = qc.input.int(21, "Slow EMA", min=2, max=10000)
    contracts = qc.input.int(1,  "Contracts", min=1, max=100,
                             tooltip="Flat position size. Read at each entry; scales PnL, equity, and fees.")

    def calculate(self, df):
        fast = qc.ta.ema(df.close, self.fast)
        slow = qc.ta.ema(df.close, self.slow)

        cross_up = qc.cross_above(fast, slow)
        cross_dn = qc.cross_below(fast, slow)

        qc.plot(fast, "Fast EMA", color="#7AA2F7")
        qc.plot(slow, "Slow EMA", color="#E0AF68")

        # No exits: the opposite entry reverses the position. The two tag arrays
        # mark which cross opened each trade so the analyzer can split stats.
        return {
            "entry_long":  cross_up,
            "entry_short": cross_dn,
            "long_cross":  cross_up,
            "short_cross": cross_dn,
            "size": self.contracts,   # scalar broadcasts to every bar
        }
multi_setup.pypython113 lines

Two tagged entry setups (Donchian breakout + EMA-trend pullback) feeding one position, with block_entries RTH gating.

workspace/strategies/built-in/python/multi_setup.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""Multi Setup - two tagged entry setups in one strategy, gated by block_entries.

Two independent setups feed the SAME position. The engine holds one position at
a time, so whichever setup fires first opens the trade and the opposite-side
signal reverses it. Each setup carries its own tag, so the Analyzer's per-tag
panel breaks out breakout trades vs pullback trades separately and you can see
which edge actually pays.

- Breakout: close pushes through the prior N-bar Donchian high / low.
- Pullback: in an EMA-defined trend, price dips to and reclaims the fast EMA.

`block_entries` is the focus here. It is a per-bar bool that gates NEW entries
without touching a position you already hold: True = no new trade may open this
bar, an open trade keeps running and its SL/TP/reversal still fire. This strategy
uses it to avoid the opening-auction noise and to stop opening fresh risk into
the close, both expressed in code rather than a settings-panel "session hours"
knob.
"""

import numpy as np
import pandas as pd
import quant_charts as qc

# RTH bounds in minutes-from-midnight, Eastern (qc.hour/qc.minute are ET).
RTH_OPEN_MIN = 9 * 60 + 30   # 09:30
RTH_CLOSE_MIN = 16 * 60      # 16:00


@qc.strategy("Multi Setup", timeframe="5m")
class MultiSetup:
    # Per-setup tags. A tag attaches at the entry bar, so each array is True only
    # where its own setup triggers. The two are near-exclusive in practice.
    tags = {
        "breakout": qc.Tag("Opened on a Donchian break of the prior range", "#7AA2F7"),
        "pullback": qc.Tag("Opened on a trend pullback that reclaimed the fast EMA", "#E0AF68"),
    }

    lookback   = qc.input.int(20, "Breakout Lookback", min=2, max=10000,
                              tooltip="Donchian window. Close beyond the prior N-bar high/low breaks out.")
    trend_ema  = qc.input.int(50, "Trend EMA", min=2, max=10000,
                              tooltip="Defines the pullback's trend direction.")
    fast_ema   = qc.input.int(10, "Pullback EMA", min=2, max=10000,
                              tooltip="The level a pullback must reclaim to trigger.")
    open_skip  = qc.input.int(15, "Skip After Open (min)", min=0, max=390,
                              tooltip="Block new entries for this many minutes after 09:30 ET.")
    close_stop = qc.input.int(20, "Stop Before Close (min)", min=0, max=390,
                              tooltip="Block new entries this many minutes before 16:00 ET.")
    sl_atr     = qc.input.float(2.0, "SL x ATR", min=0.1, max=100.0, step=0.1)
    tp_atr     = qc.input.float(3.0, "TP x ATR", min=0.1, max=100.0, step=0.1)

    def prep(self, df):
        # Param-independent arrays, shared across every swept combo on this day.
        return {
            "close": np.asarray(df["close"], dtype=np.float64),
            "atr14": qc.ta.atr(df["high"], df["low"], df["close"], 14),
        }

    def calculate(self, df, prep):
        close = prep["close"]
        atr = prep["atr14"]

        # Breakout: prior-N-bar Donchian channel (shift(1) so the current bar is
        # not part of its own reference range, no look-ahead). NaN during warmup
        # makes the comparison False, which is the intended "no signal yet".
        c = pd.Series(close)
        hi = c.rolling(self.lookback).max().shift(1).to_numpy()
        lo = c.rolling(self.lookback).min().shift(1).to_numpy()
        breakout_long  = close > hi
        breakout_short = close < lo

        # Pullback: trade with the trend, enter when price reclaims the fast EMA.
        trend = qc.ta.ema(close, self.trend_ema)
        fast = qc.ta.ema(close, self.fast_ema)
        uptrend = qc.above(close, trend)
        downtrend = qc.below(close, trend)
        pullback_long  = uptrend & qc.cross_above(close, fast)
        pullback_short = downtrend & qc.cross_below(close, fast)

        qc.plot(trend, "Trend EMA", color="#565f89")
        qc.plot(fast, "Fast EMA", color="#7AA2F7")

        # Both setups feed one position.
        entry_long  = breakout_long | pullback_long
        entry_short = breakout_short | pullback_short

        # block_entries: only allow new trades inside the RTH window, minus the
        # opening drive and the run into the close. Held trades are unaffected.
        # qc.hour / qc.minute are ET minutes-from-midnight (same pattern as
        # overnight_swing.py).
        minute_of_day = qc.hour * 60 + qc.minute
        allowed = qc.between(minute_of_day, RTH_OPEN_MIN + self.open_skip, RTH_CLOSE_MIN - self.close_stop)
        block = ~np.asarray(allowed, dtype=bool)

        atr_safe = np.where(np.isfinite(atr), atr, np.nan)
        return {
            "entry_long":  entry_long,
            "entry_short": entry_short,
            "block_entries": block,
            "breakout": breakout_long | breakout_short,
            "pullback": pullback_long | pullback_short,
            "sl_long":  close - atr_safe * self.sl_atr,
            "tp_long":  close + atr_safe * self.tp_atr,
            "sl_short": close + atr_safe * self.sl_atr,
            "tp_short": close - atr_safe * self.tp_atr,
        }
overnight_swing.pypython89 lines

continuous=True overnight holds with warmup_days, RTH gating, and volatility-scaled size.

workspace/strategies/built-in/python/overnight_swing.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""Swing Continuous - an overnight trend hold with volatility-scaled sizing.

Showcases three things at once:

1. `continuous=True` (multiday). Positions CARRY across the calendar-day
   boundary instead of force-flatting at the session close. A long opened
   Tuesday afternoon is still on Wednesday morning. The engine only flattens at
   a contract rollover / large maintenance gap. (Drop `continuous` and the same
   code force-flats every day at EOD instead.)

2. Time-of-day gating in CODE, not a UI knob. Entries are gated to the RTH
   window with `qc.hour` / `qc.minute`. Outside RTH no NEW position opens, but
   an already-open position keeps running overnight (that is the point of
   continuous mode). This is how you express "only initiate during the day but
   hold the swing" without any session setting.

3. Per-bar dynamic `size`. Risk a roughly constant dollar amount per trade by
   sizing DOWN when volatility (ATR) is high and UP when it is low. `size` is
   read at the entry bar and held for the trade's life; changing it mid-trade
   does not rescale the open position.

The regime is a slow-EMA filter: long while price is above it, short below.
On a regime flip during RTH the opposite entry REVERSES the position (one
position at a time). Several tags split the per-tag stats so you can see which
regime and which session the edge came from.
"""

import numpy as np
import quant_charts as qc


@qc.strategy("Overnight Swing", timeframe="5m", continuous=True, warmup_days=1)
class SwingContinuous:
    tags = {
        "long_regime":   qc.Tag("Price above the slow EMA (long bias)",  "#26A69A"),
        "short_regime":  qc.Tag("Price below the slow EMA (short bias)", "#EF5350"),
        "morning":       qc.Tag("Entry in the 09:30-12:00 ET window",    "#7AA2F7"),
        "afternoon":     qc.Tag("Entry in the 12:00-16:00 ET window",    "#E0AF68"),
    }

    slow_ema      = qc.input.int(100, "Slow EMA (regime)", min=10, max=10000)
    atr_period    = qc.input.int(14,  "ATR period",        min=2,  max=500)
    risk_per_unit = qc.input.float(8.0, "Risk (xATR target)", min=0.5, max=100.0, step=0.5,
                                   tooltip="Target ATR-multiple of risk per trade. Size = round(this / ATR), clamped 1..max.")
    max_contracts = qc.input.int(5,   "Max contracts",     min=1,  max=100)

    def calculate(self, df):
        close = np.asarray(df["close"], dtype=np.float64)
        ema   = qc.ta.ema(close, self.slow_ema)
        atr   = qc.ta.atr(df["high"], df["low"], df["close"], self.atr_period)

        # RTH gate (ET minutes-from-midnight). 570 = 09:30, 960 = 16:00.
        minute_of_day = qc.hour * 60 + qc.minute
        rth     = qc.between(minute_of_day, 570, 960)
        morning = qc.between(minute_of_day, 570, 720)

        up   = close > ema
        down = close < ema

        # Initiate only during RTH; continuous mode carries the position overnight.
        entry_long  = up & rth
        entry_short = down & rth

        # Volatility-scaled size: ~constant risk per trade. NaN during ATR warmup
        # reads as 1; values are clamped to [1, max] and rounded to whole contracts.
        with np.errstate(divide="ignore", invalid="ignore"):
            raw = self.risk_per_unit / atr
        size = np.clip(np.nan_to_num(raw, nan=1.0, posinf=self.max_contracts), 1, self.max_contracts).round()

        qc.plot(ema, "Regime EMA", color="#E0AF68")

        return {
            "entry_long":   entry_long,
            "entry_short":  entry_short,
            "size":         size,
            # signal-quality tags (regime + session bucket of each entry bar)
            "long_regime":  entry_long,
            "short_regime": entry_short,
            "morning":      (entry_long | entry_short) & morning,
            "afternoon":    (entry_long | entry_short) & rth & ~morning,
        }
position_ladder.pypython78 lines

The position key: return the exact net contracts to hold each bar (FIFO scale-in / out).

workspace/strategies/built-in/python/position_ladder.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""Position Ladder - hold an EXACT net position that scales with trend strength.

The smallest demo of the `position` key. Instead of emitting entry/exit
booleans, you return the EXACT net contracts to hold each bar and the engine
makes it so. Direction, sizing, scaling in/out, and reversing all emerge from
one target array - there are no execution "modes".

THE MODEL (`position` key):
`position[i]` is the signed net you want held at bar i (+2 = long 2, -1 = short
1, 0 = flat). When present it is AUTHORITATIVE: the engine drives the held net
directly off it and IGNORES the entry/exit booleans, `size`, and the limit
columns. NaN = carry the previous target (so you only write the bars that
change). Each bar it diffs your target against what it currently holds:

- want a bigger same-side net  -> scale IN  (open a new FIFO lot at this bar)
- want a smaller same-side net -> scale OUT (close the OLDEST lots first; a
                                  partial close keeps each lot's own entry price,
                                  so per-trade MAE / MFE / SNR stay meaningful)
- want the opposite side       -> REVERSE (close the whole stack, then open the
                                  other way: two trades, spread crossed twice)
- want 0                       -> flat

`sl_long` / `tp_long` / `sl_short` / `tp_short` still apply as tick-accurate
brackets keyed by the net's sign (this demo uses none). Equity and drawdown
reflect SUMMED exposure, so a 3-lot position is 3 contracts of risk.

THE STRATEGY:
A pure target curve, fully vectorized (no loops, no booleans). Size INTO trend
strength: hold more contracts the further price has stretched above a trend EMA
(measured in ATR units), fewer as it reverts, flip short when price stretches
below, flat near the line. `np.round` makes the discrete ladder rungs and
`np.clip` caps exposure. Compare with EMA Flip (booleans + scalar `size`) to see
what `position` adds: continuous, mid-trade resizing the boolean path cannot do.
"""

import numpy as np
import quant_charts as qc


@qc.strategy("Position Ladder", timeframe="5m")
class PositionLadder:
    # Tag metadata (label + color) for the analyzer's per-tag stats.
    tags = {
        "long_rungs":  qc.Tag("Net long (sized into strength)", "#26A69A"),
        "short_rungs": qc.Tag("Net short (sized into weakness)", "#EF5350"),
    }

    trend_len = qc.input.int(50, "Trend EMA", min=2, max=10000)
    atr_len   = qc.input.int(14, "ATR period", min=2, max=10000)
    max_lots  = qc.input.int(3,  "Max contracts", min=1, max=100,
                             tooltip="Caps the net position. The ladder scales 0..max as price stretches from the trend EMA.")

    def calculate(self, df):
        trend = qc.ta.ema(df.close, self.trend_len)
        atr   = qc.ta.atr(df.high, df.low, df.close, self.atr_len)

        # How many ATRs price sits above (+) or below (-) the trend line. NaN
        # during the EMA / ATR warmup -> the engine carries the previous target
        # (flat) until they warm up.
        stretch = (df.close - trend) / atr
        # Discrete ladder: ~1 contract per ATR of stretch, capped at +-max_lots.
        position = np.clip(np.round(stretch), -self.max_lots, self.max_lots)

        qc.plot(trend, "Trend EMA", color="#7AA2F7")

        return {
            "position":    position,           # the exact net to hold each bar
            "long_rungs":  position > 0,        # tag: bars held net long
            "short_rungs": position < 0,        # tag: bars held net short
        }
trailing_stop.pypython88 lines

Chandelier trail via per-tick SL/TP arrays (emit_sltp=per_tick).

workspace/strategies/built-in/python/trailing_stop.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""Trail SL/TP - chandelier trail via per-tick SL/TP arrays.

Move SL/TP mid-trade by returning per-bar level arrays. The engine ratchets
them in the favorable direction only, so a trailing stop just works.

Behavior on a long trade:
- Entry bar:    SL = entry - K*ATR  (wide initial bracket)
- Trend up:     close rises -> close - K*ATR rises -> engine ratchets SL up
- Pullback:     new value lower -> engine keeps the prior high
- Reversal:     SL stays put until price retraces K*ATR from the peak
- Exits in priority: SL (trail), TP (target), opposite EMA cross (signal).
"""

import numpy as np
import quant_charts as qc


@qc.strategy("Trailing Stop", timeframe="1m", emit_sltp="per_tick")
class TrailSltp:
    # A trailing stop's edge depends heavily on volatility, so tag each entry by
    # its ATR regime. The analyzer's per-tag panel then shows whether the trail
    # captures more in volatile vs calm conditions.
    tags = {
        "high_vol": qc.Tag("Entered when ATR was above its 100-bar average (volatile)", "#F7768E"),
        "low_vol":  qc.Tag("Entered when ATR was at/below its 100-bar average (calm)",   "#73DACA"),
    }

    fast_ema = qc.input.int(12, "Fast EMA", min=2, max=10000,
                            tooltip="Fast leg of the EMA cross. Higher = fewer, slower trades.")
    slow_ema = qc.input.int(34, "Slow EMA", min=2, max=10000,
                            tooltip="Slow leg of the EMA cross.")
    sl_atr   = qc.input.float(2.5, "SL / Trail (xATR)", min=0.05, max=100.0, step=0.05,
                              tooltip="Initial bracket AND trailing give-back distance.")
    tp_atr   = qc.input.float(4.0, "TP (xATR)", min=0.05, max=100.0, step=0.05,
                              tooltip="Take-profit distance.")

    def prep(self, df):
        # Hoisted: param-independent ATR. Swept ema/sl/tp combos share this
        # array across every combination on the current day.
        return {
            "close": np.asarray(df["close"], dtype=np.float64),
            "atr14": qc.ta.atr(df["high"], df["low"], df["close"], 14),
        }

    def calculate(self, df, prep):
        close_arr = prep["close"]
        atr = prep["atr14"]

        fast = qc.ta.ema(close_arr, self.fast_ema)
        slow = qc.ta.ema(close_arr, self.slow_ema)

        long_cross  = qc.cross_above(fast, slow)
        short_cross = qc.cross_below(fast, slow)

        # Chandelier from current close, written every bar. Engine's per-tick
        # favorable ratchet handles "keep the best value seen". NaN during the
        # ATR warmup propagates and the engine reads NaN as "no change".
        atr_safe = np.where(np.isfinite(atr), atr, np.nan)
        sl_long  = close_arr - atr_safe * self.sl_atr
        sl_short = close_arr + atr_safe * self.sl_atr
        tp_long  = close_arr + atr_safe * self.tp_atr
        tp_short = close_arr - atr_safe * self.tp_atr

        qc.plot(fast, "Fast EMA", color="#7AA2F7")
        qc.plot(slow, "Slow EMA", color="#E0AF68")

        # Volatility regime at entry: ATR vs its own 100-bar SMA (causal, no
        # look-ahead). NaN during warmup falls into neither bucket.
        atr_base = qc.ta.sma(atr, 100)
        finite_vol = np.isfinite(atr) & np.isfinite(atr_base)
        high_vol = finite_vol & (atr > atr_base)
        low_vol  = finite_vol & (atr <= atr_base)

        return {
            "entry_long":  long_cross,  "exit_long":  short_cross,
            "entry_short": short_cross, "exit_short": long_cross,
            "sl_long":  sl_long,  "tp_long":  tp_long,
            "sl_short": sl_short, "tp_short": tp_short,
            "high_vol": high_vol, "low_vol": low_vol,
        }
value_area_limit.pypython82 lines

Resting limit entries at the prior session's value-area edges (warmup_days=1).

workspace/strategies/built-in/python/value_area_limit.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""VP Limit - rest limit orders at the prior session's value-area edges.

Showcases limit-order ENTRIES. Instead of a market fill on a touch (see
vp_touch.py), this rests a working limit at the level and fills AT that price
when the market reaches it, with no adverse slippage:

- `entry_limit_long`  rests a long limit at the prior VAL (buy the dip into value)
- `entry_limit_short` rests a short limit at the prior VAH (fade the push above value)

A non-NaN value = a working limit at that price. Repeat the price on consecutive
bars to keep it resting, change it to MOVE the order, set NaN to CANCEL. These are
PASSIVE orders that never cross the spread: the long fills AT VAL when the bid
trades down through it (a seller hits the level), and the short fills AT VAH when
the ask trades up through it. The dashed limit-order line on the chart shows each
order's life: placed -> (moved) -> filled / removed.

`warmup_days=1` makes the prior session available for the VP build.
"""

import numpy as np
import quant_charts as qc


@qc.strategy("Value Area Limit", timeframe="1m", warmup_days=1)
class VpLimit:
    # Per-setup tags: the two edges are distinct trades (buy value-low vs fade
    # value-high), so the analyzer's per-tag panel shows which edge actually pays.
    tags = {
        "val_long":  qc.Tag("Long limit filled at the prior session VAL (buy the dip into value)", "#73DACA"),
        "vah_short": qc.Tag("Short limit filled at the prior session VAH (fade the push above value)", "#F7768E"),
    }

    bins           = qc.input.int(48,  "VP Bins",       min=8,   max=256)
    value_area_pct = qc.input.float(0.7, "Value Area %", min=0.5, max=0.95, step=0.01)
    sl_atr         = qc.input.float(1.5, "SL x ATR",    min=0.1, step=0.1)

    def calculate(self, df):
        prev = qc.prev_session_volume_profile(df, bins=self.bins,
                                              value_area_pct=self.value_area_pct)

        # Chart visuals: the value-area edges we rest orders at, plus the POC
        # we target. prev.* are per-bar broadcast arrays.
        qc.plot(prev.poc, "Prev POC", color="#E0AF68")
        qc.plot(prev.vah, "Prev VAH", color="#F7768E")
        qc.plot(prev.val, "Prev VAL", color="#73DACA")

        val = np.asarray(prev.val, dtype=np.float64)
        vah = np.asarray(prev.vah, dtype=np.float64)
        poc = np.asarray(prev.poc, dtype=np.float64)
        atr = qc.ta.atr(df.high, df.low, df.close, 14)

        # Rest the limits at the value-area edges whenever a prior session
        # exists (finite levels). NaN bars carry no order. The engine fills AT
        # the level on contact; the market entry_long/short bools are not used.
        have = np.isfinite(val) & np.isfinite(vah)
        low  = np.asarray(df.low,  dtype=np.float64)
        high = np.asarray(df.high, dtype=np.float64)
        return {
            "entry_limit_long":  np.where(have, val, np.nan),
            "entry_limit_short": np.where(have, vah, np.nan),
            # Per-setup tags. A tag attaches at the FILL bar, so mark the bar
            # where price reached the resting level (low<=VAL fills the long,
            # high>=VAH fills the short). The two are exclusive at a fill.
            "val_long":  have & (low <= val),
            "vah_short": have & (high >= vah),
            # Mean-revert: exit at the POC, protective stop one ATR past the edge.
            "tp_long":  np.where(have, poc, np.nan),
            "sl_long":  np.where(have, val - self.sl_atr * atr, np.nan),
            "tp_short": np.where(have, poc, np.nan),
            "sl_short": np.where(have, vah + self.sl_atr * atr, np.nan),
            # Flat at the opposite edge as a backstop.
            "exit_long":  df.high >= vah,
            "exit_short": df.low <= val,
        }

Built-in Strategies (Rust)

ema_cross.rsrust131 lines

Bar-paced EMA cross with TR-based SL/TP. The canonical OhlcStrategy (OHLC).

workspace/strategies/built-in/rust/ema_cross.rs

expand
//! qc-api: 1.0.8
// DISCLAIMER: This software is for educational and informational purposes only and does not constitute
// financial advice, investment advice, or trading advice. Past performance is not indicative of future
// results. Trading futures and other financial instruments involves substantial risk of loss. You are
// solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
// losses incurred. All rights reserved. (c) Quant Charts LLC

//! EMA Cross (Rust / OHLC bars).
//!
//! Required columns: open, high, low, close.
//! Data mode: OHLC bars (data_mode = "ohlc"). Runs against either a native
//! OHLC parquet OR a TBBO parquet aggregated to the declared timeframe.
//! Default timeframe: 1m.
//!
//! Long when fast EMA crosses above slow EMA at bar close. Short on the
//! opposite cross. SL/TP are sized off a true-range volatility estimate
//! computed at the entry bar. Bar-paced execution: one decision per closed
//! bar, no tick stream involved.
//!
//! Target-position model: this emits only the two entry signals (no exits), so
//! a cross while in the opposite position REVERSES it (close + open, one
//! position at a time). To stand flat instead, emit `exit_long` / `exit_short`.
//!
//! Canonical `OhlcStrategy` template: copy this for any classic OHLC overlay
//! logic (regime filters, pivot detection, multi-timeframe confluence, etc.).

use qc_strategy_api::prelude::*;

#[strategy(
    name = "EMA Cross",
    description = "Fast/slow EMA crossover at bar close with TR-based SL/TP. Bar-paced.",
    data_mode = "ohlc",
    timeframe = "1m",
    emit_sltp = "entry_only"
)]
#[tag(name = "long_cross",  label = "Long Cross",  color = "#26A69A", description = "Fast EMA crossed above slow EMA")]
#[tag(name = "short_cross", label = "Short Cross", color = "#EF5350", description = "Fast EMA crossed below slow EMA")]
#[derive(Default)]
pub struct EmaCross {
    #[param(default = 9, min = 2, max = 200, label = "Fast EMA",
            tooltip = "Fast EMA period in bars at the strategy's declared timeframe.")]
    pub fast: usize,
    #[param(default = 21, min = 2, max = 500, label = "Slow EMA",
            tooltip = "Slow EMA period in bars at the strategy's declared timeframe. Must exceed fast.")]
    pub slow: usize,
    #[param(default = 14, min = 2, max = 200, label = "Vol Period (bars)",
            tooltip = "Lookback for the volatility estimate (true range SMA) used to size SL/TP.")]
    pub vol_period: usize,
    #[param(default = 1.5, min = 0.1, max = 10.0, step = 0.1, label = "SL Multiplier",
            tooltip = "Stop loss distance as a multiple of the volatility estimate.")]
    pub sl_mult: f64,
    #[param(default = 3.0, min = 0.1, max = 20.0, step = 0.1, label = "TP Multiplier",
            tooltip = "Take profit distance as a multiple of the volatility estimate.")]
    pub tp_mult: f64,
}

/// Bar-level true range: max(high-low, |high-prev_close|, |low-prev_close|).
fn true_range(high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
    let n = high.len();
    let mut out = vec![f64::NAN; n];
    if n == 0 { return out; }
    out[0] = high[0] - low[0];
    for i in 1..n {
        let pc = close[i - 1];
        let hl = high[i] - low[i];
        let hpc = (high[i] - pc).abs();
        let lpc = (low[i] - pc).abs();
        out[i] = hl.max(hpc).max(lpc);
    }
    out
}

impl OhlcStrategy for EmaCross {
    fn calculate(&self, data: &BarData, _prep: &DayPrep) -> BarSignalOutput {
        let n = data.len();
        let mut out = BarSignalOutput::for_bars(n).with_entry_only_sltp();
        if n < self.slow.max(self.vol_period) + 2 {
            return out;
        }

        let fast = ta::ema(&data.close, self.fast);
        let slow = ta::ema(&data.close, self.slow);
        let tr = true_range(&data.high, &data.low, &data.close);
        // SMA-of-TR via rolling_mean; close enough to Wilder's ATR for sizing.
        let vol = rolling_mean(&tr, self.vol_period);

        let mut long_tag = vec![false; n];
        let mut short_tag = vec![false; n];
        let mut sl_long = vec![f64::NAN; n];
        let mut tp_long = vec![f64::NAN; n];
        let mut sl_short = vec![f64::NAN; n];
        let mut tp_short = vec![f64::NAN; n];

        for i in 1..n {
            let f0 = fast[i];
            let f1 = fast[i - 1];
            let s0 = slow[i];
            let s1 = slow[i - 1];
            if !f0.is_finite() || !f1.is_finite() || !s0.is_finite() || !s1.is_finite() {
                continue;
            }
            let crossed_up   = f1 <= s1 && f0 > s0;
            let crossed_down = f1 >= s1 && f0 < s0;
            let entry_price = data.close[i];
            let v = vol[i];
            if !v.is_finite() || v <= 0.0 {
                continue;
            }

            if crossed_up {
                out.entry_long[i] = true;
                long_tag[i] = true;
                sl_long[i] = entry_price - self.sl_mult * v;
                tp_long[i] = entry_price + self.tp_mult * v;
            } else if crossed_down {
                out.entry_short[i] = true;
                short_tag[i] = true;
                // Short SL sits ABOVE entry, TP sits BELOW.
                sl_short[i] = entry_price + self.sl_mult * v;
                tp_short[i] = entry_price - self.tp_mult * v;
            }
        }

        out
            .with_sl_long(sl_long).with_tp_long(tp_long)
            .with_sl_short(sl_short).with_tp_short(tp_short)
            .with_tag("long_cross", long_tag)
            .with_tag("short_cross", short_tag)
    }
}
order_flow_signals.rsrust210 lines

Six order-flow setups into one position, each tagged, with conviction-scaled size (TBBO).

workspace/strategies/built-in/rust/order_flow_signals.rs

expand
//! qc-api: 1.0.8
// DISCLAIMER: This software is for educational and informational purposes only and does not constitute
// financial advice, investment advice, or trading advice. Past performance is not indicative of future
// results. Trading futures and other financial instruments involves substantial risk of loss. You are
// solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
// losses incurred. All rights reserved. (c) Quant Charts LLC

//! Tick Signal Lab (Rust / TBBO).
//!
//! Required columns: bid, ask, bid_size, ask_size (TBBO native).
//! Data mode: TBBO (tick). Default timeframe: per-tick.
//!
//! A SIGNAL-QUALITY showcase: three independent long setups and three short
//! setups fire into the same position, each carrying its own tag. The point is
//! the analyzer's per-tag panel: after a run you see win rate / PnL / expectancy
//! sliced by setup, so you can tell which microstructure edge actually pays and
//! prune the rest. This is how you research signal quality rather than just a
//! single equity curve.
//!
//! Setups (all read live order-flow, so this is microstructure-accurate and
//! tradeable as shown):
//!   - imbalance: smoothed bid/ask size imbalance past a threshold
//!   - cvd_thrust: cumulative volume delta slope past a threshold
//!   - sweep: a wide-spread, size-heavy print in the trade direction
//! `entry_long` fires when ANY long setup is active; the per-bar `size_long`
//! equals the NUMBER of long setups agreeing (1..3), so higher-conviction
//! entries trade more contracts. Size is read at the entry tick and held for
//! the trade's life (no scale-in). Exit is a fixed tick bracket via entry-only
//! SL/TP plus an opposite-imbalance flip.
//!
//! emit_sltp = "entry_only": SL/TP land on entry rows only, so the bundle is
//! O(entries) not O(ticks). Critical for analyzer-sweep RAM.

use qc_strategy_api::prelude::*;

#[strategy(
    name = "Order Flow Signals",
    description = "Three long + three short order-flow setups into one position, each tagged for per-setup signal-quality stats. Dynamic size by conviction.",
    emit_sltp = "entry_only",
    data_mode = "tick"
)]
#[tag(name = "imbalance_long",   label = "Imbalance Long",   color = "#26A69A", description = "Smoothed bid/ask imbalance bid-heavy")]
#[tag(name = "cvd_thrust_long",  label = "CVD Thrust Long",  color = "#73DACA", description = "CVD slope strongly positive")]
#[tag(name = "sweep_long",       label = "Sweep Long",       color = "#7AA2F7", description = "Wide-spread size-heavy ask sweep")]
#[tag(name = "imbalance_short",  label = "Imbalance Short",  color = "#EF5350", description = "Smoothed bid/ask imbalance ask-heavy")]
#[tag(name = "cvd_thrust_short", label = "CVD Thrust Short", color = "#F7768E", description = "CVD slope strongly negative")]
#[tag(name = "sweep_short",      label = "Sweep Short",      color = "#FF9E64", description = "Wide-spread size-heavy bid sweep")]
#[derive(Default)]
pub struct TickSignalLab {
    #[param(default = 200, min = 1, max = 200000, label = "Imbalance Smoothing (ticks)",
            tooltip = "Rolling-mean window for bid/ask imbalance.")]
    pub imbalance_smooth: usize,
    #[param(default = 0.12, min = 0.01, max = 0.49, step = 0.01, label = "Imbalance Threshold",
            tooltip = "Distance from 0.5 that counts as dominant pressure.")]
    pub imbalance_threshold: f64,
    #[param(default = 1000, min = 2, max = 1000000, label = "CVD Lookback (ticks)")]
    pub cvd_lookback: usize,
    #[param(default = 40.0, min = 0.0, max = 1000000.0, step = 1.0, label = "CVD Slope Threshold",
            tooltip = "Min |CVD change| over the lookback to fire the cvd_thrust setup.")]
    pub cvd_threshold: f64,
    #[param(default = 2.0, min = 1.0, max = 100.0, step = 0.5, label = "Sweep Spread (xMedian)",
            tooltip = "A sweep needs spread at least this multiple of the rolling median spread.")]
    pub sweep_spread_mult: f64,
    #[param(default = 8.0, min = 0.0, max = 1000000.0, step = 0.5, label = "SL (ticks)")]
    pub sl_ticks: f64,
    #[param(default = 16.0, min = 0.0, max = 1000000.0, step = 0.5, label = "TP (ticks)")]
    pub tp_ticks: f64,
    #[param(default = 0.25, min = 0.0001, max = 10000.0, step = 0.01, label = "Tick Size",
            tooltip = "Price increment used to convert SL/TP ticks to price distance.")]
    pub tick_size: f64,
}

impl Strategy for TickSignalLab {
    fn prepare(data: &TickData) -> DayPrep {
        // Param-independent series hoisted once per day.
        let mut prep = DayPrep::empty();
        prep.insert_f64("imb_raw", imbalance(&data.bid_size, &data.ask_size));
        let signed: Vec<f64> = data.bid_size.iter()
            .zip(data.ask_size.iter())
            .map(|(b, a)| b - a)
            .collect();
        prep.insert_f64("cvd", cvd(&signed));
        // Spread from bid/ask (canonical fields); median over a fixed window
        // gives the "normal" spread each sweep is measured against.
        let spread: Vec<f64> = data.ask.iter()
            .zip(data.bid.iter())
            .map(|(a, b)| a - b)
            .collect();
        prep.insert_f64("spread_med", rolling_mean(&spread, 500));
        prep
    }

    fn calculate(&self, data: &TickData, prep: &DayPrep) -> SignalOutput {
        let n = data.len();
        if n == 0 {
            return SignalOutput::new(vec![], vec![], vec![], vec![]);
        }

        let imb_raw = prep.f64("imb_raw").map(|s| s.to_vec())
            .unwrap_or_else(|| imbalance(&data.bid_size, &data.ask_size));
        let imb = if self.imbalance_smooth > 1 {
            rolling_mean(&imb_raw, self.imbalance_smooth)
        } else {
            imb_raw
        };
        let cvd_arr = prep.f64("cvd").map(|c| c.to_vec()).unwrap_or_else(|| {
            let signed: Vec<f64> = data.bid_size.iter().zip(data.ask_size.iter())
                .map(|(b, a)| b - a).collect();
            cvd(&signed)
        });
        let spread_med = prep.f64("spread_med").map(|s| s.to_vec()).unwrap_or_else(|| vec![f64::NAN; n]);

        let hi = 0.5 + self.imbalance_threshold;
        let lo = 0.5 - self.imbalance_threshold;
        let lk = self.cvd_lookback.min(n.saturating_sub(1));

        let mut entry_long = vec![false; n];
        let mut exit_long = vec![false; n];
        let mut entry_short = vec![false; n];
        let mut exit_short = vec![false; n];

        let mut t_imb_l = vec![false; n];
        let mut t_cvd_l = vec![false; n];
        let mut t_swp_l = vec![false; n];
        let mut t_imb_s = vec![false; n];
        let mut t_cvd_s = vec![false; n];
        let mut t_swp_s = vec![false; n];

        let mut size_long = vec![f64::NAN; n];
        let mut size_short = vec![f64::NAN; n];
        let mut sl_long = vec![f64::NAN; n];
        let mut tp_long = vec![f64::NAN; n];
        let mut sl_short = vec![f64::NAN; n];
        let mut tp_short = vec![f64::NAN; n];

        let sl_dist = self.sl_ticks * self.tick_size;
        let tp_dist = self.tp_ticks * self.tick_size;

        for i in 0..n {
            let v = imb[i];
            if !v.is_finite() {
                continue;
            }
            let mid = 0.5 * (data.bid[i] + data.ask[i]);
            let spread = data.ask[i] - data.bid[i];

            // CVD slope over the lookback.
            let (cvd_up, cvd_dn) = if i >= lk {
                let d = cvd_arr[i] - cvd_arr[i - lk];
                (d >= self.cvd_threshold, d <= -self.cvd_threshold)
            } else {
                (false, false)
            };

            // Sweep: spread blown out vs its rolling median and pressure aligned.
            let wide = spread_med[i].is_finite()
                && spread_med[i] > 0.0
                && spread >= self.sweep_spread_mult * spread_med[i];

            let imb_l = v > hi;
            let imb_s = v < lo;
            let swp_l = wide && v > 0.5;
            let swp_s = wide && v < 0.5;

            let long_conv = (imb_l as u8) + (cvd_up as u8) + (swp_l as u8);
            let short_conv = (imb_s as u8) + (cvd_dn as u8) + (swp_s as u8);

            if long_conv > 0 {
                entry_long[i] = true;
                t_imb_l[i] = imb_l;
                t_cvd_l[i] = cvd_up;
                t_swp_l[i] = swp_l;
                size_long[i] = long_conv as f64; // 1..3 contracts by conviction
                sl_long[i] = mid - sl_dist;
                tp_long[i] = mid + tp_dist;
            }
            if short_conv > 0 {
                entry_short[i] = true;
                t_imb_s[i] = imb_s;
                t_cvd_s[i] = cvd_dn;
                t_swp_s[i] = swp_s;
                size_short[i] = short_conv as f64;
                sl_short[i] = mid + sl_dist;
                tp_short[i] = mid - tp_dist;
            }

            // Signal exit: imbalance flips clearly against the held side.
            if v < lo {
                exit_long[i] = true;
            }
            if v > hi {
                exit_short[i] = true;
            }
        }

        SignalOutput::new(entry_long, exit_long, entry_short, exit_short)
            .with_entry_only_sltp()
            .with_size_long(size_long)
            .with_size_short(size_short)
            .with_sl_long(sl_long).with_tp_long(tp_long)
            .with_sl_short(sl_short).with_tp_short(tp_short)
            .with_tag("imbalance_long", t_imb_l)
            .with_tag("cvd_thrust_long", t_cvd_l)
            .with_tag("sweep_long", t_swp_l)
            .with_tag("imbalance_short", t_imb_s)
            .with_tag("cvd_thrust_short", t_cvd_s)
            .with_tag("sweep_short", t_swp_s)
    }
}
order_imbalance.rsrust185 lines

Bid/ask imbalance entries with synthetic CVD slope (TBBO).

workspace/strategies/built-in/rust/order_imbalance.rs

expand
//! qc-api: 1.0.8
// DISCLAIMER: This software is for educational and informational purposes only and does not constitute
// financial advice, investment advice, or trading advice. Past performance is not indicative of future
// results. Trading futures and other financial instruments involves substantial risk of loss. You are
// solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
// losses incurred. All rights reserved. (c) Quant Charts LLC

//! Imbalance (Rust / TBBO).
//!
//! Required columns: bid, ask, bid_size, ask_size (TBBO native, all 4 in the
//! canonical tick stream).
//! Data mode: TBBO (tick).
//! Default timeframe: per-tick.
//!
//! TBBO-only entry/exit driven by bid/ask size imbalance and CVD slope. No
//! EMAs, no bar-derived signals - this strategy only makes sense on tick
//! data because it reads bid_size/ask_size on every tick. The mid-price
//! never enters the entry logic.
//!
//! CVD is synthesised from per-tick (bid_size - ask_size) and accumulated
//! into a running sum. We deliberately do not reach for a `delta` column even
//! when the parquet has one: the Rust tick-strategy executor only projects
//! the canonical 7-field stream, so a dynamic-column lookup for delta would
//! always return None and break the strategy-column validator's pre-flight
//! check.
//!
//! Entry:
//!   long  - smoothed imbalance > 0.5 + threshold AND CVD rising
//!   short - smoothed imbalance < 0.5 - threshold AND CVD falling
//! Exit:
//!   long  - imbalance flips against (< 0.5 - exit_threshold)
//!   short - imbalance flips against (> 0.5 + exit_threshold)
//!
//! No SL/TP: SignalOutput without with_sl_*/with_tp_* skips the bracket bundle
//! entirely (saves RAM on large sweeps). Exit is purely signal-driven.

use qc_strategy_api::prelude::*;

#[strategy(
    name = "Order Imbalance",
    description = "Bid/ask imbalance + CVD-slope entry; imbalance-flip exit. TBBO-only.",
    data_mode = "tick"
)]
#[tag(name = "long_entry",          label = "Long Entry",            color = "#26A69A", description = "Smoothed imbalance bid-heavy and CVD rising")]
#[tag(name = "short_entry",         label = "Short Entry",           color = "#EF5350", description = "Smoothed imbalance ask-heavy and CVD falling")]
#[tag(name = "imbalance_exit_long", label = "Imbalance Long Exit",   color = "#F7768E", description = "Imbalance flipped ask-heavy while long")]
#[tag(name = "imbalance_exit_short",label = "Imbalance Short Exit",  color = "#73DACA", description = "Imbalance flipped bid-heavy while short")]
#[derive(Default)]
pub struct Imbalance {
    #[param(default = 200, min = 1, max = 200000, label = "Imbalance Smoothing (ticks)",
            tooltip = "Rolling-mean window applied to bid/ask imbalance. ~200 ticks ~ 6s on MNQ. \
                       Lower = more sensitive flip detection at the cost of more chop.")]
    pub imbalance_smooth: usize,

    #[param(default = 0.10, min = 0.01, max = 0.49, step = 0.01, label = "Entry Threshold",
            tooltip = "Distance from 0.5 that qualifies as 'dominant' pressure for entries. \
                       Higher = waits for more extreme imbalance, fewer entries.")]
    pub entry_threshold: f64,

    #[param(default = 0.05, min = 0.0, max = 0.49, step = 0.01, label = "Exit Threshold",
            tooltip = "Distance from 0.5 that triggers a directional exit. Should be < entry_threshold \
                       so positions exit before the next opposite entry would fire.")]
    pub exit_threshold: f64,

    #[param(default = 1000, min = 2, max = 1000000, label = "CVD Lookback (ticks)",
            tooltip = "Window for CVD-rising / CVD-falling slope confirmation. \
                       ~1000 ticks ~ 30s on MNQ.")]
    pub cvd_lookback: usize,
}

impl Strategy for Imbalance {
    fn prepare(data: &TickData) -> DayPrep {
        // Hoisted: raw imbalance + synthetic CVD are param-independent. Only
        // the smoothing window and lookback comparisons live in calculate().
        let mut prep = DayPrep::empty();
        prep.insert_f64("imb_raw", imbalance(&data.bid_size, &data.ask_size));
        // Per-tick signed-volume proxy: positive when bid pressure dominates,
        // negative when ask pressure dominates. Accumulated into a running
        // sum so the cvd_rising / cvd_falling slope gate works on TBBO data
        // (which has no native `delta` column).
        let synth: Vec<f64> = data.bid_size.iter()
            .zip(data.ask_size.iter())
            .map(|(b, a)| b - a)
            .collect();
        prep.insert_f64("cvd", cvd(&synth));
        prep
    }

    fn calculate(&self, data: &TickData, prep: &DayPrep) -> SignalOutput {
        let n = data.len();
        if n == 0 {
            return SignalOutput::new(vec![], vec![], vec![], vec![]);
        }

        // Smoothed imbalance: param-dependent, lives here.
        let imb_raw = prep
            .f64("imb_raw")
            .map(|s| s.to_vec())
            .unwrap_or_else(|| imbalance(&data.bid_size, &data.ask_size));
        let imb = if self.imbalance_smooth > 1 {
            rolling_mean(&imb_raw, self.imbalance_smooth)
        } else {
            imb_raw
        };

        // CVD slope: rising / falling vs N ticks ago. Owned vec so we can
        // index without borrow trouble. The prep entry is built once per day
        // in `prepare()` from synthetic per-tick (bid_size - ask_size); the
        // fallback re-synthesises in case prep was constructed elsewhere
        // without the entry. No dynamic-column delta lookup here - the
        // canonical Rust tick stream doesn't carry delta and the validator
        // would reject the strategy if we tried.
        let cvd_owned: Option<Vec<f64>> = prep.f64("cvd").map(|c| c.to_vec()).or_else(|| {
            let synth: Vec<f64> = data.bid_size.iter()
                .zip(data.ask_size.iter())
                .map(|(b, a)| b - a)
                .collect();
            Some(cvd(&synth))
        });

        let entry_upper = 0.5 + self.entry_threshold;
        let entry_lower = 0.5 - self.entry_threshold;
        let exit_upper = 0.5 + self.exit_threshold;
        let exit_lower = 0.5 - self.exit_threshold;
        let lk = self.cvd_lookback.min(n.saturating_sub(1));

        let mut entry_long = vec![false; n];
        let mut exit_long = vec![false; n];
        let mut entry_short = vec![false; n];
        let mut exit_short = vec![false; n];
        let mut long_tag = vec![false; n];
        let mut short_tag = vec![false; n];
        let mut imb_exit_long_tag = vec![false; n];
        let mut imb_exit_short_tag = vec![false; n];

        for i in 0..n {
            let v = imb[i];
            if !v.is_finite() {
                continue;
            }

            // Entries require imbalance threshold AND CVD slope confirmation.
            // Without CVD data, fall through with slope = 0 (entries still
            // possible but less filtered).
            let (cvd_rising, cvd_falling) = match (cvd_owned.as_ref(), i >= lk) {
                (Some(cv), true) => {
                    let now = cv[i];
                    let then = cv[i - lk];
                    if now.is_finite() && then.is_finite() {
                        (now > then, now < then)
                    } else {
                        (false, false)
                    }
                }
                _ => (true, true), // permissive when CVD unavailable
            };

            if v > entry_upper && cvd_rising {
                entry_long[i] = true;
                long_tag[i] = true;
            }
            if v < entry_lower && cvd_falling {
                entry_short[i] = true;
                short_tag[i] = true;
            }

            // Exits: imbalance flips against the position direction.
            if v < exit_lower {
                exit_long[i] = true;
                imb_exit_long_tag[i] = true;
            }
            if v > exit_upper {
                exit_short[i] = true;
                imb_exit_short_tag[i] = true;
            }
        }

        SignalOutput::new(entry_long, exit_long, entry_short, exit_short)
            .with_tag("long_entry", long_tag)
            .with_tag("short_entry", short_tag)
            .with_tag("imbalance_exit_long", imb_exit_long_tag)
            .with_tag("imbalance_exit_short", imb_exit_short_tag)
    }
}
prior_day_breakout.rsrust97 lines

Prior-session high/low breakout: the warmup_days=1 showcase (OHLC).

workspace/strategies/built-in/rust/prior_day_breakout.rs

expand
//! qc-api: 1.0.8
// DISCLAIMER: This software is for educational and informational purposes only and does not constitute
// financial advice, investment advice, or trading advice. Past performance is not indicative of future
// results. Trading futures and other financial instruments involves substantial risk of loss. You are
// solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
// losses incurred. All rights reserved. (c) Quant Charts LLC

//! Prior Day High/Low Breakout (Rust / OHLC bars) - showcase of `warmup_days`.
//!
//! A bar-paced strategy that needs the prior session's range to decide whether
//! today's bar is breaking out. Without `warmup_days = 1` the strategy would
//! see only today's bars and the first day in any run would simply skip; with
//! it the runner widens each day's `BarData` slice backward by one session of
//! read-only history so the strategy can compute the prior session's high/low
//! before the active day's first bar.
//!
//! The runner trims the prelude bars from the output before the engine sees
//! them, so trades stay 1:1 with the active day. `data.session` is a monotonic
//! 0-based session index (warmup bars carry the prior session's index); the
//! ACTIVE session is `data.session.last()`. `data.active_range()` returns
//! `(prelude_len, len)` so you can split the loop cleanly.
//!
//! Long entry: today's bar closes above prior-session high.
//! Short entry: today's bar closes below prior-session low.
//! SL / TP: prior-session midpoint and 1x range projection from entry.

use qc_strategy_api::prelude::*;

#[strategy(
    name = "Prior Day Breakout",
    description = "Break out of the prior session's high or low at bar close. Needs warmup_days = 1.",
    data_mode = "ohlc",
    timeframe = "5m",
    emit_sltp = "entry_only",
    warmup_days = 1
)]
#[tag(name = "pdh_break", label = "PDH Break", color = "#26A69A", description = "Closed above prior session high")]
#[tag(name = "pdl_break", label = "PDL Break", color = "#EF5350", description = "Closed below prior session low")]
#[derive(Default)]
pub struct PdhPdlBreakout {
    #[param(default = 1.0, min = 0.1, max = 5.0, step = 0.1, label = "TP : Range",
            tooltip = "Take profit distance as a multiple of the prior session's range.")]
    pub tp_mult: f64,
}

impl OhlcStrategy for PdhPdlBreakout {
    fn calculate(&self, data: &BarData, _prep: &DayPrep) -> BarSignalOutput {
        let n = data.len();
        let mut out = BarSignalOutput::for_bars(n).with_entry_only_sltp();
        let (active_start, _) = data.active_range();
        if active_start == 0 {
            // No prelude available (first day of the run) - nothing to break out of.
            return out;
        }

        // Prior session = every bar BEFORE active_start. Take its high/low/mid.
        let prior_high = data.high[..active_start].iter().copied().fold(f64::NEG_INFINITY, f64::max);
        let prior_low = data.low[..active_start].iter().copied().fold(f64::INFINITY, f64::min);
        if !prior_high.is_finite() || !prior_low.is_finite() || prior_high <= prior_low {
            return out;
        }
        let prior_range = prior_high - prior_low;
        let prior_mid = 0.5 * (prior_high + prior_low);

        let mut pdh_tag = vec![false; n];
        let mut pdl_tag = vec![false; n];
        let mut sl_long = vec![f64::NAN; n];
        let mut tp_long = vec![f64::NAN; n];
        let mut sl_short = vec![f64::NAN; n];
        let mut tp_short = vec![f64::NAN; n];

        for i in active_start..n {
            let c = data.close[i];
            if !c.is_finite() {
                continue;
            }
            if c > prior_high {
                out.entry_long[i] = true;
                pdh_tag[i] = true;
                sl_long[i] = prior_mid;
                tp_long[i] = c + self.tp_mult * prior_range;
            } else if c < prior_low {
                out.entry_short[i] = true;
                pdl_tag[i] = true;
                sl_short[i] = prior_mid;
                tp_short[i] = c - self.tp_mult * prior_range;
            }
        }

        out
            .with_sl_long(sl_long).with_tp_long(tp_long)
            .with_sl_short(sl_short).with_tp_short(tp_short)
            .with_tag("pdh_break", pdh_tag)
            .with_tag("pdl_break", pdl_tag)
    }
}
volume_profile_zones.rsrust353 lines

Volume-profile zone trading: HVN reject / LVN break / shelf break (TBBO).

workspace/strategies/built-in/rust/volume_profile_zones.rs

expand
//! qc-api: 1.0.8
// DISCLAIMER: This software is for educational and informational purposes only and does not constitute
// financial advice, investment advice, or trading advice. Past performance is not indicative of future
// results. Trading futures and other financial instruments involves substantial risk of loss. You are
// solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
// losses incurred. All rights reserved. (c) Quant Charts LLC

//! VP Zone Trade (Rust / TBBO).
//!
//! Required columns: bid, ask, bid_size, ask_size (TBBO native).
//! Optional columns: delta (used by lvn_break CVD-slope confirmation; falls back to ask-bid sign if absent).
//! Data mode: TBBO (tick).
//! Default timeframe: per-tick.
//!
//! Three Volume Profile zone-trading behaviours, picked per parameter
//! combination via `zone_behaviour`. Clone this file and specialize to build
//! production zone-trading strategies.
//!
//!   - hvn_reject:   mean-revert at a high-volume node. Approach from below
//!                   shorts the rejection (HVN as overhead resistance);
//!                   approach from above longs it (HVN as support).
//!   - lvn_break:    momentum entry on an LVN breakout with CVD-slope
//!                   confirmation in the breakout direction.
//!   - shelf_break:  trade the breakout from a flat-volume shelf range. SL
//!                   just inside the shelf, TP projected at 1.5x shelf width.
//!
//! VP construction params come from the shared `VpParams` surface (see
//! qc_strategy_api::vp::VpParams) so the companion indicator and this
//! strategy stay in lockstep automatically.
//!
//! The hot loop, cooldown enforcement, SL/TP geometry validation, shelf
//! membership tracking, and tag bookkeeping all live in the `VpZoneTrader`
//! driver (qc_strategy_api::vp::run_zone_trader). This file just declares
//! the parameter surface and the per-tick decision logic.
//!
//! emit_sltp = "entry_only" - SL/TP are set ONCE on entry rows; bundle size
//! is O(entries) not O(ticks), critical for analyzer sweep RAM.

use qc_strategy_api::prelude::*;

const TAGS: &[&'static str] = &[
    "entry_hvn_reject_long",
    "entry_hvn_reject_short",
    "entry_lvn_break_long",
    "entry_lvn_break_short",
    "entry_shelf_break_long",
    "entry_shelf_break_short",
];

#[strategy(
    name = "Volume Profile Zones",
    description = "HVN-reject / LVN-break / shelf-break trades on a configurable Volume Profile",
    emit_sltp = "entry_only",
    data_mode = "tick"
)]
#[tag(name = "entry_hvn_reject_long",  label = "HVN Reject Long",  color = "#26A69A", description = "Long: mid approached HVN from above (support) and reversed up")]
#[tag(name = "entry_hvn_reject_short", label = "HVN Reject Short", color = "#EF5350", description = "Short: mid approached HVN from below (resistance) and reversed down")]
#[tag(name = "entry_lvn_break_long",   label = "LVN Break Long",   color = "#73DACA", description = "Long: mid broke above LVN with positive CVD slope")]
#[tag(name = "entry_lvn_break_short",  label = "LVN Break Short",  color = "#F7768E", description = "Short: mid broke below LVN with negative CVD slope")]
#[tag(name = "entry_shelf_break_long", label = "Shelf Break Long", color = "#7AA2F7", description = "Long: mid exited shelf range to the upside")]
#[tag(name = "entry_shelf_break_short",label = "Shelf Break Short",color = "#FF9E64", description = "Short: mid exited shelf range to the downside")]
#[derive(Default)]
pub struct VpZone {
    // -- Trade behaviour selector --
    #[param(default = "hvn_reject", label = "Trade / Behaviour",
            options = ["hvn_reject", "lvn_break", "shelf_break"],
            tooltip = "Which zone-trading behaviour to apply this run.")]
    pub zone_behaviour: String,

    // -- VP construction (mirrors the indicator template's surface) --
    #[param(default = "session", label = "VP / Anchor Kind",
            options = ["session", "period_ms", "period_ticks", "aligned_hour", "aligned_30m", "aligned_15m", "aligned_5m", "aligned_minute"],
            tooltip = "When a fresh VP starts.")]
    pub anchor_kind: String,
    #[param(default = 3600000, min = 1, max = 86400000, label = "VP / Period (ms)",
            show_if = "anchor_kind == 'period_ms'")]
    pub period_ms: usize,
    #[param(default = 100000, min = 1, max = 100000000, label = "VP / Period (ticks)",
            show_if = "anchor_kind == 'period_ticks'")]
    pub period_ticks: usize,
    #[param(default = "until_next_anchor", label = "VP / Window Kind",
            options = ["until_next_anchor", "rolling_ms", "rolling_ticks", "capped_ms"],
            tooltip = "How the active VP retains ticks.")]
    pub window_kind: String,
    #[param(default = 1800000, min = 1, max = 86400000, label = "VP / Window (ms)",
            show_if = "window_kind in ['rolling_ms', 'capped_ms']")]
    pub window_ms: usize,
    #[param(default = 50000, min = 1, max = 100000000, label = "VP / Window (ticks)",
            show_if = "window_kind == 'rolling_ticks'")]
    pub window_ticks: usize,
    #[param(default = "every_ms", label = "VP / Update Kind",
            options = ["every_tick", "every_ms", "every_n_ticks"],
            tooltip = "How often zones / POC are recomputed.")]
    pub update_kind: String,
    #[param(default = 1000, min = 1, max = 86400000, label = "VP / Update (ms)",
            show_if = "update_kind == 'every_ms'")]
    pub update_ms: usize,
    #[param(default = 500, min = 1, max = 10000000, label = "VP / Update (ticks)",
            show_if = "update_kind == 'every_n_ticks'")]
    pub update_n_ticks: usize,
    #[param(default = 0, min = 0, max = 86400000, label = "VP / Warmup (ms)")]
    pub warmup_ms: usize,
    #[param(default = 0.25, min = 0.0001, max = 10000.0, step = 0.01, label = "VP / Row Size",
            tooltip = "Vertical bin size in price units. Each profile row covers this much price.")]
    pub row_size: f64,
    #[param(default = "tick_count", label = "VP / Volume Source",
            options = ["tick_count", "bid_plus_ask", "delta", "signed"],
            tooltip = "What each tick contributes to the bin.")]
    pub volume_source: String,

    // -- Zone criteria --
    #[param(default = 0.70, min = 0.0, max = 1.0, step = 0.01, label = "Zones / HVN Threshold")]
    pub hvn_threshold: f64,
    #[param(default = 0.20, min = 0.0, max = 1.0, step = 0.01, label = "Zones / LVN Threshold")]
    pub lvn_threshold: f64,
    #[param(default = 3, min = 1, max = 1000, label = "Zones / Neighbourhood")]
    pub neighbourhood: usize,
    #[param(default = 0.30, min = 0.0, max = 1.0, step = 0.01, label = "Zones / Shelf Lo")]
    pub shelf_lo: f64,
    #[param(default = 0.55, min = 0.0, max = 1.0, step = 0.01, label = "Zones / Shelf Hi")]
    pub shelf_hi: f64,
    #[param(default = 5, min = 1, max = 10000, label = "Zones / Shelf Min Bins")]
    pub shelf_min_bins: usize,
    #[param(default = 2, min = 0, max = 10000, label = "Zones / Merge Within Bins")]
    pub merge_within_bins: usize,
    #[param(default = 0.70, min = 0.0, max = 1.0, step = 0.01, label = "Zones / Value Area %")]
    pub value_area_pct: f64,

    // -- Trade rules --
    #[param(default = 2, min = 0, max = 100000, label = "Trade / Proximity Ticks",
            tooltip = "Touch distance for HVN reject, in ticks of row_size. \
                       0 = exact-touch only; high values = effectively any approach counts.")]
    pub proximity_ticks: usize,
    #[param(default = 4, min = 0, max = 100000, label = "Trade / Risk Ticks",
            tooltip = "SL distance fallback when no nearby HVN/LVN exists, in ticks of row_size.")]
    pub risk_ticks: usize,
    #[param(default = 8, min = 0, max = 1000000, label = "Trade / Min Target Ticks",
            tooltip = "Skip trade if next HVN is closer than this in trade direction.")]
    pub min_target_ticks: usize,
    #[param(default = 5000, min = 0, max = 86400000, label = "Trade / Cooldown (ms)",
            tooltip = "No re-entry within this many ms after the previous entry.")]
    pub cooldown_ms: usize,
    #[param(default = 50, min = 1, max = 1000000, label = "Trade / CVD Slope Window (ticks)",
            tooltip = "Lookback for the CVD slope used to confirm LVN break-outs.")]
    pub cvd_slope_window: usize,
    #[param(default = 25.0, min = 0.0, max = 1000000.0, step = 0.5, label = "Trade / CVD Slope Threshold",
            tooltip = "Min |cvd_slope| over the window to confirm an LVN break-out.")]
    pub cvd_slope_threshold: f64,

    #[param(default = true, label = "Render / Show VP",
            tooltip = "Render the strategy's Volume Profile + zones on the chart alongside its trades. \
                       On by default so a fresh attach shows the zones the strategy traded against \
                       without needing a separate companion indicator; flip off when launching \
                       parameter sweeps to keep them allocation-free.")]
    pub show_vp: bool,

    #[param(default = false, label = "Render / Show Zone Trail",
            tooltip = "When show_vp is on, also draw the rolling POC trail and value-area ribbon. \
                       Useful for spotting any historical zone shift (a zone trail extending right of \
                       'now' would indicate a kernel-level lookahead bug).")]
    pub show_zone_trail: bool,

    #[param(default = false, label = "Trade / Use Completed VP",
            tooltip = "When true, decisions are made against the LAST FULLY COMPLETED VP rather than the \
                       in-progress one. Strict anti-lookahead semantics: zones never drift while a trade is \
                       being decided. Trade-off: no trades fire until the first anchor period archives \
                       (a few minutes for aligned_5m, one hour for aligned_hour).")]
    pub use_completed_vp: bool,
}

impl VpStrategy for VpZone {
    /// Hoist per-day precompute. CVD is parameter-independent, so we pay the
    /// O(n) cumulative-sum once per day and read it as `ctx.prep.f64("cvd")`
    /// from the per-tick decision loop. The framework wires this into
    /// `Strategy::prepare` automatically via the blanket impl.
    fn prepare(data: &TickData) -> DayPrep {
        let mut prep = DayPrep::empty();
        let n = data.len();
        let mut delta = Vec::with_capacity(n);
        for i in 0..n {
            delta.push(data.ask_size[i] - data.bid_size[i]);
        }
        prep.insert_f64("cvd", cvd(&delta));
        prep
    }

    fn tags(&self) -> &'static [&'static str] {
        TAGS
    }

    /// Map the strategy's flat #[param] fields to the shared VpParams surface
    /// so the companion indicator can declare the same param names and stay
    /// in sync without extra code. The runner fingerprints this struct to
    /// share a VpDayContext across every combo whose VP construction params
    /// match - on a sweep that varies SL/TP/proximity the per-tick VP walk
    /// is paid once per (day, fingerprint) instead of once per combo.
    fn vp_params(&self) -> VpParams {
        VpParams {
            anchor_kind: self.anchor_kind.clone(),
            period_ms: self.period_ms as u64,
            period_ticks: self.period_ticks,
            window_kind: self.window_kind.clone(),
            window_ms: self.window_ms as u64,
            window_ticks: self.window_ticks,
            update_kind: self.update_kind.clone(),
            update_ms: self.update_ms as u64,
            update_n_ticks: self.update_n_ticks,
            warmup_ms: self.warmup_ms as u64,
            row_size: self.row_size,
            volume_source: self.volume_source.clone(),
            hvn_threshold: self.hvn_threshold,
            lvn_threshold: self.lvn_threshold,
            neighbourhood: self.neighbourhood,
            shelf_lo: self.shelf_lo,
            shelf_hi: self.shelf_hi,
            shelf_min_bins: self.shelf_min_bins,
            merge_within_bins: self.merge_within_bins,
            value_area_pct: self.value_area_pct,
            // Trail tracking is only useful when visuals are on; otherwise it
            // pays a small per-cadence write for nothing.
            track_zone_trail: self.show_vp && self.show_zone_trail,
            max_history: None,
        }
    }

    fn rules(&self) -> VpZoneRules {
        VpZoneRules {
            cooldown_ms: self.cooldown_ms as i64,
            risk_ticks: self.risk_ticks,
            min_target_ticks: self.min_target_ticks,
            proximity_ticks: self.proximity_ticks,
            emit_visuals: self.show_vp,
            use_completed_vp: self.use_completed_vp,
        }
    }

    fn on_zone_tick(&self, ctx: &VpZoneCtx) -> Option<ZoneEntry> {
        match self.zone_behaviour.as_str() {
            "lvn_break" => self.lvn_break(ctx),
            "shelf_break" => self.shelf_break(ctx),
            _ => self.hvn_reject(ctx),
        }
    }
}

impl VpZone {
    /// HVN rejection (mean revert). Approach from below = HVN is overhead
    /// resistance, take a SHORT. Approach from above = HVN is below as
    /// support, take a LONG.
    fn hvn_reject(&self, ctx: &VpZoneCtx) -> Option<ZoneEntry> {
        for h in &ctx.snap.hvn {
            let hvn_p = h.price;
            // Approach from below: SHORT.
            if ctx.prev_mid < hvn_p - ctx.prox
                && ctx.mid >= hvn_p - ctx.prox
                && ctx.mid <= hvn_p + ctx.prox
            {
                let tp = nearest_hvn_below(&ctx.snap.hvn, ctx.mid - ctx.min_target)
                    .map(|h| h.price)
                    .unwrap_or(ctx.mid - ctx.risk * 2.0);
                return Some(ZoneEntry::Short {
                    sl: hvn_p + ctx.risk,
                    tp,
                    tag: "entry_hvn_reject_short",
                });
            }
            // Approach from above: LONG.
            if ctx.prev_mid > hvn_p + ctx.prox
                && ctx.mid <= hvn_p + ctx.prox
                && ctx.mid >= hvn_p - ctx.prox
            {
                let tp = nearest_hvn_above(&ctx.snap.hvn, ctx.mid + ctx.min_target)
                    .map(|h| h.price)
                    .unwrap_or(ctx.mid + ctx.risk * 2.0);
                return Some(ZoneEntry::Long {
                    sl: hvn_p - ctx.risk,
                    tp,
                    tag: "entry_hvn_reject_long",
                });
            }
        }
        None
    }

    /// LVN breakout (momentum). Long when price breaks above an LVN with
    /// positive CVD slope; short when it breaks below with negative slope.
    fn lvn_break(&self, ctx: &VpZoneCtx) -> Option<ZoneEntry> {
        let cvd_arr = match ctx.prep.f64("cvd") {
            Some(v) => v,
            None => return None,
        };
        let slope_w = self.cvd_slope_window.max(1);
        let slope = if ctx.i >= slope_w {
            cvd_arr[ctx.i] - cvd_arr[ctx.i - slope_w]
        } else {
            return None; // not enough warmup for a valid slope
        };
        let threshold = self.cvd_slope_threshold;
        for l in &ctx.snap.lvn {
            let lvn_p = l.price;
            if ctx.prev_mid <= lvn_p && ctx.mid > lvn_p && slope >= threshold {
                let tp = nearest_hvn_above(&ctx.snap.hvn, ctx.mid + ctx.min_target)
                    .map(|h| h.price)
                    .unwrap_or(ctx.mid + ctx.risk * 2.0);
                return Some(ZoneEntry::Long {
                    sl: lvn_p - ctx.risk,
                    tp,
                    tag: "entry_lvn_break_long",
                });
            }
            if ctx.prev_mid >= lvn_p && ctx.mid < lvn_p && slope <= -threshold {
                let tp = nearest_hvn_below(&ctx.snap.hvn, ctx.mid - ctx.min_target)
                    .map(|h| h.price)
                    .unwrap_or(ctx.mid - ctx.risk * 2.0);
                return Some(ZoneEntry::Short {
                    sl: lvn_p + ctx.risk,
                    tp,
                    tag: "entry_lvn_break_short",
                });
            }
        }
        None
    }

    /// Shelf breakout. If we were inside a shelf one tick ago and just exited
    /// the upper or lower bound, take the breakout in that direction with TP
    /// projected at 1.5x shelf width and SL just inside the shelf.
    fn shelf_break(&self, ctx: &VpZoneCtx) -> Option<ZoneEntry> {
        let shelf = ctx.prev_shelf.as_ref()?;
        let was_inside = ctx.prev_mid >= shelf.price_lo && ctx.prev_mid <= shelf.price_hi;
        if !was_inside {
            return None;
        }
        let width = shelf.price_hi - shelf.price_lo;
        let inset = (width * 0.25).max(ctx.risk);
        if ctx.mid > shelf.price_hi && (1.5 * width) >= ctx.min_target {
            return Some(ZoneEntry::Long {
                sl: shelf.price_hi - inset,
                tp: ctx.mid + 1.5 * width,
                tag: "entry_shelf_break_long",
            });
        }
        if ctx.mid < shelf.price_lo && (1.5 * width) >= ctx.min_target {
            return Some(ZoneEntry::Short {
                sl: shelf.price_lo + inset,
                tp: ctx.mid - 1.5 * width,
                tag: "entry_shelf_break_short",
            });
        }
        None
    }
}

Web Examples (Indicators)

cross_day_levels.pypython62 lines

Prior-day high/low/open/close levels drawn across the session.

web-examples/indicators/cross_day_levels.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""Cross-Day Levels: prior-session close + 3-session VP POC (Python / OHLC).

Required columns: high, low, close, volume.
Data mode: OHLC.

Cross-day information needs NO Quant Charts API. Declare warmup so prior
sessions are prepended to `df`, then read them with plain pandas.

The frame is self-describing: every row carries a string `session` column
(the ET trading-day key) and the index is a tz-aware ET DatetimeIndex. So a
"previous session close" is just a slice, and a multi-session volume profile
is a numpy histogram. The leading warmup rows are trimmed from the returned
series automatically, so output stays 1:1 with the active day.

Known semantic: `session` is the ET CALENDAR date (matches the internal
trading-day split); a futures session is not shifted to its Sunday 6pm ET
start.
"""

import numpy as np
from quant_charts import indicator, plot


@indicator(
    name="Cross-Day Levels",
    description="Prior-session close + 3-session volume-profile POC, zero API",
    overlay=True,
    data_mode="ohlc",
    required_columns=["high", "low", "close", "volume"],
    warmup_days=3,
)
class CrossDayLevels:
    def calculate(self, df):
        sessions = list(df["session"].unique())
        n = len(df)

        prev_close = np.full(n, np.nan)
        if len(sessions) >= 2:
            prev = df[df["session"] == sessions[-2]]
            prev_close[:] = prev["close"].iloc[-1]

        poc = np.full(n, np.nan)
        recent = df[df["session"].isin(sessions[-3:])]
        if len(recent) > 1:
            price = recent["close"].to_numpy(dtype=np.float64)
            vol = recent["volume"].to_numpy(dtype=np.float64)
            edges = np.linspace(price.min(), price.max(), 51)
            idx = np.clip(np.digitize(price, edges) - 1, 0, 49)
            heaviest = int(np.argmax(np.bincount(idx, weights=vol, minlength=50)))
            poc[:] = (edges[heaviest] + edges[heaviest + 1]) / 2.0

        plot(prev_close, "Prev Session Close", color="#7AA2F7", linewidth=1)
        plot(poc, "3-Session POC", color="#E0AF68", linewidth=2)
        return {}
cvd.pypython56 lines

Standalone cumulative volume delta with rising/falling tags.

web-examples/indicators/cvd.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""CVD: standalone Cumulative Volume Delta (Python / OHLC).

Required columns: close.
Optional columns: open, volume, delta, bid_vol, ask_vol.
Data mode: OHLC. Built for TBBO-aggregated bars but degrades gracefully.

A focused cumulative-volume-delta indicator on its own pane. `delta_series`
resolves the per-bar signed volume via the fallback chain
delta -> (ask_vol - bid_vol) -> sign(close - open); `cvd` accumulates it.
Tags mark CVD trend so a strategy can filter on order-flow direction.
"""

import numpy as np
from quant_charts import (
    indicator, input, plot, hline, define_tag,
    cvd, delta_series, rising, falling, PlotType,
)


@indicator(
    name="CVD",
    description="Cumulative volume delta line with rising/falling trend tags",
    overlay=False,
    data_mode="ohlc",
    required_columns=["close"],
)
class Cvd:
    slope_lookback = input.int(5, "Slope Lookback", min=1, max=200,
                               tooltip="Bars used to classify CVD as rising or falling.")
    line_color = input.color("#7AA2F7", "CVD Color")

    def calculate(self, df):
        delta = delta_series(df)
        cvd_line = cvd(delta)

        plot(cvd_line, "CVD", color=self.line_color, linewidth=2, plot_type=PlotType.LINE)
        hline(0.0, "Zero", color="#63636e", linestyle="dashed")

        cvd_rising = rising(cvd_line, self.slope_lookback)
        cvd_falling = falling(cvd_line, self.slope_lookback)

        define_tag("cvd_rising", f"CVD rising over {self.slope_lookback} bars", color="#73DACA")
        define_tag("cvd_falling", f"CVD falling over {self.slope_lookback} bars", color="#F7768E")

        return {
            "cvd_rising": cvd_rising,
            "cvd_falling": cvd_falling,
        }
higher_timeframe_ema.pypython59 lines

A higher-timeframe EMA pinned to its compute timeframe.

web-examples/indicators/higher_timeframe_ema.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""Higher-timeframe EMA: compute on 5m bars, draw on 5m and finer charts.

Required columns: close.
Data mode: OHLC.

Two separate ideas:

  timeframe="5m"   the COMPUTE timeframe. The EMA is calculated on 5m bars no
                   matter what timeframe the chart is showing, so dropping to a
                   1m or tick chart still draws the SAME 5m line (it does not
                   recompute on the finer bars).

  visible_on       which chart timeframes the line DRAWS on. The default "auto"
                   is directional: visible on the compute timeframe and every
                   FINER chart timeframe, hidden on coarser ones. So this 5m EMA
                   shows on 5m / 1m / 30s / tick, and is hidden on 15m / 1h
                   (where a single 5m point per several bars would be sparse and
                   misleading). To override:
                     visible_on="all"            draw on every chart timeframe
                     visible_on=["5m", "15m"]    draw only on those timeframes

A moving average is a continuous curve, so plot it as a LINE. For a value that
holds flat across its bar (a level: VWAP-as-level, prior-day high, POC), prefer
plot_type=PlotType.STEPLINE so a coarse series on a finer chart reads as held
steps rather than interpolated ramps.
"""

import numpy as np
from quant_charts import indicator, input, plot, ta


@indicator(
    name="Higher Timeframe EMA",
    description="A 5m EMA that draws on the 5m chart and every finer timeframe",
    overlay=True,
    data_mode="ohlc",
    timeframe="5m",      # compute on 5m bars, independent of the chart timeframe
    visible_on="auto",   # directional default: 5m and finer; try "all" or ["5m","15m"]
    continuous=True,     # one smooth EMA across the whole window; do NOT reset per day
    required_columns=["close"],
)
class HigherTimeframeEma:
    period = input.int(20, "EMA Period", min=2, max=1000,
                       tooltip="EMA lookback in 5m bars.")
    color = input.color("#7AA2F7", "Line Color")

    def calculate(self, df):
        close = np.asarray(df["close"], dtype=np.float64)
        ema = ta.ema(close, self.period)
        plot(ema, f"EMA({self.period}) 5m", color=self.color, linewidth=2)
        return {}
imbalance.pypython67 lines

Standalone bid/ask volume imbalance ratio with pressure tags.

web-examples/indicators/imbalance.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""Imbalance: standalone bid/ask volume imbalance (Python / OHLC).

Required columns: close.
Optional columns: bid_vol, ask_vol.
Data mode: OHLC. Needs TBBO-aggregated bars carrying bid_vol / ask_vol; on
plain OHLC the ratio is flat at 0.5 and the tags never fire.

Plots the imbalance ratio in [0, 1] on its own pane. `imbalance(bid, ask)`
returns bid / (bid + ask): values above 0.5 mean bid-side (buy) pressure,
below 0.5 mean ask-side (sell) pressure. Threshold inputs drive heavy_buy /
heavy_sell tags for use as strategy filters.
"""

import numpy as np
from quant_charts import (
    indicator, input, plot, hline, define_tag,
    imbalance, df_col_or, PlotType,
)


@indicator(
    name="Imbalance",
    description="Bid/ask volume imbalance ratio with heavy buy/sell tags",
    overlay=False,
    data_mode="ohlc",
    required_columns=["close"],
)
class Imbalance:
    threshold = input.float(0.65, "Imbalance Threshold", min=0.5, max=1.0, step=0.01,
                            tooltip="Ratio above this = heavy buy; below (1 - this) = heavy sell.")
    line_color = input.color("#E0AF68", "Imbalance Color")

    def calculate(self, df):
        close = np.asarray(df["close"], dtype=np.float64)
        n = len(close)

        bid_vol = df_col_or(df, "bid_vol")
        ask_vol = df_col_or(df, "ask_vol")

        if bid_vol is not None and ask_vol is not None:
            imb = imbalance(bid_vol, ask_vol)
        else:
            imb = np.full(n, 0.5, dtype=np.float64)

        plot(imb, "Imbalance", color=self.line_color, linewidth=2, plot_type=PlotType.LINE)
        hline(0.5, "Neutral", color="#63636e", linestyle="dashed")
        hline(self.threshold, "Buy Threshold", color="#73DACA", linestyle="dotted")
        hline(1.0 - self.threshold, "Sell Threshold", color="#F7768E", linestyle="dotted")

        heavy_buy = imb > self.threshold
        heavy_sell = imb < (1.0 - self.threshold)

        define_tag("heavy_buy_pressure", f"Bid imbalance > {self.threshold:.2f}", color="#73DACA")
        define_tag("heavy_sell_pressure", f"Ask imbalance > {self.threshold:.2f}", color="#F7768E")

        return {
            "heavy_buy_pressure": heavy_buy,
            "heavy_sell_pressure": heavy_sell,
        }
moving_averages.pypython67 lines

SMA + EMA + WMA overlay with bull/bear stack-order tags.

web-examples/indicators/moving_averages.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""Moving Averages: SMA, EMA, and WMA on one overlay (Python / OHLC).

Required columns: close.
Data mode: OHLC.

The canonical moving-average example. Plots three moving averages of close on
the price pane and emits trend tags from their stack order:

  ma_bull : close > EMA > SMA   (fast above slow, price leading)
  ma_bear : close < EMA < SMA

Use `ta.sma`, `ta.ema`, `ta.wma` rather than a hand-rolled rolling mean: they
are vectorized and JIT-compiled for large series.
"""

import numpy as np
from quant_charts import indicator, input, plot, define_tag, ta


@indicator(
    name="Moving Averages",
    description="SMA + EMA + WMA overlay with bull/bear stack-order tags",
    overlay=True,
    data_mode="ohlc",
    required_columns=["close"],
)
class MovingAverages:
    sma_period = input.int(50, "SMA Period", min=2, max=1000,
                           tooltip="Simple moving average lookback in bars.")
    ema_period = input.int(20, "EMA Period", min=2, max=1000,
                           tooltip="Exponential moving average lookback in bars.")
    wma_period = input.int(20, "WMA Period", min=2, max=1000,
                           tooltip="Weighted moving average lookback in bars.")
    sma_color = input.color("#A1A1AA", "SMA Color")
    ema_color = input.color("#7AA2F7", "EMA Color")
    wma_color = input.color("#73DACA", "WMA Color")

    def calculate(self, df):
        close = np.asarray(df["close"], dtype=np.float64)

        sma = ta.sma(close, self.sma_period)
        ema = ta.ema(close, self.ema_period)
        wma = ta.wma(close, self.wma_period)

        plot(sma, f"SMA({self.sma_period})", color=self.sma_color, linewidth=2)
        plot(ema, f"EMA({self.ema_period})", color=self.ema_color, linewidth=2)
        plot(wma, f"WMA({self.wma_period})", color=self.wma_color, linewidth=2)

        finite = np.isfinite(sma) & np.isfinite(ema)
        ma_bull = finite & (close > ema) & (ema > sma)
        ma_bear = finite & (close < ema) & (ema < sma)

        define_tag("ma_bull", "close > EMA > SMA", color="#73DACA")
        define_tag("ma_bear", "close < EMA < SMA", color="#F7768E")

        return {
            "ma_bull": ma_bull,
            "ma_bear": ma_bear,
        }

Web Examples (Strategies)

pdh_pdl_breakout.rsrust97 lines

Prior-session high/low breakout in Rust; the warmup_days=1 showcase (also a built-in).

web-examples/strategies/pdh_pdl_breakout.rs

expand
//! qc-api: 1.0.8
// DISCLAIMER: This software is for educational and informational purposes only and does not constitute
// financial advice, investment advice, or trading advice. Past performance is not indicative of future
// results. Trading futures and other financial instruments involves substantial risk of loss. You are
// solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
// losses incurred. All rights reserved. (c) Quant Charts LLC

//! Prior Day High/Low Breakout (Rust / OHLC bars) - showcase of `warmup_days`.
//!
//! A bar-paced strategy that needs the prior session's range to decide whether
//! today's bar is breaking out. Without `warmup_days = 1` the strategy would
//! see only today's bars and the first day in any run would simply skip; with
//! it the runner widens each day's `BarData` slice backward by one session of
//! read-only history so the strategy can compute the prior session's high/low
//! before the active day's first bar.
//!
//! The runner trims the prelude bars from the output before the engine sees
//! them, so trades stay 1:1 with the active day. `data.session` is a monotonic
//! 0-based session index (warmup bars carry the prior session's index); the
//! ACTIVE session is `data.session.last()`. `data.active_range()` returns
//! `(prelude_len, len)` so you can split the loop cleanly.
//!
//! Long entry: today's bar closes above prior-session high.
//! Short entry: today's bar closes below prior-session low.
//! SL / TP: prior-session midpoint and 1x range projection from entry.

use qc_strategy_api::prelude::*;

#[strategy(
    name = "PDH/PDL Breakout",
    description = "Break out of the prior session's high or low at bar close. Needs warmup_days = 1.",
    data_mode = "ohlc",
    timeframe = "5m",
    emit_sltp = "entry_only",
    warmup_days = 1
)]
#[tag(name = "pdh_break", label = "PDH Break", color = "#26A69A", description = "Closed above prior session high")]
#[tag(name = "pdl_break", label = "PDL Break", color = "#EF5350", description = "Closed below prior session low")]
#[derive(Default)]
pub struct PdhPdlBreakout {
    #[param(default = 1.0, min = 0.1, max = 5.0, step = 0.1, label = "TP : Range",
            tooltip = "Take profit distance as a multiple of the prior session's range.")]
    pub tp_mult: f64,
}

impl OhlcStrategy for PdhPdlBreakout {
    fn calculate(&self, data: &BarData, _prep: &DayPrep) -> BarSignalOutput {
        let n = data.len();
        let mut out = BarSignalOutput::for_bars(n).with_entry_only_sltp();
        let (active_start, _) = data.active_range();
        if active_start == 0 {
            // No prelude available (first day of the run) - nothing to break out of.
            return out;
        }

        // Prior session = every bar BEFORE active_start. Take its high/low/mid.
        let prior_high = data.high[..active_start].iter().copied().fold(f64::NEG_INFINITY, f64::max);
        let prior_low = data.low[..active_start].iter().copied().fold(f64::INFINITY, f64::min);
        if !prior_high.is_finite() || !prior_low.is_finite() || prior_high <= prior_low {
            return out;
        }
        let prior_range = prior_high - prior_low;
        let prior_mid = 0.5 * (prior_high + prior_low);

        let mut pdh_tag = vec![false; n];
        let mut pdl_tag = vec![false; n];
        let mut sl_long = vec![f64::NAN; n];
        let mut tp_long = vec![f64::NAN; n];
        let mut sl_short = vec![f64::NAN; n];
        let mut tp_short = vec![f64::NAN; n];

        for i in active_start..n {
            let c = data.close[i];
            if !c.is_finite() {
                continue;
            }
            if c > prior_high {
                out.entry_long[i] = true;
                pdh_tag[i] = true;
                sl_long[i] = prior_mid;
                tp_long[i] = c + self.tp_mult * prior_range;
            } else if c < prior_low {
                out.entry_short[i] = true;
                pdl_tag[i] = true;
                sl_short[i] = prior_mid;
                tp_short[i] = c - self.tp_mult * prior_range;
            }
        }

        out
            .with_sl_long(sl_long).with_tp_long(tp_long)
            .with_sl_short(sl_short).with_tp_short(tp_short)
            .with_tag("pdh_break", pdh_tag)
            .with_tag("pdl_break", pdl_tag)
    }
}
pyramid_position.pypython68 lines

Scale into a trend with the position key, adding contracts as it extends.

web-examples/strategies/pyramid_position.py

expand
# qc-api: 1.0.8
# DISCLAIMER: This software is for educational and informational purposes only and does not constitute
# financial advice, investment advice, or trading advice. Past performance is not indicative of future
# results. Trading futures and other financial instruments involves substantial risk of loss. You are
# solely responsible for your own trading decisions. Quant Charts LLC assumes no liability for any
# losses incurred. All rights reserved. (c) Quant Charts LLC

"""Pyramiding Trend - own position management in code with the `position` key.

Most strategies emit entry/exit booleans (plus an optional `size`) and let the
engine hold one position at a time. This one instead returns `position`: the
EXACT net signed quantity to hold each bar. When `position` is present it is
authoritative. The engine drives the held net directly off it and the booleans,
`size`, and limit columns are ignored. Direction, sizing, scale-in/out, and
reverse are all just what the array says.

The rule here is a slow-EMA regime filter that pyramids INTO a trend:

  - flat when there is no trend,
  - scale 1 -> 2 -> 3 contracts as an uptrend persists (each step adds a FIFO
    lot at that bar's price; a later trim closes the oldest lots first and keeps
    each lot's own entry, so per-trade stats stay meaningful),
  - flip to short 1 when the regime turns down (a sign flip closes the whole
    long stack, then opens the short - a reverse is two+ trades, spread crossed
    twice),
  - back to flat (0) when the down-trend ends.

`position` semantics: NaN = carry the previous target (emit only changes), 0 =
flat, sign = side, |value| = contracts. SL/TP arrays still apply as brackets on
the net position keyed by its sign (this example uses none). Equity and drawdown
reflect summed concurrent exposure, so a 3-lot position is 3 contracts of risk.
"""

import numpy as np
import quant_charts as qc


@qc.strategy("Pyramiding Trend", timeframe="5m")
class PyramidingTrend:
    fast = qc.input.int(20, "Fast EMA", min=2, max=100)
    slow = qc.input.int(50, "Slow EMA", min=5, max=300)
    max_contracts = qc.input.int(3, "Max contracts", min=1, max=10)

    def calculate(self, df):
        fast = qc.ta.ema(df.close, self.fast)
        slow = qc.ta.ema(df.close, self.slow)
        up = np.asarray(fast > slow)
        down = np.asarray(fast < slow)

        # Build the net position bar by bar: pyramid up in an uptrend, flip short
        # on a down regime, flat otherwise. Pure numpy/loop authoring - the engine
        # turns these targets into scale-ins, trims, and reverses.
        n = len(df)
        held = np.zeros(n, dtype=np.float64)
        run = 0
        for i in range(n):
            if up[i]:
                run += 1
                held[i] = float(min(run, self.max_contracts))
            elif down[i]:
                run = 0
                held[i] = -1.0
            else:
                run = 0
                held[i] = 0.0

        return {"position": held}
Raw template sources are also mirrored to /templates/{path} for AI agents to fetch directly. See /llms-symbols.txt for the symbol index.