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.

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"

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"])
model
Pipeline(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.

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()
Back to top