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",
}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.
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_summaryrows 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:
thresholdchanges state whenever the score crosses ±2.hysteresisenters 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_turnovermoves 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.