import numpy as np
import pandas as pd
import plotly.io as pio
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import log_loss
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
import qrt as q
pio.renderers.default = "notebook_connected"AAPL classification from SPY regimes
This example uses homegrown q.indicator functions to turn SPY trend into predefined short, hold, and long targets. A scikit-learn model then learns to predict the next session’s SPY regime from AAPL market features.
The target is an interpretable indicator rule rather than a claim about future returns. Treat the workflow as an evaluation template, not as a trading strategy.
1. Get data
Load the bundled AAPL and SPY histories and align their trading sessions. The model features describe AAPL using returns, trend, volatility, range, volume, and AAPL’s relative strength against SPY.
aapl = q.data.datasets.load("aapl")
spy = q.data.datasets.load("spy")
common_sessions = aapl.index.intersection(spy.index)
aapl = aapl.loc[common_sessions]
spy = spy.loc[common_sessions]
aapl_returns = aapl["close"].pct_change()
relative_strength = q.indicator.relative_strength(
aapl["close"], spy["close"], lookback=20
)
relative_strength_average = q.indicator.rsma(relative_strength, window=20)
relative_strength_phase = q.indicator.rs_phase(
relative_strength, relative_strength_average
)
features = pd.DataFrame(
{
"return_1d": aapl_returns,
"momentum_5d": aapl["close"].pct_change(5),
"momentum_20d": aapl["close"].pct_change(20),
"close_vs_sma_20d": aapl["close"].div(
q.indicator.sma(aapl["close"], 20)
).sub(1),
"ema_20d_vs_sma_100d": q.indicator.ema(aapl["close"], 20).div(
q.indicator.sma(aapl["close"], 100)
).sub(1),
"relative_strength_20d": relative_strength,
"rsma_20d": relative_strength_average,
"rs_phase": relative_strength_phase["rs_phase"].astype(int),
"rs_phase_days": relative_strength_phase["rs_phase_days"],
"rs_days_in_correction_60d": q.indicator.rs_days(
relative_strength, spy["close"], window=60
),
"rs_new_high_before_price_60d": q.indicator.rsnhbp(
aapl["close"], relative_strength, window=60
).astype(int),
"volatility_20d": aapl_returns.rolling(20).std(),
"range_1d": (aapl["high"] - aapl["low"]) / aapl["close"],
"volume_change_5d": aapl["volume"].pct_change(5),
}
).replace([np.inf, -np.inf], np.nan).dropna()
features.tail()| return_1d | momentum_5d | momentum_20d | close_vs_sma_20d | ema_20d_vs_sma_100d | relative_strength_20d | rsma_20d | rs_phase | rs_phase_days | rs_days_in_correction_60d | rs_new_high_before_price_60d | volatility_20d | range_1d | volume_change_5d | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| datetime | ||||||||||||||
| 2026-07-17 | 0.001440 | 0.058417 | 0.127690 | 0.092374 | 0.104660 | 0.121962 | 0.037196 | 1 | 11 | 0.0 | 0 | 0.022858 | 0.017948 | 0.857686 |
| 2026-07-20 | -0.021424 | 0.029246 | 0.095903 | 0.063995 | 0.107562 | 0.102130 | 0.043380 | 1 | 12 | 0.0 | 0 | 0.023679 | 0.030711 | 0.236031 |
| 2026-07-21 | 0.003521 | 0.040907 | 0.103464 | 0.062423 | 0.110397 | 0.098239 | 0.048605 | 1 | 13 | 0.0 | 0 | 0.023603 | 0.022518 | 0.137659 |
| 2026-07-22 | -0.005645 | -0.004916 | 0.107340 | 0.051045 | 0.112154 | 0.088487 | 0.052403 | 1 | 14 | 0.0 | 0 | 0.023505 | 0.017368 | -0.364215 |
| 2026-07-23 | -0.012980 | -0.034808 | 0.097516 | 0.032643 | 0.111944 | 0.090779 | 0.056058 | 1 | 15 | 0.0 | 0 | 0.023775 | 0.012280 | -0.351431 |
2. Split chronologically
Use the oldest 60% of AAPL observations for training, the next 20% for validation, and the newest 20% for testing. One session is purged before each boundary because every feature row will receive the following session’s regime label.
train_boundary = int(len(features) * 0.60)
validation_boundary = int(len(features) * 0.80)
X = {
"train": features.iloc[: train_boundary - 1],
"validation": features.iloc[train_boundary : validation_boundary - 1],
"test": features.iloc[validation_boundary:-1],
}
pd.DataFrame(
{
name: {
"start": frame.index.min().date(),
"end": frame.index.max().date(),
"observations": len(frame),
}
for name, frame in X.items()
}
).T| start | end | observations | |
|---|---|---|---|
| train | 2000-05-24 | 2016-02-01 | 3946 |
| validation | 2016-02-03 | 2021-04-23 | 1315 |
| test | 2021-04-27 | 2026-07-22 | 1315 |
3. Label with SPY indicators
Define the target from SPY’s q.indicator.ema(20) relative to its q.indicator.sma(100). A spread above 2% is long, below -2% is short, and the neutral band is hold. These fixed rules are applied unchanged to train, validation, and test.
classes = ["short", "hold", "long"]
regime_band = 0.02
spy_trend_spread = q.indicator.ema(spy["close"], 20).div(
q.indicator.sma(spy["close"], 100)
).sub(1)
spy_regime = pd.Series(
np.select(
[spy_trend_spread < -regime_band, spy_trend_spread > regime_band],
["short", "long"],
default="hold",
),
index=spy.index,
name="position",
)
next_session_regime = spy_regime.shift(-1)
y = {
name: next_session_regime.loc[frame.index]
for name, frame in X.items()
}
pd.DataFrame(
{name: labels.value_counts(normalize=True) for name, labels in y.items()}
).T.loc[:, classes].style.format("{:.1%}")| position | short | hold | long |
|---|---|---|---|
| train | 20.5% | 35.6% | 44.0% |
| validation | 10.6% | 22.3% | 67.1% |
| test | 15.1% | 21.6% | 63.3% |
4. Train with scikit-learn
Build a scikit-learn pipeline that standardizes the AAPL features and fits multinomial logistic regression. Keeping preprocessing inside the pipeline ensures its statistics are learned from training data only.
def make_model(c_value):
return make_pipeline(
StandardScaler(),
LogisticRegression(C=c_value, max_iter=2_000),
)
model = make_model(c_value=1.0)
model.fit(X["train"], y["train"])
modelPipeline(steps=[('standardscaler', StandardScaler()),
('logisticregression', LogisticRegression(max_iter=2000))])In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
Fitted attributes
| Name | Type | Value |
|---|---|---|
| classes_ classes_: ndarray of shape (n_classes,) The classes labels. Only exist if the last step of the pipeline is a classifier. |
ndarray[object](3,) | ['hold','long','short'] |
| feature_names_in_ feature_names_in_: ndarray of shape (`n_features_in_`,) Names of features seen during :term:`fit`. Only defined if the underlying estimator exposes such an attribute when fit. .. versionadded:: 1.0 |
ndarray[object](14,) | ['return_1d','momentum_5d','momentum_20d',...,'volatility_20d','range_1d', 'volume_change_5d'] |
| n_features_in_ n_features_in_: int Number of features seen during :term:`fit`. Only defined if the underlying first estimator in `steps` exposes such an attribute when fit. .. versionadded:: 0.24 |
int | 14 |
Parameters
Fitted attributes
14 features
| return_1d |
| momentum_5d |
| momentum_20d |
| close_vs_sma_20d |
| ema_20d_vs_sma_100d |
| relative_strength_20d |
| rsma_20d |
| rs_phase |
| rs_phase_days |
| rs_days_in_correction_60d |
| rs_new_high_before_price_60d |
| volatility_20d |
| range_1d |
| volume_change_5d |
Parameters
Fitted attributes
5. Validate
Fit a small inverse-regularization grid and select the value with the lowest scikit-learn log loss on validation probabilities. The test partition remains untouched during this choice.
models = {}
validation_rows = []
for c_value in [0.01, 0.1, 1.0, 10.0, 100.0]:
candidate = make_model(c_value)
candidate.fit(X["train"], y["train"])
validation_score = pd.DataFrame(
candidate.predict_proba(X["validation"]),
index=X["validation"].index,
columns=candidate.classes_,
)
models[c_value] = candidate
validation_rows.append(
{
"C": c_value,
"log_loss": log_loss(
y["validation"],
validation_score,
labels=validation_score.columns,
),
}
)
validation_results = pd.DataFrame(validation_rows)
best_c = validation_results.loc[validation_results["log_loss"].idxmin(), "C"]
model = models[best_c]
validation_results.style.format({"C": "{:.3g}", "log_loss": "{:.3f}"})| C | log_loss | |
|---|---|---|
| 0 | 0.01 | 0.588 |
| 1 | 0.1 | 0.518 |
| 2 | 1 | 0.508 |
| 3 | 10 | 0.508 |
| 4 | 100 | 0.508 |
6. Test
Call predict_proba once on the held-out AAPL test period, then pass the resulting class probabilities to q.stats. The tidy one-vs-rest curves remain available independently of Plotly.
y_true = y["test"]
y_score = pd.DataFrame(
model.predict_proba(X["test"]),
index=X["test"].index,
columns=model.classes_,
).loc[:, classes]
roc_curves = q.stats.multiclass_roc_curve(y_true, y_score)
pr_curves = q.stats.multiclass_precision_recall_curve(y_true, y_score)
roc_summary = roc_curves.assign(
label=lambda frame: frame["class"].fillna(frame["curve"])
).groupby("label", sort=False)["auc"].first()
pr_summary = pr_curves.assign(
label=lambda frame: frame["class"].fillna(frame["curve"])
).groupby("label", sort=False)["average_precision"].first()
pd.concat(
[roc_summary.rename("roc_auc"), pr_summary.rename("average_precision")],
axis=1,
).style.format("{:.3f}")| roc_auc | average_precision | |
|---|---|---|
| label | ||
| short | 0.969 | 0.826 |
| hold | 0.825 | 0.555 |
| long | 0.937 | 0.953 |
| micro | 0.932 | 0.877 |
| macro | 0.910 | 0.778 |
ROC curve
Each solid line treats one SPY regime as positive and the other two as negative. AUC measures how well the AAPL model ranks that regime across every decision threshold.
q.plot.roc(
y_true,
y_score,
title="AAPL model of next-session SPY regime: ROC",
).show()Precision-recall curve
Average precision summarizes precision across recall levels. This view makes performance on the less frequent short and hold regimes easier to judge than ROC alone.
q.plot.precision_recall(
y_true,
y_score,
title="AAPL model of next-session SPY regime: precision-recall",
).show()