A modular backtesting framework for equities with built-in machine learning support and strict data validation. Strategies — from a moving-average crossover to XGBoost / Random Forest factor models — are composed from small, independently testable components and executed by a single engine.
QuantLib is a library, not an application. Your factors, models, selectors, and strategies live in your own code; QuantLib only provides the registry, the engine, and the evaluation tools.
Each backtest run consists of five stages:
| Stage | Responsibility |
|---|---|
| DataFeed | Validates required columns, prints available fields, and splits the raw frame into per-symbol Stock entities |
| Factors | @ql.factor computes indicators; ML models score via factor functions (quantlib.utils.check_fields validates input fields) |
| Selectors | @ql.selector filters and truncates the daily universe before the strategy runs |
| Strategies | @ql.strategy executes buy/sell logic on the surviving stocks |
| Analyzer | Plots NAV curves, drawdowns, and trade markers for side-by-side comparison |
Missing data must never be silently backfilled: a 0 in the wrong column can turn bad data into a catastrophic trade. QuantLib therefore validates inputs at three layers and aborts immediately on violation.
| Layer | Validation | On failure |
|---|---|---|
| DataFeed | Required columns Date, Stock, Open, Close present |
ValueError at construction; missing columns printed |
| Model inference | check_fields reports missing / extra columns against training features |
Missing -> ValueError; extra -> warning and ignored |
| Strategy | required_fields declared and checked per strategy |
KeyError, execution blocked |
From the project root:
pip install -e .
pip install xgboost # optional; XGBoost is skipped when unavailableQuantLib consumes a pandas.DataFrame (typically read from CSV or Parquet):
import pandas as pd
df = pd.read_parquet("data/ml_merged.parquet") # must contain Date, Stock, Open, CloseDataFeed prints all available columns on load so you can verify the schema before the run.
Register indicators with @ql.factor. Decorator arguments are forwarded to the function, so one implementation can back several factors:
# my_components.py
from quantlib import ql
@ql.factor("MidPrice")
def mid_price(stock_df):
return (stock_df["Open"] + stock_df["Close"]) / 2.0
@ql.factor("MA_5", column="MidPrice", window=5)
@ql.factor("MA_20", column="MidPrice", window=20)
def moving_average(stock_df, column="Close", window=5):
return stock_df[column].rolling(window=window).mean()Training is intentionally not part of QuantLib. Tree models, CNNs and Transformers each need their own Dataset / loss / epoch loop, and no single template can hold them. Train wherever you like (Jupyter, a standalone script) with sklearn, xgboost, PyTorch, etc., then persist the estimator together with its feature list so the framework can validate inference columns:
# your_script.py -- train with sklearn directly, save in the loadable format
import joblib
from sklearn.ensemble import RandomForestRegressor
rf = RandomForestRegressor(n_estimators=200, max_depth=10, random_state=42, n_jobs=-1)
rf.fit(X_train, y_train)
joblib.dump({'model': rf, 'features': FEATURE_COLS}, 'models/rf_v1.joblib')examples/train_model.py trains both RandomForest and XGBoost this way and
saves the better one as models/best_model.joblib.
Models are not a framework concept: predicting is just what a factor
function does. You load your own model (sklearn, xgboost, PyTorch,
Transformer — anything with a predict), and the framework provides one
optional helper for the repetitive part: field validation.
check_fields is a generic field checker — it tells you which required
fields are missing and which extra fields are present, for any DataFrame /
Series / dict:
from quantlib.utils import check_fields
result = check_fields(stock_df, feats) # missing -> ValueError (default); extra -> warning
print(result.missing, result.extra, result.ok) # FieldCheck(missing=[...], extra=[...])
X = stock_df[feats].fillna(0) # pick columns + fill NaN yourself
scores = model.predict(X) # Booster needs xgb.DMatrix(X)Example — an XGBoost factor with user-side loading:
@ql.factor("AI_Score", model_file="xgb_alpha.ubj", meta_file="xgb_alpha.joblib")
def ai_score(stock_df, model_file, meta_file):
meta = joblib.load(f"models/{meta_file}") # {'features': [...]}
model = xgb.Booster()
model.load_model(f"models/{model_file}")
check_fields(stock_df, meta["features"]) # missing / extra field report
X = stock_df[meta["features"]].fillna(0)
return pd.Series(model.predict(xgb.DMatrix(X)), index=stock_df.index)A generic sklearn-style factor (works with any estimator that has a
predict — joblib-pickled {'model': ..., 'features': [...]}):
@ql.factor("AI_Score", model_file="best_model.joblib")
def ai_score(stock_df, model_file="best_model.joblib"):
meta = joblib.load(f"models/{model_file}") # {'model': est, 'features': [...]}
model, feats = meta["model"], meta["features"]
check_fields(stock_df, feats) # missing / extra field report
X = stock_df[feats].fillna(0)
return pd.Series(model.predict(X), index=stock_df.index)@ql.selector("Top_3_Momentum")
def top_3_momentum(date, snapshot):
ranked = sorted(snapshot.items(), key=lambda x: x[1].get("MA_5", 0), reverse=True)
return dict(ranked[:3]) # only the top names reach the strategyEvery strategy declares the fields it depends on and validates them before trading; a missing field aborts execution.
@ql.strategy("AI_Score_Strategy")
def ai_strategy(date, snapshot, portfolio, close_prices):
required_fields = ["Close", "AI_Score"]
for stock in portfolio.stocks:
if stock not in snapshot:
continue
data = snapshot[stock]
missing = [f for f in required_fields if f not in data]
if missing:
raise KeyError(f"{stock} missing required fields: {missing}")
if data["AI_Score"] > 0.0002:
portfolio.order_target_percent(date, stock, 0.5, data["Close"], close_prices)
elif data["AI_Score"] < -0.0001:
portfolio.order_target_percent(date, stock, 0.0, data["Close"], close_prices)# backtest.py
from quantlib import ql, DataFeed, Portfolio, Engine, Analyzer
import my_components # registers your factors / selectors / strategies
feed = DataFeed(df_raw)
port = Portfolio(initial_cash=1_000_000, fee=0.0001, stocks=stocks)
engine = Engine(data_feed=feed, ql_context=ql)
history = engine.run(
strategy_name="AI_Score_Strategy",
factors=["MidPrice", "MA_5", "MA_20", "MOM_10", "VOL_5", "AI_Score"],
portfolio=port,
start="2025-08-01",
end="2026-07-31",
selector_name="Top_3_Momentum", # optional
)
results = {"AI_Score_Strategy": {"history": history, "trades": port.trades}}
Analyzer.plot_multi_strategies(results, initial_cash=1_000_000)Decider is the live counterpart of Engine: it runs a registered strategy
once for today against a real Portfolio (real positions / cash / stock
pool) and returns the orders the strategy tried to place, instead of executing
them on an exchange. Route the returned list to your broker.
from quantlib import ql, Portfolio, Decider
import my_components # registers your strategies
# real account state
port = Portfolio(initial_cash=1_000_000, fee=0.0001, stocks=stocks)
decider = Decider(ql_context=ql)
# today_snapshot: {stock: {'Close': ..., 'AI_Score': ...}} from your live data feed
orders = decider.generate_today_orders(
strategy_name="AI_Score_Strategy",
portfolio=port,
today_snapshot=today_snapshot,
today_date=today_date,
required_fields=["Close", "AI_Score"], # optional: fail-fast snapshot validation
)
# orders -> brokerexamples/alpha_components.py demonstrates a complete ML-driven setup that reached +76% cumulative return on the 2025-08-01 ~ 2026-07-31 window (equal-weight market benchmark -1.3%, max drawdown -22%):
examples/fe_engine.py— builds 14 cross-sectional z-scored features (momentum / volatility / turnover / size / distance-to-extremes) plus a 10-day forward-return label fromml_merged.parquet.examples/train_alpha.py— trains an XGBoost booster with the nativexgb.trainengine (early stopping on a time-split validation set) and saves it asmodels/xgb_alpha.ubj(+ a{'features': [...]}metadata joblib); theAI_Scorefactor loads it itself.examples/alpha_components.py— registersAI_Score(factor),Top_10_Score(selector), andAI_TopN_Weekly(strategy: rebalance every 7 calendar days, top-10 equal weight, clear the rest).examples/run_backtest_alpha.py— runs the full backtest throughEngineand plots the equity curve.
The strategy rebalances into the top-10 stocks by model score, holding them equally weighted.
For a full step-by-step reproduction (data requirements, feature engineering, training, backtest, and live Decider usage), see examples/ALPHA_GUIDE.md. A short file-by-file index lives in examples/README.md.
Data note:
data/andmodels/are git-ignored (large artifacts). After cloning, prepare your own daily quote parquet (seeexamples/ALPHA_GUIDE.mdfor the required columns) and run the feature / training / backtest scripts.
examples/decider_example.py shows the Decider in action:
python examples/decider_example.py # offline demo using historical data
python examples/decider_example.py --live # try the East Money real-time crawlerquantlib/
├── pyproject.toml # packaging; pip install -e .
├── quantlib/ # the library
│ ├── __init__.py # exports ql, DataFeed, Stock, Portfolio, Engine, Decider, Analyzer, FieldCheck, check_fields
│ ├── context.py # global registry + decorators
│ ├── core/
│ │ ├── data_feed.py # schema validation, field printing, per-stock split
│ │ ├── stock.py # single-stock entity + live snapshot (East Money crawler)
│ │ ├── portfolio.py # cash, positions, costs, drawdown tracking
│ │ ├── engine.py # backtest daily loop: factors -> selection -> strategy -> settlement
│ │ └── decider.py # live one-shot decision: runs a strategy once, returns today's orders
│ └── utils/
│ ├── analyzer.py # NAV / drawdown / trade-marker plots
│ └── fields.py # check_fields / FieldCheck (generic field validator)
├── examples/ # user-side code (index: examples/README.md)
│ ├── basic/ # framework tour: components / training / backtest
│ ├── alpha/ # ML cross-sectional pipeline + ALPHA_GUIDE.md
│ └── live/ # Decider example + East Money diagnostics
├── data/ # input parquet (git-ignored)
├── models/ # persisted models (git-ignored)
└── .gitignore # excludes data/, models/, caches
See examples/ for a complete, runnable setup.