Understanding signals on a price series

A label records what happened after an observation and may inspect a future price path. A signal expresses what a strategy wants to do using only information available at that observation. This tutorial makes that distinction visible by placing point-in-time signal states directly on SPY’s price history.

We use bundled data, transparent moving-average inputs, and no trained model or network calls. The example is explanatory rather than an investment strategy.

1. Import q.signal and plotting dependencies

QRT supplies the data, indicators, signal operations, and base line-chart style. Plotly provides the signal-specific subplots, markers, and regime shading.

import numpy as np
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots

import qrt as q

COLORS = {
    "price": "#1f2937",
    "fast": "#0f8b8d",
    "slow": "#e07a5f",
    "score": "#3d5a80",
    "long": "#16875b",
    "short": "#c43d3d",
    "flat": "#6b7280",
}

2. Load and inspect a price series

The bundled SPY dataset is daily OHLCV data indexed by datetime. We keep the latest two years so transitions remain readable while preserving enough history for the 80-session moving average.

prices = q.data.datasets.load("spy")
prices = prices.loc[prices.index.max() - pd.DateOffset(years=2) :].copy()
close = prices["close"].rename("SPY close")

price_summary = pd.Series(
    {
        "rows": len(prices),
        "start": prices.index.min(),
        "end": prices.index.max(),
        "columns": ", ".join(prices.columns),
        "missing values": int(prices.isna().sum().sum()),
    },
    name="bundled SPY",
)
price_summary
rows                                         502
start                        2024-07-23 00:00:00
end                          2026-07-23 00:00:00
columns           open, high, low, close, volume
missing values                                 0
Name: bundled SPY, dtype: object
figure = q.plot.line(close, title="Bundled SPY closing price", ylabel="Price (USD)")
figure.show(renderer="notebook_connected")

3. Create signals with q.signal

The continuous score compares a 20-session moving average with an 80-session moving average and scales their relative distance by trailing 20-session return volatility. Positive values indicate an upward trend; negative values indicate a downward trend.

We interpret the same score in two ways:

  • threshold changes state whenever the score crosses ±2.
  • hysteresis enters at ±2 but waits for the score to return through ±0.5 before exiting. Separate entry and exit levels reduce rapid state changes near a boundary.
fast_average = q.indicator.sma(close, 20).rename("SMA 20")
slow_average = q.indicator.sma(close, 80).rename("SMA 80")
volatility = close.pct_change(fill_method=None).rolling(20).std()
trend_score = q.signal.as_signal(
    ((fast_average / slow_average) - 1.0).div(volatility).rename("trend score")
)

threshold_intent = q.signal.threshold(
    trend_score,
    long_above=2.0,
    short_below=-2.0,
).rename("threshold intent")
hysteresis_intent = q.signal.hysteresis(
    trend_score,
    long_enter=2.0,
    long_exit=0.5,
    short_enter=-2.0,
    short_exit=-0.5,
).rename("hysteresis intent")

pd.concat(
    [close, fast_average, slow_average, trend_score, threshold_intent, hysteresis_intent],
    axis=1,
).dropna().tail()
SPY close SMA 20 SMA 80 trend score threshold intent hysteresis intent
datetime
2026-07-17 743.289978 745.021005 721.288380 4.266702 1.0 1.0
2026-07-20 742.090027 744.788507 722.420736 4.216013 1.0 1.0
2026-07-21 748.280029 744.983008 723.585084 3.914382 1.0 1.0
2026-07-22 747.409973 745.674506 724.884804 4.267241 1.0 1.0
2026-07-23 738.179993 745.921506 726.206296 3.694040 1.0 1.0

4. Align signals with price data

The slow moving average creates an intentional warm-up period. We preserve those missing values rather than filling them with a fabricated position. q.signal.delay then moves intent forward by one observation: a score computed from day t’s close becomes available on day t + 1.

The final pipeline also requires five observations between directional state changes, keeps three flat observations after an exit, maps direction to a maximum 30% desired exposure, and limits each daily exposure change to 10 percentage points.

available_intent = q.signal.delay(hysteresis_intent).rename("available intent")
held_intent = q.signal.hold(available_intent, periods=5)
disciplined_intent = q.signal.cooldown(held_intent, periods=3).rename("disciplined intent")
desired_exposure = q.signal.target_exposure(
    disciplined_intent,
    maximum=0.30,
).rename("desired exposure")
exposure = q.signal.limit_turnover(
    desired_exposure,
    max_change=0.10,
).rename("turnover-limited exposure")

signal_data = pd.concat(
    [
        close,
        fast_average,
        slow_average,
        trend_score,
        threshold_intent,
        hysteresis_intent,
        available_intent,
        disciplined_intent,
        desired_exposure,
        exposure,
    ],
    axis=1,
)

pd.Series(
    {
        "warm-up rows": int(trend_score.isna().sum()),
        "threshold transitions": int(threshold_intent.diff().fillna(0).ne(0).sum()),
        "hysteresis transitions": int(hysteresis_intent.diff().fillna(0).ne(0).sum()),
        "first score": trend_score.first_valid_index(),
        "first available intent": available_intent.first_valid_index(),
    },
    name="signal alignment",
)
warm-up rows                               79
threshold transitions                      20
hysteresis transitions                      8
first score               2024-11-12 00:00:00
first available intent    2024-11-13 00:00:00
Name: signal alignment, dtype: object

5. Overlay continuous signals on the price series

The moving averages remain in price units, so they belong on the price axis. The volatility-scaled score uses its own axis below. Horizontal lines expose the exact entry and exit thresholds used by hysteresis.

figure = make_subplots(
    rows=2,
    cols=1,
    shared_xaxes=True,
    row_heights=[0.68, 0.32],
    vertical_spacing=0.06,
    subplot_titles=("Price and point-in-time inputs", "Volatility-scaled trend score"),
)
figure.add_trace(
    go.Scatter(x=close.index, y=close, name="SPY close", line={"color": COLORS["price"], "width": 2}),
    row=1,
    col=1,
)
figure.add_trace(
    go.Scatter(x=fast_average.index, y=fast_average, name="SMA 20", line={"color": COLORS["fast"]}),
    row=1,
    col=1,
)
figure.add_trace(
    go.Scatter(x=slow_average.index, y=slow_average, name="SMA 80", line={"color": COLORS["slow"]}),
    row=1,
    col=1,
)
figure.add_trace(
    go.Scatter(x=trend_score.index, y=trend_score, name="Trend score", line={"color": COLORS["score"]}),
    row=2,
    col=1,
)
for level, color, dash in [
    (2.0, COLORS["long"], "dash"),
    (0.5, COLORS["flat"], "dot"),
    (-0.5, COLORS["flat"], "dot"),
    (-2.0, COLORS["short"], "dash"),
]:
    figure.add_hline(y=level, line={"color": color, "dash": dash, "width": 1}, row=2, col=1)
figure.update_yaxes(title_text="Price (USD)", row=1, col=1)
figure.update_yaxes(title_text="Score", row=2, col=1)
figure.update_layout(
    title="SPY inputs and signal thresholds",
    height=720,
    hovermode="x unified",
    legend={"orientation": "h", "y": 1.08},
)
figure.show(renderer="notebook_connected")

6. Plot entry and exit signals as markers

A signal state persists across rows; an entry or exit is a transition between states. Open circles show when hysteresis first changes its point-in-time intent. Triangles and crosses show the delayed state that is available on the following row. This one-row separation is the causal timing policy made visible.

immediate_previous = hysteresis_intent.shift(1).fillna(0.0)
immediate_change = hysteresis_intent.notna() & hysteresis_intent.ne(immediate_previous)

available_previous = available_intent.shift(1).fillna(0.0)
long_entries = available_intent.eq(1.0) & available_previous.ne(1.0)
short_entries = available_intent.eq(-1.0) & available_previous.ne(-1.0)
exits = available_intent.eq(0.0) & available_previous.ne(0.0)

figure = q.plot.line(close, title="SPY with observation-time and available signal transitions", ylabel="Price (USD)")
figure.add_trace(
    go.Scatter(
        x=close.index[immediate_change],
        y=close.loc[immediate_change],
        mode="markers",
        name="Intent changes at close",
        marker={"symbol": "circle-open", "size": 12, "color": COLORS["flat"], "line": {"width": 2}},
    )
)
for mask, name, symbol, color in [
    (long_entries, "Available long entry", "triangle-up", COLORS["long"]),
    (short_entries, "Available short entry", "triangle-down", COLORS["short"]),
    (exits, "Available exit", "x", COLORS["flat"]),
]:
    figure.add_trace(
        go.Scatter(
            x=close.index[mask],
            y=close.loc[mask],
            mode="markers",
            name=name,
            marker={"symbol": symbol, "size": 11, "color": color},
        )
    )
figure.update_layout(height=560, hovermode="x unified")
figure.show(renderer="notebook_connected")

7. Compare multiple signal configurations

Lower entry thresholds react sooner but accept weaker trends. Higher thresholds wait for stronger evidence and spend more time flat. Each panel applies symmetric long/short hysteresis with a narrower exit threshold.

configurations = {
    "Responsive: enter ±1.0, exit ±0.2": (1.0, 0.2),
    "Balanced: enter ±2.0, exit ±0.5": (2.0, 0.5),
    "Selective: enter ±3.0, exit ±1.0": (3.0, 1.0),
}
configuration_states = {
    name: q.signal.hysteresis(
        values=trend_score,
        long_enter=entry,
        long_exit=exit_level,
        short_enter=-entry,
        short_exit=-exit_level,
    )
    for name, (entry, exit_level) in configurations.items()
}

figure = make_subplots(
    rows=len(configuration_states),
    cols=1,
    shared_xaxes=True,
    vertical_spacing=0.05,
    subplot_titles=tuple(configuration_states),
)
for row_number, (name, state) in enumerate(configuration_states.items(), start=1):
    figure.add_trace(
        trace=go.Scatter(x=close.index, y=close, name="SPY close", line={"color": COLORS["price"], "width": 1.5}, showlegend=row_number == 1),
        row=row_number,
        col=1,
    )
    previous = state.shift(1).fillna(0.0)
    for mask, label, symbol, color in [
        (state.eq(1.0) & previous.ne(1.0), "Long", "triangle-up", COLORS["long"]),
        (state.eq(-1.0) & previous.ne(-1.0), "Short", "triangle-down", COLORS["short"]),
        (state.eq(0.0) & previous.ne(0.0), "Exit", "x", COLORS["flat"]),
    ]:
        figure.add_trace(
            trace=go.Scatter(
                x=close.index[mask],
                y=close.loc[mask],
                mode="markers",
                name=label,
                marker={"symbol": symbol, "size": 8, "color": color},
                legendgroup=label,
                showlegend=row_number == 1,
            ),
            row=row_number,
            col=1,
        )
    figure.update_yaxes(title_text="USD", row=row_number, col=1)
figure.update_layout(
    title="How hysteresis parameters change signal timing",
    height=900,
    hovermode="x unified",
    legend={"orientation": "h", "y": 1.05},
)
figure.show(renderer="notebook_connected")

8. Inspect signal behavior over a selected time window

The helper below combines the final artifacts in one view:

  • green, gray, and red bands identify positive, flat, and negative exposure regimes;
  • price and moving averages show the observations behind the score;
  • markers identify delayed long entries, short entries, and exits;
  • the dashed secondary-axis line shows how limit_turnover moves exposure in 10-point steps rather than jumping immediately to ±30%.

The selected interval includes a move from flat to short, back to flat, and then long.

def plot_signal_window(start, end):
    window = signal_data.loc[start:end]
    figure = make_subplots(specs=[[{"secondary_y": True}]])

    regimes = np.sign(window["turnover-limited exposure"].fillna(0.0))
    groups = regimes.ne(regimes.shift()).cumsum()
    regime_colors = {-1.0: COLORS["short"], 0.0: COLORS["flat"], 1.0: COLORS["long"]}
    for _, regime in regimes.groupby(groups):
        figure.add_vrect(
            x0=regime.index[0],
            x1=regime.index[-1],
            fillcolor=regime_colors[float(regime.iloc[0])],
            opacity=0.10,
            line_width=0,
            layer="below",
        )

    for values, name, color, width in [
        (close, "SPY close", COLORS["price"], 2.2),
        (fast_average, "SMA 20", COLORS["fast"], 1.3),
        (slow_average, "SMA 80", COLORS["slow"], 1.3),
    ]:
        selected = values.loc[window.index]
        figure.add_trace(
            go.Scatter(x=selected.index, y=selected, name=name, line={"color": color, "width": width}),
            secondary_y=False,
        )

    for mask, name, symbol, color in [
        (long_entries, "Available long entry", "triangle-up", COLORS["long"]),
        (short_entries, "Available short entry", "triangle-down", COLORS["short"]),
        (exits, "Available exit", "x", COLORS["flat"]),
    ]:
        selected_mask = mask.reindex(window.index, fill_value=False)
        figure.add_trace(
            go.Scatter(
                x=window.index[selected_mask],
                y=close.loc[window.index[selected_mask]],
                mode="markers",
                name=name,
                marker={"symbol": symbol, "size": 11, "color": color},
            ),
            secondary_y=False,
        )

    figure.add_trace(
        trace=go.Scatter(
            x=window.index,
            y=window["turnover-limited exposure"],
            name="Exposure",
            line={"color": "#7c3aed", "width": 2, "dash": "dash"},
        ),
        secondary_y=True,
    )
    figure.update_yaxes(title_text="Price (USD)", secondary_y=False)
    figure.update_yaxes(title_text="Exposure", range=[-0.35, 0.35], tickformat=".0%", secondary_y=True)
    figure.update_layout(
        title=f"Signal mechanics from {pd.Timestamp(start):%Y-%m-%d} to {pd.Timestamp(end):%Y-%m-%d}",
        height=600,
        hovermode="x unified",
        legend={"orientation": "h", "y": 1.08},
    )
    return figure


figure = plot_signal_window("2025-02-01", "2025-06-30")
figure.show(renderer="notebook_connected")

9. Validate transitions and missing values

Visual inspection is useful, but the notebook also makes its timing claims executable. These assertions verify exact index alignment, directional domains, the moving-average warm-up, the one-row delay, complete transition classification, and bounded turnover-limited exposure.

assert signal_data.index.equals(close.index)
assert set(hysteresis_intent.dropna().unique()) <= {-1.0, 0.0, 1.0}
assert trend_score.iloc[:79].isna().all()
assert trend_score.iloc[79:].notna().any()
pd.testing.assert_series_equal(
    available_intent,
    hysteresis_intent.shift(1).rename("available intent"),
)

available_changes = available_intent.notna() & available_intent.ne(available_previous)
classified_changes = long_entries | short_entries | exits
pd.testing.assert_series_equal(available_changes, classified_changes)

assert exposure.dropna().abs().le(0.30).all()
assert exposure.diff().dropna().abs().le(0.10 + 1e-12).all()

pd.Series(
    {
        "aligned rows": len(signal_data),
        "warm-up rows preserved": int(trend_score.isna().sum()),
        "classified delayed transitions": int(classified_changes.sum()),
        "maximum absolute exposure": float(exposure.abs().max()),
        "maximum one-row exposure change": float(exposure.diff().abs().max()),
    },
    name="all checks passed",
)
aligned rows                       502.0
warm-up rows preserved              79.0
classified delayed transitions       9.0
maximum absolute exposure            0.3
maximum one-row exposure change      0.1
Name: all checks passed, dtype: float64

What to carry forward

A continuous measurement becomes a deployable signal only after its interpretation and timing policy are explicit. Here, hysteresis controls when intent changes, delay controls when that intent is available, holding and cooldown control state persistence, and turnover limiting controls how quickly desired exposure can move.

None of these operations evaluates whether the subsequent trade was profitable. That future-aware question belongs to q.label during model training or to q.stats after a backtest.

Back to top