Pull historic candles with the Public Python SDK, load them into a Pandas DataFrame, and run a moving-average crossover strategy through the backtesting.py framework.
The Public API offers historic market data. This includes historic data for equities and options contracts. Historic market data is the raw material for basically everything you do as an algo trader. This data can be useful for API/algo traders for a few reasons:
In this article we'll pull historic candles with the Public Python SDK, load them into a Pandas DataFrame, and run a moving-average crossover strategy through the simple to use backtesting.py framework. By the end you'll have a working backtest you can point at any symbol.
One thing up front: this post is about wiring, not alpha. The strategy we will build and test here is a textbook, simple test on a small sample of data. Treat the results as a proof of a pipeline and not as evidence that the strategy works. We'll come back to reviewing results at the end.
Here's a few things you'll need to get started:
# Create project folder and cd into it
mkdir public-market-data
cd public-market-data
# Create python env
python3 -m venv .venv
source .venv/bin/activate
# Install public SDK
pip install publicdotcom-py python-dotenv pandas backtesting TA-Lib
# Create a file we will work in
touch main.pyOne note on that install line:
๐ก Before you install
TA-Lib is a Python wrapper around a C library, and pip install TA-Libwill fail if that C library isn't on your system. On macOS, brew install ta-lib first. On Debian/Ubuntu, install ta-libfrom your package manager or build from source. On Windows, grab a prebuilt wheel. If you'd rather skip the hassle entirely, see the pure-Pandas alternative at the end โ a simple moving average doesn't actually need TA-Lib.
Now create a .env file next to main.py:
PUBLIC_API_SECRET_KEY=your_secret_key_here
PUBLIC_DEFAULT_ACCOUNT_NUMBER=your_account_number_hereThe PublicApiClientis the single object you'll use to talk to your Public account. This includes getting quotes, historic data, orders, positions and more.
import os
from dotenv import load_dotenv
from public_api_sdk import (
ApiKeyAuthConfig,
InstrumentType,
OrderInstrument,
PublicApiClient,
PublicApiClientConfiguration,
)
# Load env variables
load_dotenv()
PUBLIC_API_SECRET_KEY: str = os.getenv("PUBLIC_API_SECRET_KEY") or ""
PUBLIC_DEFAULT_ACCOUNT_NUMBER: str = os.getenv("PUBLIC_DEFAULT_ACCOUNT_NUMBER") or ""
# Initialize the client
client = PublicApiClient(
ApiKeyAuthConfig(api_secret_key=PUBLIC_API_SECRET_KEY),
config=PublicApiClientConfiguration(
default_account_number=PUBLIC_DEFAULT_ACCOUNT_NUMBER
),
)Before going further, pull a couple of quotes to confirm your key and account number are wired up correctly. If this block prints prices, everything downstream will work.
quotes = client.get_quotes(
[
OrderInstrument(symbol="SPY", type=InstrumentType.EQUITY),
OrderInstrument(symbol="QQQ", type=InstrumentType.EQUITY),
]
)
for quote in quotes:
print(
f"{quote.instrument.symbol}: "
f"last=${quote.last}, "
f"bid=${quote.bid}, "
f"ask=${quote.ask}, "
f"volume={quote.volume:,}"
)Running this should print something like this. Note that your results will vary since this is live market data:
SPY: last=$741.95, bid=$741.96, ask=$742.04, volume=65,363,758
QQQ: last=$685.94, bid=$685.97, ask=$686.0599, volume=64,749,452๐ก Source code
Source code for the Public Python SDK can be found on GitHub: github.com/PublicDotCom/publicdotcom-py
The Public API lets you get historic OHLCV candlestick data for supported equities and options contracts.
from public_api_sdk import BarAggregation, BarPeriod
bars = client.get_bars(
symbol="SPY",
period=BarPeriod.WEEK,
aggregation=BarAggregation.FIVE_MINUTES,
)Note that not every combination is valid at this moment. For example, you can't request 1 minute candles going back 10 years.
Here is a list of valid pairings:
BarPeriod | BarAggregation |
|---|---|
| DAY, WEEK, MONTH | ONE_MINUTE โฆ ONE_HOUR (intraday) |
| QUARTER, HALF_YEAR, YTD, YEAR | ONE_HOUR, ONE_DAY, ONE_WEEK |
| FIVE_YEARS, TEN_YEARS, ALL | ONE_DAY and coarser |
The response splits bars by trading session. bars.regular_market.bars gives you 9:30โ16:00 ET; pre-market and after-hours are returned separately.
For most backtests you want regular hours only. Extended-hours sessions are thin, spreads are wide, and fills there are far less realistic than the backtester will assume. Mixing them in makes your results look better than they are.
regular_bars = bars.regular_market.bars
print(
f"{bars.symbol} {bars.period}: "
f"{len(regular_bars)} bars, "
f"previous close=${bars.previous_close_price}, "
f"gain/loss=${bars.total_gain_loss} "
f"({bars.total_gain_loss_percentage}%)"
)
# Print the first 5 bars as compact OHLCV rows
for bar in regular_bars[:5]:
print(
f"{bar.timestamp} | "
f"O={bar.open} H={bar.high} L={bar.low} C={bar.close} | "
f"V={bar.volume:,.0f}"
)SPY WEEK: 378 bars, previous close=$738.18, gain/loss=$3.53 (0.4800%)
2026-07-29T09:30:00-04:00 | O=739.97 H=740.39 L=738.72 C=740.03 | V=981,335
2026-07-29T09:35:00-04:00 | O=740.01 H=740.24 L=739.17 C=739.90 | V=350,034
2026-07-29T09:40:00-04:00 | O=739.89 H=740.04 L=739.32 C=739.68 | V=387,061
2026-07-29T09:45:00-04:00 | O=739.66 H=740.22 L=738.88 C=739.15 | V=483,067
2026-07-29T09:50:00-04:00 | O=739.16 H=739.21 L=737.43 C=737.81 | V=571,793Each bar carries a timezone-aware timestamp plus open, high, low, close, and volume โ everything a backtester needs.
backtesting.py, like most Python backtesting frameworks, expects a Pandas DataFrame with a DatetimeIndex.
There's one detail that will bite you if you miss it: backtesting.py requires the columns to be named Open, High, Low, Close, and Volume, capitalized. Lowercase names raise an error.
First the data needs to be converted to a Pandas DataFrame:
import pandas as pd
df = pd.DataFrame(
{
"Open": [bar.open for bar in regular_bars],
"High": [bar.high for bar in regular_bars],
"Low": [bar.low for bar in regular_bars],
"Close": [bar.close for bar in regular_bars],
"Volume": [bar.volume for bar in regular_bars],
},
index=pd.DatetimeIndex(
pd.to_datetime([bar.timestamp for bar in regular_bars]), name="timestamp"
),
).astype(float)
print(df) Open High Low Close Volume
timestamp
2026-07-29 09:30:00-04:00 739.97 740.39 738.72 740.03 981334.912959
2026-07-29 09:35:00-04:00 740.01 740.24 739.17 739.90 350034.114040
2026-07-29 09:40:00-04:00 739.89 740.04 739.32 739.68 387060.912612
2026-07-29 09:45:00-04:00 739.66 740.22 738.88 739.15 483067.261000
2026-07-29 09:50:00-04:00 739.16 739.21 737.43 737.81 571793.000000
... ... ... ... ... ...
2026-08-04 14:15:00-04:00 772.20 772.37 771.98 772.11 300751.708942
2026-08-04 14:20:00-04:00 772.12 772.45 772.06 772.13 300828.000000
2026-08-04 14:25:00-04:00 772.12 772.38 772.01 772.35 171338.000000
2026-08-04 14:30:00-04:00 772.38 772.48 772.13 772.17 174269.000000
2026-08-04 14:35:00-04:00 772.16 772.32 771.83 771.88 161095.145671
[378 rows x 5 columns]The .astype(float) matters. Some fields come back as strings or Decimal, and TA-Lib will refuse to work with them.
This DataFrame is now portable. backtesting.pyis what we're using here, but the same object drops straight into vectorbt, Backtrader, or your own loop.
We'll use a moving average crossover, the "hello world" of systematic trading. Two simple moving averages, one fast (9 periods) and one slow (21). When the fast MA crosses above the slow MA, momentum is turning up: go long. When it crosses below, flip short.
In backtesting.py a strategy is a class with two methods:
init() runs once before the backtest. Compute your indicators here. Wrapping them in self.I(...)registers them with the framework so they're plotted and correctly aligned to each bar, which leads to no lookahead.next() runs once per bar, and only ever sees data up to that bar. This is what keeps you honest: you cannot accidentally peek at the future.This is how you set up a Strategy class in backtesting.py:
import talib
from backtesting import Backtest, Strategy
from backtesting.lib import crossover
class SmaCross(Strategy):
def init(self):
price = self.data.Close
self.ma1 = self.I(talib.SMA, price, timeperiod=9)
self.ma2 = self.I(talib.SMA, price, timeperiod=21)
def next(self):
if crossover(self.ma1, self.ma2):
self.buy()
elif crossover(self.ma2, self.ma1):
self.sell()crossover(a, b) returns True only on the bar where a crosses from below bto above it โ not on every bar it stays above. That's what makes this an event, not a state.
Worth being precise, because it's easy to misread. self.sell() in backtesting.py opens a short positionโ it is not "close my long." Combined with exclusive_orders=True (which closes any existing position before opening a new one), this is a stop-and-reverse system: always in the market, flipping between long and short on every crossover.
If you'd rather go long-only โ closing to cash instead of shorting โ change one line:
def next(self):
if crossover(self.ma1, self.ma2):
self.buy()
elif crossover(self.ma2, self.ma1):
self.position.close() # exit to cash instead of going shortRun both. Comparing them tells you how much of the strategy's performance came from the short side, which is a useful thing to know.
bt = Backtest(df, SmaCross, commission=0, exclusive_orders=True)
stats = bt.run()
print(stats)
bt.plot()bt.run() returns a stats object; bt.plot() opens an interactive chart in your browser showing the equity curve, the two moving averages, and every trade marked on the price series.
Start 2026-07-29 09:30:00-04:00
End 2026-08-04 14:35:00-04:00
Duration 6 days 05:05:00
Exposure Time [%] 94.44
Equity Final [$] 10,412.33
Equity Peak [$] 10,533.91
Return [%] 4.12
Buy & Hold Return [%] 4.31
Return (Ann.) [%] 571.24
Volatility (Ann.) [%] 118.06
Sharpe Ratio 4.84
Sortino Ratio 18.02
Calmar Ratio 92.31
Max. Drawdown [%] -6.19
Avg. Drawdown [%] -0.87
Max. Drawdown Duration 0 days 06:20:00
# Trades 21
Win Rate [%] 38.10
Best Trade [%] 1.94
Worst Trade [%] -0.71
Avg. Trade [%] 0.19
Profit Factor 1.43
Expectancy [%] 0.20
SQN 0.92Replace the block above with your own output โ numbers will differ by symbol, date range, and market conditions.
A handful of lines carry most of the signal:
Return (Ann.) entirely here.The pipeline works. The result doesn't mean anything yet. Four reasons:
The sample is tiny.One week of five-minute bars is 378 candles and ~21 trades. That's not enough to distinguish a real edge from luck. Rerun with BarPeriod.YEAR and BarAggregation.ONE_DAYand you'll get years of daily bars and a far more meaningful trade count.
commission=0 is a fiction. No slippage, no spread, no fees. At 21 trades per week, transaction costs are not a rounding error โ they're often the difference between a profitable backtest and an unprofitable strategy. Set commission=0.0002 and spread= to something realistic and watch the numbers move.
Fills are optimistic.The backtester assumes you get filled at the next bar's open. In live trading, on a fast move, you often don't.
Overfitting is the real danger.It is trivially easy to try 9/21, then 10/30, then 12/26, and pick whichever produced the best return. That is not research; that's curve-fitting to a specific week of price history, and it will not survive contact with live markets. If you tune parameters, tune them on one period and validate on a different period the strategy has never seen.
Pull more history. One parameter change:
bars = client.get_bars(
symbol="SPY",
period=BarPeriod.FIVE_YEARS,
aggregation=BarAggregation.ONE_DAY,
)Backtest options contracts. get_bars also accepts option instruments, so you can pull historic candles for a specific contract by its OSI symbol and study how premium behaved into expiration.
Use history to warm up a live strategy.This is the payoff mentioned at the top. Fetch enough historic bars to fill your indicator's lookback window, seed your strategy with them, then switch to live quotes. Your bot produces valid signals from the first tick instead of the two-hundredth.
Optimize carefully. backtesting.py has bt.optimize()for parameter sweeps. It's a sharp tool โ see the overfitting warning above before you reach for it.
If TA-Lib's C dependency is giving you trouble, a simple moving average is one line of Pandas. Drop the import talib and swap the init method:
def sma(series, n):
return pd.Series(series).rolling(n).mean()
class SmaCross(Strategy):
def init(self):
price = self.data.Close
self.ma1 = self.I(sma, price, 9)
self.ma2 = self.I(sma, price, 21)Same result, one less dependency. TA-Lib earns its place when you move on to indicators that are genuinely fiddly to implement โ ATR, ADX, MACD โ but you don't need it on day one.
backtesting.py docs: kernc.github.io/backtesting.py