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.

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

Scaffolds

indicator_template.pypython249 lines

Python indicator skeleton with QC disclaimer header.

indicator_template.py

expand
# 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. rolling-window on
`close` with a literal period, heavy mask building), the same `_DAY_CACHE[id(df)]`
hoisting pattern from `templates/strategy_template.py` applies here too. Lines
that reference `self.*` belong in `calculate()`; lines that don't belong in
`_prepare_day(df)` at module level.
"""

# =============================================================================
# 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, data_mode="both")  # data_mode: "tick", "ohlc", or "both"
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.pypython59 lines

Minimal Python script (Jupyter-runnable) skeleton.

script_template.py

expand
"""
{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.pypython87 lines

Python OHLC strategy skeleton with entry/exit signals.

strategy_template.py

expand
# 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
from quant_charts import strategy, day_start, input, ta, cross_above, cross_below, Timeframe


# When calculate() has param-independent work (raw_ticks filtering, argsort,
# reduceat, literal-period ta.* calls), use @day_start instead of writing your
# own module-level _DAY_CACHE. The framework calls the @day_start method ONCE
# per day in the main process, packs the returned dict into shared memory, and
# ships it to every worker — so a 6-worker × 50-day sweep runs the prep 50
# times instead of 300 times. The return value is exposed as `self._day` inside
# calculate(). See the commented example inside the class below.
#
# Contract for @day_start return value:
#   - Must be a dict.
#   - Values must be np.ndarray, int, float, bool, or str.
#   - pd.Series, lists, tuples, etc. are NOT shipped through SHM. Convert to
#     ndarray here and rebuild any Series locally inside calculate() if needed.


# SL/TP emit contract (optional, defaults to 'per_tick'):
#   emit_sltp='per_tick'   - sl/tp arrays carry a value on every row. Engine
#                            ratchets the active stop on each non-NaN sample.
#                            Use this for trailing or bar-by-bar adjusted stops.
#   emit_sltp='entry_only' - sl/tp arrays must be NaN except on entry rows
#                            (and on modify rows for opt-in trail strategies).
#                            Engine reads the value once at entry and holds it
#                            until exit. Sweep bundles drop from O(n) to O(entries)
#                            per combo; required to scale to 50d x 700p TBBO sweeps.
@strategy(
    name="{NAME}",
    description="Enter description here",
    overlay=True,
    timeframe=Timeframe.M1,
    data_mode="ohlc",
    # emit_sltp='entry_only',  # opt in when sl/tp values do not change after entry
)
class {CLASS_NAME}:
    """
    Before reading anything beyond open/high/low/close/volume, consult the
    Column Availability Matrix in the Claude system prompt. A missing column
    under your (data_mode, timeframe, source_file_type) silently resolves to an
    empty Series and your signal dict will crash at DataFrame construction.
    """

    fast_period = input.int(10, "Fast Period", min=2, max=100)
    slow_period = input.int(20, "Slow Period", min=2, max=200)

    # Uncomment when calculate() repeats the same param-independent work for
    # every parameter set on the same day:
    #
    # @day_start
    # def prep(self, df):
    #     return {
    #         "close_arr": np.array(df["close"]).astype(np.float64),
    #         "atr14": ta.atr(np.array(df["high"]), np.array(df["low"]), np.array(df["close"]), 14).astype(np.float64),
    #     }
    #
    # def calculate(self, df):
    #     close_arr = self._day["close_arr"]
    #     atr14 = self._day["atr14"]
    #     ...

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

        return {
            "entry_long": cross_above(fast, slow),
            "exit_long": cross_below(fast, slow),
            "entry_short": cross_below(fast, slow),
            "exit_short": cross_above(fast, slow),
        }
strategy_template.rsrust109 lines

Rust TBBO strategy skeleton with #[strategy] macro.

strategy_template.rs

expand
// 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)

atr.pypython71 lines

Average True Range indicator on OHLC bars.

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

expand
# qc-api: 1.0.7
# 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: Average True Range with regime tags (Python / OHLC).

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

Baseline indicator. Plots ATR as a line and emits three regime tags
(low_atr / medium_atr / high_atr) defined relative to the day's median ATR.
Use these tags as filter / record sources on any strategy.

Default thresholds (multipliers of the day's median ATR):
  low_atr    : ATR < 0.7 * median
  medium_atr : 0.7 * median <= ATR <= 1.4 * median
  high_atr   : ATR > 1.4 * median
"""

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


@indicator(
    name="ATR",
    description="ATR line + low/medium/high regime tags relative to day's median",
    overlay=False,
    data_mode="ohlc",
    required_columns=["high", "low", "close"],
)
class Atr:
    period = input.int(14, "ATR Period", min=2, max=500,
                       tooltip="ATR lookback in bars.")
    low_mult = input.float(0.7, "Low Threshold (x median)", min=0.0, max=10.0, step=0.05,
                           tooltip="ATR below this many medians = low_atr.")
    high_mult = input.float(1.4, "High Threshold (x median)", min=0.0, max=10.0, step=0.05,
                            tooltip="ATR above this many medians = high_atr.")
    line_color = input.color("#E0AF68", "Line Color")

    def calculate(self, df):
        high_arr = np.asarray(df["high"], dtype=np.float64)
        low_arr = np.asarray(df["low"], dtype=np.float64)
        close_arr = np.asarray(df["close"], dtype=np.float64)

        atr = ta.atr(high_arr, low_arr, close_arr, self.period)

        finite = atr[np.isfinite(atr)]
        median_atr = float(np.median(finite)) if finite.size else float("nan")
        low_thresh = median_atr * self.low_mult if np.isfinite(median_atr) else float("nan")
        high_thresh = median_atr * self.high_mult if np.isfinite(median_atr) else float("nan")

        plot(atr, "ATR", color=self.line_color, linewidth=2)

        finite_atr = np.isfinite(atr)
        low_atr = (atr < low_thresh) & finite_atr
        high_atr = (atr > high_thresh) & finite_atr
        medium_atr = finite_atr & ~low_atr & ~high_atr

        define_tag("low_atr", f"ATR(<{self.low_mult:.2f}x median)", color="#7AA2F7")
        define_tag("medium_atr", f"{self.low_mult:.2f}x .. {self.high_mult:.2f}x median", color="#A1A1AA")
        define_tag("high_atr", f"ATR(>{self.high_mult:.2f}x median)", color="#F7768E")

        return {
            "low_atr": low_atr,
            "medium_atr": medium_atr,
            "high_atr": high_atr,
        }
bands.pypython89 lines

Bollinger Bands indicator.

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

expand
# qc-api: 1.0.7
# 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

"""Bands: EMA + ATR-band regime classifier (Python / OHLC).

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

Demonstrates:
- ta.ema + ta.atr literal-period hoisting in @day_start
- Multiple plotted bands and fill() between them
- define_tag() for three regime states (trending, ranging, volatile)
- required_columns declared so SHM ships only OHLC (not order-flow extras)
- Conditional styling: bgcolor() per regime state
"""

import numpy as np
from quant_charts import (
    indicator, input, plot, fill, bgcolor, define_tag,
    ta, above, below, between,
)


@indicator(
    name="Bands",
    description="EMA + ATR-band regime classifier with three regime tags",
    overlay=True,
    data_mode="ohlc",
    required_columns=["high", "low", "close"],
)
class Bands:
    ema_period = input.int(50, "EMA Period", min=2, max=10000)
    atr_mult = input.float(2.0, "ATR Band Multiplier", min=0.05, max=100.0, step=0.05)
    vol_ratio = input.float(1.5, "High-Vol Ratio (atr20 / atr200)", min=0.0, max=100.0, step=0.05)
    trend_color = input.color("#7AA2F7", "Trend Band Color")
    range_color = input.color("#A1A1AA", "Range Band Color")

    def calculate(self, df):
        close_arr = np.asarray(df["close"], dtype=np.float64)
        high_arr = np.asarray(df["high"], dtype=np.float64)
        low_arr = np.asarray(df["low"], dtype=np.float64)
        atr20 = ta.atr(high_arr, low_arr, close_arr, 20)
        atr200 = ta.atr(high_arr, low_arr, close_arr, 200)

        # Param-dependent EMA: has to live here, not in @day_start.
        # alt: hoist a fixed-period EMA (e.g., 50) and skip sweeping ema_period
        ema = ta.ema(close_arr, self.ema_period)

        upper = ema + atr20 * self.atr_mult
        lower = ema - atr20 * self.atr_mult

        plot(ema, "EMA", color=self.trend_color, linewidth=2)
        plot(upper, "Upper Band", color=self.trend_color, linewidth=1, opacity=60)
        plot(lower, "Lower Band", color=self.trend_color, linewidth=1, opacity=60)
        fill("Upper Band", "Lower Band", color=self.trend_color, opacity=8)

        # alt: add a dashed midline reference at the daily VWAP equivalent
        # hline(0.0, "Zero", color="#63636e", linestyle="dotted")

        # Regime classification.
        # Trending: close outside the band AND price persistently in that direction.
        # Ranging: close within the band.
        # Volatile: short-term ATR materially above long-run baseline.
        trending_up = above(close_arr, upper)
        trending_dn = below(close_arr, lower)
        ranging = between(close_arr, lower, upper)
        high_vol = (atr20 > atr200 * self.vol_ratio) & np.isfinite(atr200)

        # Visualize regime via background highlights.
        bgcolor(trending_up, color="#26A69A", opacity=8)
        bgcolor(trending_dn, color="#EF5350", opacity=8)
        bgcolor(high_vol, color="#E0AF68", opacity=6)

        define_tag("trend_up", "Close above upper ATR band", color="#22C55E")
        define_tag("trend_down", "Close below lower ATR band", color="#EF4444")
        define_tag("ranging", "Close inside ATR band envelope", color="#A1A1AA")
        define_tag("high_volatility", f"ATR(20) > {self.vol_ratio:.2f} x ATR(200)", color="#E0AF68")

        return {
            "trend_up": trending_up,
            "trend_down": trending_dn,
            "ranging": ranging,
            "high_volatility": high_vol,
        }
orderflow.pypython89 lines

Cumulative delta orderflow on OHLC.

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

expand
# qc-api: 1.0.7
# 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 (Python / OHLC).

Required columns: high, low, close.
Optional columns: open, volume, delta, bid_vol, ask_vol, tickCount.
Data mode: OHLC. Designed for TBBO-aggregated bars carrying order-flow extras
but degrades gracefully when those columns are absent.

Why this is a separate pane (not on the price chart): delta and CVD are signed
volume counts, on a totally different scale from price. Mixing them with a
~$20k VWAP line in one pane collapses the y-axis and one of them becomes
invisible. Use the dedicated `vwap.py` overlay if you want VWAP on the price
pane alongside this.

Demonstrates:
- delta_series fallback chain (delta -> ask_vol-bid_vol -> sign(close-open)).
- cvd() helper (cumulative volume delta) on a separate normalized pane.
- plot_histogram_colored() with positive-vs-negative coloring on the delta pane.
- imbalance() helper applied to bid_vol / ask_vol when present, used for the
  heavy_buy / heavy_sell tags (no plotted output - the imbalance line and the
  delta line live on incompatible scales, so we surface the signal as tags).
"""

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


@indicator(
    name="Order-Flow",
    description="Per-bar delta histogram + CVD line, with bid/ask imbalance tags",
    overlay=False,
    data_mode="ohlc",
    required_columns=["high", "low", "close"],
)
class OrderFlow:
    imbalance_threshold = input.float(0.65, "Imbalance Threshold", min=0.5, max=1.0, step=0.01)
    pos_color = input.color("#26A69A", "Buy Pressure Color")
    neg_color = input.color("#EF5350", "Sell Pressure Color")

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

        # delta_series resolves: delta -> (ask_vol-bid_vol) -> sign(close-open).
        delta_arr = delta_series(df)
        cvd_arr = cvd(delta_arr)

        # Per-bar delta histogram colored by direction. Sits on the same axis
        # as cvd: both are signed volume counts, scales are commensurate.
        delta_colors = np.where(delta_arr >= 0, self.pos_color, self.neg_color)
        plot_histogram_colored(delta_arr, "Delta", color=self.pos_color, colors=delta_colors)
        hline(0.0, "Zero", color="#63636e", linestyle="dashed")
        plot(cvd_arr, "CVD", color="#7AA2F7", linewidth=2, plot_type=PlotType.LINE)

        # bid/ask imbalance from TBBO-aggregated sizes per bar. Reported as
        # tags only - the imbalance ratio sits in [0, 1] and would dominate
        # the delta histogram if plotted on the same axis.
        bid_vol_arr = df_col_or(df, "bid_vol")
        ask_vol_arr = df_col_or(df, "ask_vol")
        if bid_vol_arr is not None and ask_vol_arr is not None:
            imb = 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)

        define_tag("delta_positive", "Per-bar buy-side delta", color=self.pos_color)
        define_tag("delta_negative", "Per-bar sell-side delta", color=self.neg_color)
        define_tag("heavy_buy_pressure", f"Bid imbalance > {self.imbalance_threshold:.2f}", color=self.pos_color)
        define_tag("heavy_sell_pressure", f"Ask imbalance > {self.imbalance_threshold:.2f}", color=self.neg_color)

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

OHLC volume profile with HVN / LVN / value-area detection.

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

expand
# qc-api: 1.0.7
# 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 (Python / OHLC) - anchored profile with POC, VA, HVN/LVN.

Required columns: high, low, close, timestamp. Optional: volume.
Data mode: OHLC (use vp.rs / vp_orderflow.rs for tick-fidelity TBBO profiles).

Builds one horizontal volume histogram per anchor period (session or wall-clock
ET alignment) by binning each bar's typical price into fixed-width rows and
accumulating volume into the touched row. Computes:
  - POC (point of control)  : row with max volume
  - VAH / VAL               : edges of the value area (default 70% of total volume)
  - HVN levels              : local maxima above hvn_threshold of POC
  - LVN levels              : local minima below lvn_threshold of POC

Tags emitted (per-bar bool):
  at_poc            : close within proximity_ticks of POC
  inside_value_area : close between VAL and VAH
  above_vah         : close above VAH (price discovery up)
  below_val         : close below VAL (price discovery down)
  near_hvn          : close near any HVN level
  near_lvn          : close near any LVN level

With use_completed_vp on (default), tags for bar i reference the LAST FULLY
COMPLETED anchor period's VP, not the in-progress one. Bars in the first
anchor period of the session emit no tags. Mirrors the Rust vp.rs flag and
the strategy-side VpZoneRules.use_completed_vp semantics.

aligned_hour / aligned_30m / aligned_15m bucket on UTC milliseconds. ET is a
whole-hour offset from UTC for futures sessions, so these alignments are
identical between UTC and ET. Finer-than-15m alignments would require ET
conversion - not exposed here. For tick fidelity (every print binned, not just
bar typical price), use the Rust `vp.rs` indicator.
"""

import numpy as np
from quant_charts import (
    indicator, input, plot, hline, define_tag, volume_series, vp_visual,
)


@indicator(
    name="Volume Profile",
    description="Anchored volume profile with POC, value area, and HVN/LVN tags",
    overlay=True,
    data_mode="ohlc",
    required_columns=["high", "low", "close"],
)
class VolumeProfile:
    anchor_kind = input.string("aligned_hour", "Anchor",
                               options=["session", "aligned_hour", "aligned_30m", "aligned_15m"],
                               tooltip="When a fresh VP starts. session = one VP per trading day; "
                                       "aligned_* = wall-clock boundaries.")
    use_completed_vp = input.bool(True, "Use Completed VP",
                                  tooltip="When on, tags and lines reflect the LAST FULLY COMPLETED "
                                          "anchor period (the previous hour with aligned_hour) "
                                          "instead of the live in-progress profile. Strict "
                                          "anti-lookahead: no fires until the first period has "
                                          "archived (e.g. 1 hour into the session with aligned_hour).")
    row_size = input.float(0.25, "Row Size", min=0.01, max=100.0, step=0.01,
                           tooltip="Vertical bin size in price units. 0.25 = one MNQ tick. "
                                   "Smaller = finer detail, slower; larger = coarser zones.")
    value_area_pct = input.float(0.70, "Value Area %", min=0.10, max=0.99, step=0.01,
                                 tooltip="Cumulative share of period volume between VAL and VAH.")
    hvn_threshold = input.float(0.85, "HVN Threshold", min=0.30, max=1.00, step=0.05,
                                tooltip="Row volume must exceed this fraction of POC to qualify as an HVN.")
    lvn_threshold = input.float(0.20, "LVN Threshold", min=0.01, max=0.50, step=0.01,
                                tooltip="Row volume must be below this fraction of POC to qualify as an LVN.")
    proximity_ticks = input.int(2, "Proximity (ticks)", min=0, max=100,
                                tooltip="How close 'near' tags fire. Measured in row_size units.")
    poc_color = input.color("#E0AF68", "POC Color")
    va_color = input.color("#73DACA", "Value Area Color")
    render_histogram = input.bool(True, "Render Histogram",
                                  tooltip="Emit one horizontal histogram per anchor period. When off, "
                                          "only the last completed period's POC/VAH/VAL hlines are drawn.")
    histogram_color = input.color("#7AA2F7", "Histogram Color")
    histogram_align = input.string("right_of_range", "Histogram Align",
                                   options=["right_of_range", "left_of_range", "over_range", "pinned_right", "pinned_left"],
                                   tooltip="Where to anchor each period's histogram. pinned_* glues it to the chart edge regardless of pan.")
    histogram_width_px = 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):
        """Run the single-period POC / value-area / HVN / LVN computation.
        Returns None for empty / degenerate periods so the caller can skip them.

        Each bar's volume is spread uniformly across every price row its
        [low, high] range covers (difference-array + cumsum, O(n)). This is
        the standard OHLC volume-profile approximation. The old method charged
        the whole bar to one row at its typical price, which collapsed a wide
        bar's volume onto a single tick and produced a sparse, misleading
        profile. For true per-print fidelity use the Rust vp.rs / vp_orderflow.rs.
        """
        finite = (np.isfinite(high) & np.isfinite(low) & np.isfinite(close)
                  & np.isfinite(vol) & (vol > 0.0))
        if not finite.any():
            return None
        h = high[finite]
        l = low[finite]
        v = vol[finite]
        lo = float(np.min(l))
        hi = 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)
        # uniform share per touched row; difference array then cumsum so each
        # bar contributes to its whole range in O(1) regardless of width
        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 = 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

        # Group bars into anchor periods. UTC ms alignment is correct for hour /
        # 30m / 15m because ET is a whole-hour offset (EST=-5, EDT=-4); those
        # boundaries land on the same instants in UTC.
        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])

        # Per-bar tag reference. use_completed_vp: walk back from anchor_idx-1
        # to find the first non-empty completed period. Plain mode: use the
        # bar's own period (in-progress allowed). Bars in the first period get
        # no tags when use_completed_vp is on - matches the Rust warmup gate.
        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

        # hline fallback: when not rendering histograms, draw POC / VAH / VAL
        # for the most recent COMPLETED period. With use_completed_vp on this
        # excludes the in-progress final period; without it, last non-empty.
        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]
            hline(r["poc_price"], "POC", color=self.poc_color, linestyle="solid")
            hline(r["vah"], "VAH", color=self.va_color, linestyle="dashed")
            hline(r["val"], "VAL", color=self.va_color, linestyle="dashed")

        # One histogram per completed period. With use_completed_vp we skip the
        # in-progress final period so its bars do not render. Level lines
        # forward-project to the NEXT period (when this completed VP is driving
        # tags), mirroring vp.rs.
        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]
                # next period's anchor or this period's last bar ts
                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
                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),
                )

        define_tag("at_poc", "Close near POC", color="#E0AF68")
        define_tag("inside_value_area", "Close between VAL and VAH", color="#73DACA")
        define_tag("above_vah", "Close above VAH", color="#26A69A")
        define_tag("below_val", "Close below VAL", color="#EF5350")
        define_tag("near_hvn", "Close near a high-volume node", color="#7AA2F7")
        define_tag("near_lvn", "Close near a low-volume node", color="#F7768E")

        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.pypython81 lines

Volume-weighted average price.

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

expand
# qc-api: 1.0.7
# 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

"""VWAP: session VWAP with deviation bands and tags (Python / OHLC).

Required columns: high, low, close.
Optional columns: vwap (precomputed by upstream), volume (improves accuracy).
Data mode: OHLC.

Baseline indicator. Builds VWAP from typical price * volume, draws +/- band_mult
sigma deviation bands, and emits three tags. Falls back to the parquet's `vwap`
column when present; otherwise computes session VWAP from typical price + volume.
On parquets that ship neither volume nor a precomputed vwap, falls back to a
simple typical-price cumulative average so the line still renders.

Tags:
  above_vwap_band  : close above VWAP + band_mult*sigma
  below_vwap_band  : close below VWAP - band_mult*sigma
  inside_vwap_band : close within +/- band_mult*sigma of VWAP
"""

import numpy as np
from quant_charts import indicator, input, plot, fill, define_tag, vwap_series, volume_series


@indicator(
    name="VWAP",
    description="Session VWAP with deviation bands and above/inside/below tags",
    overlay=True,
    data_mode="ohlc",
    required_columns=["high", "low", "close"],
)
class Vwap:
    band_mult = input.float(2.0, "Band Multiplier (x sigma)", min=0.0, max=10.0, step=0.05,
                            tooltip="Deviation-band width as a multiple of session-cumulative VWAP sigma.")
    line_color = input.color("#7AA2F7", "VWAP Color")
    band_color = input.color("#73DACA", "Band Color")

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

        # vwap_series handles the column-priority fallback: precomputed `vwap`
        # column when present, else session-cumulative VWAP from typical price
        # * volume_series(df). Works on every parquet shape.
        vwap = vwap_series(df)
        vol_safe = volume_series(df)

        # Deviation: cumulative variance of (close - VWAP) weighted by volume.
        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

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

        finite_band = np.isfinite(upper) & np.isfinite(lower)
        above_band = (close_arr > upper) & finite_band
        below_band = (close_arr < lower) & finite_band
        inside_band = finite_band & ~above_band & ~below_band

        define_tag("above_vwap_band", f"Close > VWAP + {self.band_mult:.2f}x sigma", color="#26A69A")
        define_tag("below_vwap_band", f"Close < VWAP - {self.band_mult:.2f}x sigma", color="#EF5350")
        define_tag("inside_vwap_band", f"Close within +/- {self.band_mult:.2f}x sigma of VWAP", color="#A1A1AA")

        return {
            "above_vwap_band": above_band,
            "below_vwap_band": below_band,
            "inside_vwap_band": inside_band,
        }

Built-in Indicators (Rust)

atr.rsrust105 lines

Average True Range computed on TBBO ticks.

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

expand
//! qc-api: 1.0.7
// 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 (Rust)",
    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)
    }
}
orderflow.rsrust143 lines

TBBO cumulative orderflow delta.

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

expand
//! qc-api: 1.0.7
// 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 `vp_orderflow.rs` for zone-conditioned dominance tags.
//!
//! 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)
    }
}
vp_orderflow.rsrust235 lines

Volume profile combined with orderflow.

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

expand
//! qc-api: 1.0.7
// 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 Order-Flow (Rust / TBBO).
//!
//! Required columns: bid, ask, bid_size, ask_size (TBBO native).
//! Data mode: TBBO (tick).
//!
//! Volume Profile combined with bid/ask dominance at zones. Tags fire when the
//! mid is at a VP zone AND the smoothed imbalance points one way - useful for
//! "tag a trade as 'bid-supported HVN' when entered" style analysis.
//!
//! Tags (per-tick):
//!   bid_dom_at_poc - mid at POC AND smoothed imbalance bid-heavy
//!   ask_dom_at_poc - mid at POC AND smoothed imbalance ask-heavy
//!   bid_dom_at_hvn - mid near any HVN AND imbalance bid-heavy
//!   ask_dom_at_hvn - mid near any HVN AND imbalance ask-heavy
//!
//! Pair with `vp.rs` for the basic VP, or with `orderflow.rs` for pure
//! imbalance/CVD without the zone overlay.

use qc_strategy_api::prelude::*;

#[indicator(
    name = "VP Order-Flow",
    description = "Volume Profile + bid/ask dominance combined as zone-conditioned tags",
    overlay = true,
    data_mode = "tick",
    // Zone-conditioned tags fire from VP membership, not raw per-tick imbalance,
    // so the indicator stays meaningful in OHLC view.
    cross_view = true
)]
#[tag(name = "bid_dom_at_poc", label = "Bid Dom @ POC", color = "#26A69A", description = "Mid at POC and imbalance bid-heavy")]
#[tag(name = "ask_dom_at_poc", label = "Ask Dom @ POC", color = "#EF5350", description = "Mid at POC and imbalance ask-heavy")]
#[tag(name = "bid_dom_at_hvn", label = "Bid Dom @ HVN", color = "#73DACA", description = "Mid near any HVN and imbalance bid-heavy")]
#[tag(name = "ask_dom_at_hvn", label = "Ask Dom @ HVN", color = "#F7768E", description = "Mid near any HVN and imbalance ask-heavy")]
#[derive(Default)]
pub struct VpOrderFlow {
    #[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.")]
    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 for an HVN.")]
    pub hvn_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,

    #[param(default = 1000, min = 1, max = 200000, label = "Imbalance Smoothing (ticks)",
            tooltip = "Rolling-mean window for the bid/ask imbalance.")]
    pub imbalance_smooth: usize,

    #[param(default = 0.15, min = 0.0, max = 0.49, step = 0.01, label = "Imbalance Threshold",
            tooltip = "Distance from 0.5 that qualifies as bid- or ask-dominant.")]
    pub imbalance_threshold: f64,
}

impl Indicator for VpOrderFlow {
    fn prepare(data: &TickData) -> DayPrep {
        // Hoisted: raw imbalance is param-independent.
        let mut prep = DayPrep::empty();
        prep.insert_f64("imb_raw", imbalance(&data.bid_size, &data.ask_size));
        prep
    }

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

        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 upper = 0.5 + self.imbalance_threshold;
        let lower = 0.5 - self.imbalance_threshold;

        let params = VpParams {
            anchor_kind: "session".to_string(),
            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: 0.20,
            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,
        };
        let mut vp = params.build(data);

        let mut bid_dom_at_poc = vec![false; n];
        let mut ask_dom_at_poc = vec![false; n];
        let mut bid_dom_at_hvn = vec![false; n];
        let mut ask_dom_at_hvn = vec![false; n];

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

        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;
            }
            let v = imb[i];
            if !v.is_finite() {
                continue;
            }
            let bid_dom = v > upper;
            let ask_dom = v < lower;
            if !bid_dom && !ask_dom {
                continue;
            }

            if let Some(snap) = vp.current() {
                if (mid - snap.poc_price).abs() <= prox {
                    if bid_dom {
                        bid_dom_at_poc[i] = true;
                    } else {
                        ask_dom_at_poc[i] = true;
                    }
                }
                for h in &snap.hvn {
                    if (mid - h.price).abs() <= prox {
                        if bid_dom {
                            bid_dom_at_hvn[i] = true;
                        } else {
                            ask_dom_at_hvn[i] = true;
                        }
                        break;
                    }
                }
            }
        }
        vp.finalize();

        // Render the VP histogram so the user can see what the tags are firing
        // against. Single archived snapshot per anchor period.
        const PRIMARY_BIN: &str = "#BB9AF7";
        const POC_COLOR: &str = "#E0AF68";
        const VA_COLOR: &str = "#73DACA";
        let history = vp.history();
        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
            };
            // Trim leading / trailing zero bins. See vp.rs for the rationale;
            // snap.bins is sized for the whole day so any single period wastes
            // most of the array on zero padding.
            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: 30,
                width_px: 70,
                align: HistogramAlign::RightOfRange,
                style: VpStyle::Bars,
                bin_colors: vec![],
                hvn_levels: snap.hvn.iter().map(|h| h.price).collect(),
                lvn_levels: vec![],
                poc_trail: vec![],
                value_area_trail: vec![],
                level_lines_anchor_ts: None,
                level_lines_end_ts: None,
            });
        }

        let mut out = IndicatorOutput::new()
            .with_overlay(true)
            .with_tag("bid_dom_at_poc", bid_dom_at_poc)
            .with_tag("ask_dom_at_poc", ask_dom_at_poc)
            .with_tag("bid_dom_at_hvn", bid_dom_at_hvn)
            .with_tag("ask_dom_at_hvn", ask_dom_at_hvn);
        out.vp_visuals = vp_visuals;
        out
    }
}
vp.rsrust285 lines

TBBO volume profile (canonical implementation).

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

expand
//! qc-api: 1.0.7
// 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 VP combined with order-flow dominance (bid/ask at zones), see
//! `vp_orderflow.rs`.

use qc_strategy_api::prelude::*;

#[indicator(
    name = "VP",
    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,
        }
    }
}
vwap.rsrust108 lines

Tick-level VWAP.

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

expand
//! qc-api: 1.0.7
// 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

//! VWAP (Rust / TBBO).
//!
//! Required columns: bid, ask, bid_size, ask_size. Data mode: TBBO (tick).
//!
//! Session-cumulative VWAP from tick mid * tick size. Builds deviation bands
//! at +/- band_mult * sigma. Three tags fire from close-vs-band membership.
//!
//! Anchoring: the cumulative sums reset each day-prep call, so on a multi-day
//! sweep VWAP is per-day session-anchored automatically. For a rolling-window
//! VWAP, swap the cumulative sum for a rolling sum with a fixed window.

use qc_strategy_api::prelude::*;

#[indicator(
    name = "VWAP (Rust)",
    description = "Session VWAP with deviation bands and above/inside/below tags",
    overlay = true,
    data_mode = "tick",
    // Session VWAP is a smooth cumulative quantity; downsampling to bar closes
    // is faithful, so this stays rendered in OHLC view.
    cross_view = true
)]
#[tag(name = "above_vwap_band",  label = "Above Band",  color = "#26A69A", description = "Mid above VWAP + band_mult x sigma")]
#[tag(name = "below_vwap_band",  label = "Below Band",  color = "#EF5350", description = "Mid below VWAP - band_mult x sigma")]
#[tag(name = "inside_vwap_band", label = "Inside Band", color = "#A1A1AA", description = "Mid within +/- band_mult x sigma of VWAP")]
#[derive(Default)]
pub struct Vwap {
    #[param(default = 2.0, min = 0.0, max = 10.0, step = 0.05, label = "Band Multiplier (x sigma)",
            tooltip = "Deviation band width as a multiple of session VWAP sigma.")]
    pub band_mult: f64,
}

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

        let mut vwap = vec![f64::NAN; n];
        let mut upper = vec![f64::NAN; n];
        let mut lower = vec![f64::NAN; n];

        // Cumulative running sums. Each tick's "size" is bid_size + ask_size
        // (volume proxy on TBBO). Sigma is the volume-weighted variance.
        let mut sum_pv: f64 = 0.0;
        let mut sum_v: f64 = 0.0;
        let mut sum_p2v: f64 = 0.0;

        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 };
            let v = (bs + as_).max(0.0);

            if mid.is_finite() && v > 0.0 {
                sum_pv += mid * v;
                sum_v += v;
                sum_p2v += mid * mid * v;
            }

            if sum_v > 0.0 {
                let mean = sum_pv / sum_v;
                let var = (sum_p2v / sum_v) - mean * mean;
                let sigma = var.max(0.0).sqrt();
                vwap[i] = mean;
                upper[i] = mean + self.band_mult * sigma;
                lower[i] = mean - self.band_mult * sigma;
            }
        }

        let mut above = vec![false; n];
        let mut below = vec![false; n];
        let mut inside = vec![false; n];
        for i in 0..n {
            let m = data.mid[i];
            let u = upper[i];
            let l = lower[i];
            if !(m.is_finite() && u.is_finite() && l.is_finite()) {
                continue;
            }
            if m > u {
                above[i] = true;
            } else if m < l {
                below[i] = true;
            } else {
                inside[i] = true;
            }
        }

        IndicatorOutput::new()
            .with_overlay(true)
            .plot_line("VWAP", vwap, "#7AA2F7")
            .plot_line("VWAP Upper", upper, "#73DACA")
            .plot_line("VWAP Lower", lower, "#73DACA")
            .with_tag("above_vwap_band", above)
            .with_tag("below_vwap_band", below)
            .with_tag("inside_vwap_band", inside)
    }
}

Built-in Strategies (Python)

multi_setup.pypython93 lines

Multi-tag entry labeling with vol-spike exit trigger.

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

expand
# qc-api: 1.0.7
# 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: distinguish two entry setups in the analyzer.

Two long entry conditions feed the same `entry_long` signal:
  - breakout: close above 20-bar high
  - pullback: close below 20-bar SMA AND RSI<35

Each setup gets a tag returned from calculate(); the analyzer's per-tag stats
panel shows breakout vs pullback win rate / PnL separately, and the tag chips
drive preventive trade filtering.

A third tag, `vol_spike`, demonstrates trade modification via the return-dict
arrays: `block_entries` refuses new entries while a 3-sigma volume spike is
firing, and folding it into `exit_long` closes any open long on the spike.
"""

import numpy as np
from quant_charts import (
    strategy, input, ta, define_tag, volume_series, Timeframe,
)


@strategy(
    name="Multi-Setup",
    overlay=True,
    timeframe=Timeframe.M1,
    data_mode="ohlc",
    required_columns=["close", "high", "low"],
    uses_sltp=False,
)
class MultiSetup:
    breakout_lookback = input.int(20, "Breakout lookback", min=5, max=200)
    rsi_period = input.int(14, "RSI period", min=2, max=100)
    pullback_thresh = input.int(35, "Pullback RSI threshold", min=10, max=60)
    vol_z_thresh = input.float(3.0, "Vol-spike z-score", min=1.0, max=10.0, step=0.1)

    def calculate(self, df):
        close = np.asarray(df["close"], dtype=np.float64)
        high = np.asarray(df["high"], dtype=np.float64)
        # volume_series resolves volume -> bid_vol+ask_vol -> tickCount -> zeros
        # so this works on raw DBN TBBO (no native volume column) the same as
        # on a file that carries volume directly.
        volume = volume_series(df)

        sma20 = ta.sma(close, 20)
        rolling_high = np.zeros_like(close)
        for i in range(self.breakout_lookback, len(close)):
            rolling_high[i] = high[i - self.breakout_lookback:i].max()

        rsi = ta.rsi(close, self.rsi_period)

        breakout = close > rolling_high
        pullback = (close < sma20) & (rsi < self.pullback_thresh)

        # 3-sigma volume spike, computed inline.
        vol_mean = ta.sma(volume, 50)
        vol_std = np.zeros_like(volume)
        for i in range(50, len(volume)):
            vol_std[i] = volume[i - 50:i].std()
        vol_spike = (volume - vol_mean) > (self.vol_z_thresh * vol_std)

        # Tag declarations make these chips render with the right color/label.
        # Returning the bool arrays below is what powers per-tag analyzer stats
        # and preventive filtering (breakout vs pullback grouped separately).
        define_tag("breakout", "Long entry on N-bar breakout", color="#22c55e")
        define_tag("pullback", "Long entry on RSI pullback below SMA20", color="#3b82f6")
        define_tag("vol_spike", "3-sigma volume spike", color="#ef4444")

        # Simple cross-below-SMA exit, plus a forced close on a volume spike.
        from quant_charts import cross_below
        exit_signal = cross_below(close, sma20)

        return {
            "entry_long": breakout | pullback,
            # Folding vol_spike into the exit closes any open long on the spike.
            "exit_long": exit_signal | vol_spike,
            "entry_short": np.zeros_like(close, dtype=bool),
            "exit_short": np.zeros_like(close, dtype=bool),
            # Per-bar entry gate: no NEW entries while a volume spike is firing.
            "block_entries": vol_spike,
            # tag bool arrays travel as plain return-dict keys (anything not in
            # the canonical signal-key set is auto-classified as a tag).
            "breakout": breakout,
            "pullback": pullback,
            "vol_spike": vol_spike,
        }
optimize.pypython123 lines

Showcase: @day_start hoisting patterns for parameter sweeps.

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

expand
# qc-api: 1.0.7
# 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

"""Optimize: canonical day-start hoisting reference (Python / OHLC).

Required columns: high, low, close.
Data mode: OHLC.
Default timeframe: 1m.

CANONICAL PERFORMANCE REFERENCE for Python strategies. The strategy logic is
intentionally trivial (a basic EMA crossover with an ATR regime filter) so the
hoisting pattern, not the trading idea, is the focus.

Five rules of @day_start hoisting (the analyzer can sweep 700+ param combos per
day; doing literal-period TA outside the @day_start hook means doing it 700x):

  1. Convert columns to numpy ONCE per day, not per combo.
       BAD:   close = np.asarray(df["close"]) inside calculate()
       GOOD:  close = np.asarray(df["close"]) inside @day_start prep()

  2. Run literal-period TA ONCE per day, not per combo.
       BAD:   atr = ta.atr(high, low, close, 14)  inside calculate()
       GOOD:  same call inside @day_start; reuse self._day["atr14"]

  3. Run SWEPT-period TA inside calculate() ONLY for the param being swept.
       Keep all other periods literal and hoisted.
       BAD:   ema_fast = ta.ema(close, self.fast_period)
              ema_slow = ta.ema(close, 200)              # literal, but called per combo!
       GOOD:  ema_slow comes from self._day["ema200"];
              ema_fast = ta.ema(close, self.fast_period) here in calculate()

  4. Cache day-level scalars (vol_mean, max_drawdown, regime label) in @day_start
     and read as self._day["..."]. Saves recomputing them once per combo.

  5. Use required_columns=[...] so SHM only ships what you read. On a 28d sweep
     this saves 30-70% of inter-process bandwidth.

The decorator scans for any method marked with @day_start, calls it once per day
per worker BEFORE the param loop, and stashes the return on self._day. From
calculate() you just read self._day["foo"].

Pair this template with `vp_zone.rs` for the Rust performance reference.
"""

import numpy as np
from quant_charts import (
    strategy, day_start, input, ta, cross_above, cross_below,
    Timeframe,
)


@strategy(
    name="Optimize",
    overlay=True,
    timeframe=Timeframe.M1,
    data_mode="ohlc",
    # Rule 5: ship only what we read.
    required_columns=["high", "low", "close"],
    uses_sltp=False,
)
class Optimize:
    fast_ema = input.int(9, "Fast EMA", min=2, max=10000)        # SWEPT
    slow_ema = input.int(21, "Slow EMA", min=2, max=10000)       # SWEPT
    regime_atr_mult = input.float(1.5, "Regime ATR Mult", min=0.0, max=100.0, step=0.1)  # SWEPT

    @day_start
    def prep(self, df):
        # Rule 1: convert columns to numpy ONCE per day.
        close_arr = np.asarray(df["close"], dtype=np.float64)
        high_arr = np.asarray(df["high"], dtype=np.float64)
        low_arr = np.asarray(df["low"], dtype=np.float64)

        # Rule 2: literal-period TA ONCE per day. atr14 is shared across every
        # (fast_ema, slow_ema, regime_atr_mult) combination for this day.
        atr14 = ta.atr(high_arr, low_arr, close_arr, 14)

        # Rule 4: cache a day-level scalar for the regime threshold.
        # We use median ATR as the day's "normal volatility": combos compare
        # against this without recomputing the percentile.
        finite_atr = atr14[np.isfinite(atr14)]
        median_atr = float(np.median(finite_atr)) if finite_atr.size else float("nan")

        return {
            "close": close_arr,
            "high": high_arr,
            "low": low_arr,
            "atr14": atr14,
            "median_atr": median_atr,
        }

    def calculate(self, df):
        # All param-independent work already lives on self._day. The bar-loop body
        # below is now pure vectorized math on pre-computed arrays.
        close_arr = self._day["close"]
        atr14 = self._day["atr14"]
        median_atr = self._day["median_atr"]

        # Rule 3: only the SWEPT-period TA runs here. Two EMA calls, that's it.
        fast = ta.ema(close_arr, self.fast_ema)
        slow = ta.ema(close_arr, self.slow_ema)

        # Regime filter: trade only when current ATR is materially above the
        # day's median (avoid sleepy chop). Vectorized: no Python loops.
        in_regime = atr14 > median_atr * self.regime_atr_mult

        entry_long = cross_above(fast, slow) & in_regime
        entry_short = cross_below(fast, slow) & in_regime

        # Symmetric exits: opposing crossover, regime-independent.
        exit_long = cross_below(fast, slow)
        exit_short = cross_above(fast, slow)

        return {
            "entry_long": entry_long,
            "exit_long": exit_long,
            "entry_short": entry_short,
            "exit_short": exit_short,
        }
trail_sltp.pypython137 lines

Custom per-tick chandelier trail logic in Python.

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

expand
# qc-api: 1.0.7
# 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 (Python / OHLC) - chandelier trail via per-tick SL/TP arrays.

Required columns: high, low, close.
Data mode: OHLC.
Default timeframe: 1m.

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. For
breakeven / tick-shift patterns keyed off a tag, the `breakeven_when()` and
`shift_levels()` helpers build these arrays for you.

Demonstrates:
- uses_sltp=True with emit_sltp='per_tick': engine evaluates SL/TP against
  every tick; the strategy emits one value per bar and the engine carries it
  forward to intra-bar ticks
- Chandelier-from-close: SL = close - K*ATR every bar. The engine's per-tick
  favorable ratchet (highest for long, lowest for short) keeps the best value
  seen per trade, so the SL trails the trend with zero per-trade state needed
- Wide initial bracket scaled to ATR. Trail tightens automatically as the
  trend extends

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
- Result: gives back K*ATR of paper profit on a clean reversal

Exits in priority order: SL (trail), TP (target), opposite EMA cross (signal).
"""

import numpy as np
from quant_charts import (
    strategy, day_start, input, ta, cross_above, cross_below,
    Timeframe,
)


@strategy(
    name="Trail SL/TP",
    overlay=True,
    timeframe=Timeframe.M1,
    data_mode="ohlc",
    required_columns=["high", "low", "close"],
    uses_sltp=True,
    emit_sltp="per_tick",  # required: trailing stops live in this mode
)
class TrailSltp:
    fast_ema = input.int(12, "Fast EMA", min=2, max=10000,
                         tooltip="Fast leg of the EMA cross. Higher = fewer, slower trades. "
                                 "12 is brisk enough on MNQ to give the MCP/AI a baseline that fires; "
                                 "raise to 21+ if you want the original conservative trend-follow shape.")
    slow_ema = input.int(34, "Slow EMA", min=2, max=10000,
                         tooltip="Slow leg of the EMA cross. 12/34 trades roughly 2-4x more often than 21/55 "
                                 "on MNQ 1m, which keeps the default visible without overfitting to noise.")
    sl_atr = input.float(2.5, "SL / Trail (xATR)", min=0.05, max=100.0, step=0.05,
                         tooltip="Wide initial bracket and trailing give-back distance (same number). "
                                 "Larger = lets winners run further, gives back more on reversal.")
    tp_atr = input.float(4.0, "TP (xATR)", min=0.05, max=100.0, step=0.05,
                         tooltip="Take-profit distance. Keep >= sl_atr for non-negative R-multiple.")

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

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

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

        entry_long = cross_above(fast, slow)
        entry_short = cross_below(fast, slow)

        # Chandelier from current close, written every bar. The engine's
        # per-tick favorable ratchet does the rest:
        #   - Long entry: sl = entry - K*ATR (initial wide bracket)
        #   - Trend up:   close rises => sl rises => engine ratchets up
        #   - Pullback:   new sl lower => engine keeps the prior high
        # Same mirrored for shorts (engine keeps prior low).
        # Zero per-trade state needed - the strategy emits "what SL should
        # be IF I had a position here" and the engine handles the rest.
        # NaN guard: ATR is NaN for the first 14 bars of warmup. Multiplying
        # propagates NaN into the SL/TP arrays, which the engine reads as
        # "no change" - exactly the behaviour we want during warmup.
        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

        # alt: separate initial bracket from trail distance. Requires explicit
        # per-trade state (am I in a trade? for how long?), which strategies
        # don't have native access to. The chandelier-from-close pattern above
        # collapses initial and trail into one parameter to keep things stateless.

        # alt: time-based session exit (close at 15:55 ET regardless of PnL):
        # from quant_charts import hour, minute
        # eod = (hour == 15) & (minute >= 55)
        # exit_long = exit_long | eod ; exit_short = exit_short | eod

        # alt: tighten the trail once trade is in profit by R*ATR using a
        # rolling-extreme stop. Doable but pattern collapses if rolling extreme
        # ends up on the wrong side of price - keep it simple unless you need it.

        # alt: snap the stop tighter by 8 ticks from each bar a `tighten` tag
        # fires (sticky-forward; the favorable ratchet keeps it from loosening):
        # from quant_charts import shift_levels
        # tighten = atr_safe < np.nanmean(atr_safe) * 0.5
        # sl_long = shift_levels(sl_long, tighten, ticks=8)

        return {
            "entry_long": entry_long,
            "exit_long": cross_below(fast, slow),
            "entry_short": entry_short,
            "exit_short": cross_above(fast, slow),
            "sl_long": sl_long,
            "tp_long": tp_long,
            "sl_short": sl_short,
            "tp_short": tp_short,
        }

Built-in Strategies (Rust)

ema_cross.rsrust127 lines

Simple TBBO EMA crossover strategy.

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

expand
//! qc-api: 1.0.7
// 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.
//!
//! 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)
    }
}
imbalance.rsrust185 lines

Order imbalance detection with signal confirmation.

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

expand
//! qc-api: 1.0.7
// 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 = "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)
    }
}
vp_shelf.rsrust451 lines

Volume profile shelf rejection with per-tick trail.

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

expand
//! qc-api: 1.0.7
// 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 Shelf Rejection (Rust / TBBO).
//!
//! Required columns: bid, ask, bid_size, ask_size (TBBO native).
//! Data mode: TBBO (tick).
//! Default timeframe: per-tick.
//!
//! Trades shelf rejections off the LAST FULLY COMPLETED Volume Profile.
//! "Rejection" here means price tested a shelf boundary from outside the
//! shelf and is now reversing back: the shelf acted as support / resistance.
//!
//!   - LONG  when price drops down INTO the shelf from above (shelf as
//!           support, expect bounce back up).
//!   - SHORT when price rises up INTO the shelf from below (shelf as
//!           resistance, expect rejection back down).
//!
//! Why the previous (completed) VP and not the live one:
//!   - Live VP shelves drift as new bins materialize - a tick that "entered"
//!     the shelf at t=N might not still be inside that shelf at t=N+1
//!     because the shelf's bounds shifted under it. Trading off the
//!     fully-archived previous period gives a stable reference frame.
//!   - Strict no-lookahead semantics. The decision at tick N can only see
//!     bins that were locked in by the last anchor close, not anything
//!     produced from the in-progress period.
//!
//! Render: visuals are gated behind `show_vp` and use the strategy's own
//! configurable alignment / width / style so they're visually distinct from
//! a companion `vp` indicator if both are on the chart at once (e.g. set the
//! strategy to `left_of_range` and the indicator to its default
//! `right_of_range`).

use qc_strategy_api::prelude::*;

const TAGS: &[&'static str] = &[
    "entry_shelf_reject_long",
    "entry_shelf_reject_short",
];

// Warm-red bin tint distinguishes the strategy's VP from a companion
// indicator on the same chart (the indicator hardcodes #7AA2F7 / cool blue).
// When both are rendered with the default `right_of_range` align they sit on
// the same side of each period but with visibly different colors, so the
// user can verify the strategy's shelves against the indicator's zones at a
// glance instead of having to toggle one off.
const PRIMARY_BIN: &str = "#F7768E";
const POC_COLOR: &str = "#FF9E64";
const VA_COLOR: &str = "#73DACA";

#[strategy(
    name = "VP Shelf Rejection",
    description = "Long shelf rejection from above, short from below. Trades off the last completed VP only.",
    emit_sltp = "entry_only",
    data_mode = "tick"
)]
#[tag(name = "entry_shelf_reject_long",  label = "Shelf Reject Long",  color = "#26A69A", description = "Long: mid dropped down into a shelf range from above")]
#[tag(name = "entry_shelf_reject_short", label = "Shelf Reject Short", color = "#EF5350", description = "Short: mid rose up into a shelf range from below")]
#[derive(Default)]
pub struct VpShelfRejection {
    // -- VP construction (mirrors the indicator's surface) --
    #[param(default = "aligned_15m", 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. session = ET futures session. aligned_* = wall-clock ET boundaries.")]
    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"])]
    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"])]
    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 = "bid_plus_ask", label = "VP / Volume Source",
            options = ["tick_count", "bid_plus_ask", "delta", "signed"])]
    pub volume_source: String,

    // -- Shelf detection --
    //
    // Shelves are bands of contiguous bins whose volumes sit between
    // shelf_lo and shelf_hi (relative to the period's POC volume) and run
    // for at least `shelf_min_bins` rows. shelf_min_bins controls how flat
    // the band must be before it counts; merge_within_bins lets nearby
    // candidate bands fuse into one wider shelf.
    #[param(default = 0.25, min = 0.0, max = 1.0, step = 0.01, label = "Shelf / Lo Threshold",
            tooltip = "Min bin volume relative to POC for a row to count as shelf material. \
                       Lower = more shelves detected (more potential rejections to trade).")]
    pub shelf_lo: f64,

    #[param(default = 0.60, min = 0.0, max = 1.0, step = 0.01, label = "Shelf / Hi Threshold",
            tooltip = "Max bin volume relative to POC. Above this the row is HVN territory, not shelf.")]
    pub shelf_hi: f64,

    #[param(default = 5, min = 1, max = 10000, label = "Shelf / Min Bins",
            tooltip = "Min contiguous flat-band width (in row_size units) for a shelf.")]
    pub shelf_min_bins: usize,

    #[param(default = 2, min = 0, max = 10000, label = "Shelf / Merge Within Bins",
            tooltip = "Merge candidate shelves that sit within this many bins of each other.")]
    pub merge_within_bins: usize,

    // -- Trade rules --
    #[param(default = 4, min = 0, max = 100000, label = "Trade / Risk Ticks",
            tooltip = "SL distance OUTSIDE the shelf bound, in row_size units. Long SL = shelf_lo - risk; short SL = shelf_hi + risk.")]
    pub risk_ticks: usize,

    #[param(default = 8, min = 0, max = 1000000, label = "Trade / Min Target Ticks",
            tooltip = "Min reward distance from entry, in row_size units. TP = entry + max(min_target, shelf_width) for longs (mirrored for shorts).")]
    pub min_target_ticks: usize,

    #[param(default = 2000, min = 0, max = 86400000, label = "Trade / Cooldown (ms)",
            tooltip = "No re-entry within this many ms after the previous fire. Prevents stacking on the same shelf during a tight chop.")]
    pub cooldown_ms: usize,

    // -- Render --
    //
    // Match the indicator's render param surface exactly so users tuning
    // both can recognize the controls. show_vp gates emission entirely.
    // Default align is right_of_range so the histogram lands at the period
    // END (always inside the visible viewport - even when the user is
    // zoomed in on the most-recent ticks). When a companion indicator is on
    // the same chart, the strategy's warmer-red bins distinguish it from
    // the indicator's cool-blue bins; if you want them physically separated
    // instead, switch this to left_of_range.
    #[param(default = true, label = "Render / Show VP",
            tooltip = "Render the strategy's Volume Profile + shelves on the chart alongside its trades. On by default so a fresh attach shows the shelves the strategy is trading against; flip off when launching parameter sweeps to keep them allocation-free.")]
    pub show_vp: bool,

    #[param(default = "right_of_range", label = "Render / Align",
            options = ["right_of_range", "left_of_range", "over_range", "pinned_right", "pinned_left"],
            tooltip = "Where the histogram sits horizontally. Default 'right_of_range' anchors the histogram to each period's end so it's visible at the latest visible tick. Switch to left_of_range to physically separate from a companion VP indicator.")]
    pub render_align: String,

    #[param(default = "bars", label = "Render / Style",
            options = ["bars", "line", "area"])]
    pub render_style: String,

    #[param(default = 60, min = 1, max = 4000, label = "Render / Width (px)")]
    pub render_width_px: usize,

    #[param(default = 200, min = 1, max = 100000, label = "Render / Max Profiles",
            tooltip = "Maximum VPs to render on the chart (most recent N).")]
    pub render_history_periods: usize,
}

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

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

        let mut entry_long = vec![false; n];
        let exit_long = vec![false; n];
        let mut entry_short = vec![false; n];
        let exit_short = 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];

        let mut tag_long = vec![false; n];
        let mut tag_short = vec![false; n];

        let row = self.row_size;
        let risk = self.risk_ticks as f64 * row;
        let min_target = self.min_target_ticks as f64 * row;
        let cooldown = self.cooldown_ms as i64;

        let mut prev_mid = f64::NAN;
        let mut last_entry_ts: i64 = i64::MIN;

        for i in 0..n {
            let ts = data.timestamp[i];
            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(ts, mid, bs, as_);

            if !mid.is_finite() || !prev_mid.is_finite() {
                prev_mid = mid;
                continue;
            }

            // Strict anti-lookahead: shelves come ONLY from the previous
            // (fully-archived) period. last_completed_snapshot returns None
            // until at least one anchor period has finalized.
            let snap = match vp.last_completed_snapshot() {
                Some(s) => s,
                None => {
                    prev_mid = mid;
                    continue;
                }
            };

            // Cooldown: any prior fire blocks subsequent fires for cooldown_ms.
            // saturating_sub guards against the i64::MIN sentinel on the first
            // tick where last_entry_ts has no real meaning.
            let cooldown_ok = ts.saturating_sub(last_entry_ts) >= cooldown;
            if !cooldown_ok {
                prev_mid = mid;
                continue;
            }

            // Walk every shelf in the snapshot; the first geometrically valid
            // rejection wins. Shelves don't overlap by construction (the VP
            // detector merges adjacent ones via merge_within_bins) so the
            // first-match-wins rule can't double-count.
            for shelf in &snap.shelves {
                let lo = shelf.price_lo;
                let hi = shelf.price_hi;
                let width = hi - lo;
                if width <= 0.0 {
                    continue;
                }
                let target = min_target.max(width);

                // LONG: prev tick was strictly above the shelf top, this tick
                // touched or breached the top -> price came down into the
                // shelf from above. SL just below shelf, TP one shelf-width
                // (or min_target) above entry.
                if prev_mid > hi && mid <= hi && mid >= lo {
                    let sl = lo - risk;
                    let tp = mid + target;
                    if let Some((vsl, vtp)) = bracket(Side::Long, mid, sl, tp, min_target) {
                        entry_long[i] = true;
                        sl_long[i] = vsl;
                        tp_long[i] = vtp;
                        tag_long[i] = true;
                        last_entry_ts = ts;
                        break;
                    }
                }

                // SHORT: mirror. Prev tick was strictly below shelf bottom,
                // this tick touched or breached the bottom -> price came up
                // into the shelf from below.
                if prev_mid < lo && mid >= lo && mid <= hi {
                    let sl = hi + risk;
                    let tp = mid - target;
                    if let Some((vsl, vtp)) = bracket(Side::Short, mid, sl, tp, min_target) {
                        entry_short[i] = true;
                        sl_short[i] = vsl;
                        tp_short[i] = vtp;
                        tag_short[i] = true;
                        last_entry_ts = ts;
                        break;
                    }
                }
            }

            prev_mid = mid;
        }
        vp.finalize();

        let mut out = SignalOutput::new(entry_long, exit_long, entry_short, exit_short)
            .with_sl_long(sl_long)
            .with_tp_long(tp_long)
            .with_sl_short(sl_short)
            .with_tp_short(tp_short)
            .with_entry_only_sltp()
            .with_tag(TAGS[0], tag_long)
            .with_tag(TAGS[1], tag_short);

        // Sweep batches set `prep.suppress_visuals` so we skip the visual
        // build regardless of `show_vp`. The analyzer chart re-runs the
        // selected combo as a 1-combo batch on click and produces visuals
        // there.
        if self.show_vp && !prep.suppress_visuals {
            out.vp_visuals = self.build_visuals(vp.history());
        }

        out
    }
}

impl VpShelfRejection {
    /// Map flat #[param] fields into VpParams. Mirrors the indicator's surface
    /// for VP construction so a user can pin the same anchor / window /
    /// update / row settings on both for a 1:1 visual comparison.
    ///
    /// Zone-detection knobs the strategy ignores (HVN / LVN / value-area)
    /// still need values for VpParams::build() to succeed - it always runs
    /// full zone classification. We pass through-defaults that match the
    /// indicator's defaults so the shelf detection itself runs identically.
    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(),
            // Defaults that match vp.rs's defaults so shelf
            // detection produces the same shelves both surfaces compute.
            // HVN/LVN/value-area numbers don't gate shelf detection but are
            // needed by VpConfig - leaving them at sensible defaults keeps
            // the snapshot consistent with the indicator's snapshot.
            hvn_threshold: 0.70,
            lvn_threshold: 0.20,
            neighbourhood: 3,
            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: 0.70,
            track_zone_trail: false,
            max_history: None,
        }
    }

    /// Build VpVisualSpecs from the full archived history using the strategy's
    /// own configurable render params. Self-contained (does not call into
    /// build_vp_visuals_from_history) so the strategy gets independent
    /// alignment / style / width control vs. a companion indicator on the
    /// same chart.
    fn build_visuals(&self, history: &[VpSnapshot]) -> Vec<VpVisualSpec> {
        let h_len = history.len();
        if h_len == 0 {
            return Vec::new();
        }
        let n_show = self.render_history_periods.min(h_len);
        let start = h_len.saturating_sub(n_show);
        let align = parse_render_align(&self.render_align);
        let style = parse_render_style(&self.render_style);
        let width_px = self.render_width_px as u32;

        let mut out: Vec<VpVisualSpec> = Vec::with_capacity(n_show);
        for (idx, snap) in history.iter().enumerate().skip(start) {
            if snap.total_volume <= 0.0 {
                continue;
            }
            let end_ts = if idx + 1 < h_len {
                history[idx + 1].anchor_ts
            } else {
                snap.last_ts
            };
            // Forward-shift level lines by one period so they overlay the
            // window the strategy was actually trading against (the
            // last-completed VP feeds decisions in the NEXT period). Lines
            // for snapshot N span [end_ts(N), end_ts(N+1)].
            let next_end = if idx + 2 < h_len {
                history[idx + 2].anchor_ts
            } else if idx + 1 < h_len {
                history[idx + 1].last_ts
            } else {
                snap.last_ts
            };

            // Shelf-only render: synthesize HVN/LVN level lines from the
            // shelf bounds so the renderer paints the shelf top + bottom as
            // discrete level lines. POC / VAH / VAL still draw because the
            // VpVisualSpec carries them, but the user's eye is meant to
            // track the shelf bounds (the strategy's actual decision
            // boundary). If you want a cleaner-only-shelves view, set
            // `show_vp = false` and rely on the bracket markers for entries.
            let shelf_tops: Vec<f64> = snap.shelves.iter().map(|s| s.price_hi).collect();
            let shelf_bots: Vec<f64> = snap.shelves.iter().map(|s| s.price_lo).collect();

            out.push(VpVisualSpec {
                anchor_ts: snap.anchor_ts,
                end_ts,
                price_min: snap.price_min,
                price_step: snap.price_step,
                bins: snap.bins.clone(),
                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: 45,
                width_px,
                align,
                style,
                bin_colors: Vec::new(),
                hvn_levels: shelf_tops,
                lvn_levels: shelf_bots,
                poc_trail: Vec::new(),
                value_area_trail: Vec::new(),
                level_lines_anchor_ts: Some(end_ts),
                level_lines_end_ts: Some(next_end),
            });
        }
        out
    }
}

fn parse_render_style(s: &str) -> VpStyle {
    match s {
        "line" => VpStyle::Line,
        "area" => VpStyle::Area,
        _ => VpStyle::Bars,
    }
}

fn parse_render_align(s: &str) -> HistogramAlign {
    match s {
        "left_of_range" => HistogramAlign::LeftOfRange,
        "over_range" => HistogramAlign::OverRange,
        "pinned_right" => HistogramAlign::PinnedRight,
        "pinned_left" => HistogramAlign::PinnedLeft,
        _ => HistogramAlign::RightOfRange,
    }
}
vp_zone.rsrust353 lines

Volume profile zone trading (HVN / LVN / shelf modes).

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

expand
//! qc-api: 1.0.7
// 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 = "VP Zone Trade",
    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)

cvd.pypython56 lines

Standalone cumulative volume delta with rising/falling tags.

web-examples/indicators/cvd.py

expand
# qc-api: 1.0.7
# 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,
        }
imbalance.pypython67 lines

Standalone bid/ask volume imbalance ratio with pressure tags.

web-examples/indicators/imbalance.py

expand
# qc-api: 1.0.7
# 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.7
# 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,
        }
Raw template sources are also mirrored to /templates/{path} for AI agents to fetch directly. See /llms-symbols.txt for the symbol index.