API

Get Started

WelcomeQuickstartGuidesChangelogSDKs and ToolsAdditional Info

Authorization

POSTCreate personal access token

List Accounts

GETGet accounts

Account Details

GETGet account portfolio v2GETGet history

Tax Lot Selling

GETGet unrealized tax lotsGETGet unrealized tax lots for symbolGETGet unrealized tax lots csv

Instrument Details

GETGet all instrumentsGETGet instrumentGETSearch bonds

Market Data

POSTGet quotesGETGet bond detailsPOSTGet option expirationsPOSTGet option chainGETGet bars v2GETGet bars v2 with aggregation

Order Placement

POSTPreflight single legPOSTPreflight multi legPOSTPlace orderPUTReplace orderPOSTPlace multileg orderGETGet orderDELETECancel order

Option Details

GETGet option greeksPOSTGet strategy quote
HelpFeedback

Get Started

Using market data for a backtest

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:

  • Backtesting: Perhaps the most useful thing to do with historic market data is backtest a trading strategy. The good part about getting this data programmatically is you can automate the backtest with Python.
  • Strategy warm up: Once you have a trading strategy backtested and ready to go, you can use historic data to warm up the strategy so you don't have to wait for realtime data to build up to get the strategy running.

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.

Setting up

Here's a few things you'll need to get started:

  • A Public account and an API key, which you can generate at public.com/settings/v2/api/api-keys
  • A basic understanding of Python and setting up a Python environment.
# 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.py

One 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_here

Creating the client

The 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

Getting historic data

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,
)

Choosing a period and aggregation

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:

BarPeriodBarAggregation
DAY, WEEK, MONTHONE_MINUTE โ€ฆ ONE_HOUR (intraday)
QUARTER, HALF_YEAR, YTD, YEARONE_HOUR, ONE_DAY, ONE_WEEK
FIVE_YEARS, TEN_YEARS, ALLONE_DAY and coarser

Regular vs extended hours

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,793

Each bar carries a timezone-aware timestamp plus open, high, low, close, and volume โ€” everything a backtester needs.

Converting to a DataFrame

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.

Writing the strategy

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.

What this strategy actually does

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 short

Run both. Comparing them tells you how much of the strategy's performance came from the short side, which is a useful thing to know.

Running the backtest

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.92

Replace the block above with your own output โ€” numbers will differ by symbol, date range, and market conditions.

How to read this

A handful of lines carry most of the signal:

  • Return [%] vs. Buy & Hold Return [%].The most important comparison on the page. If your strategy doesn't beat simply buying and holding the same asset over the same window, it isn't earning its complexity. Here it doesn't.
  • Max. Drawdown [%].Peak-to-trough loss. This is what determines whether you'd actually stick with the strategy. A 4% return with a 6% drawdown means you'd have been underwater along the way.
  • # Trades. Your sample size. Twenty-one trades is far too few to conclude anything. You want hundreds before any statistic here means much.
  • Win Rate vs. Profit Factor.A 38% win rate sounds bad but isn't necessarily โ€” trend-following systems typically win less than half their trades and make it back on the size of the winners. Profit Factor (gross profit รท gross loss) above 1.0 is what matters.
  • Sharpe Ratio. Return per unit of volatility. A Sharpe of 4.8 on six days of data is noise, not skill. Annualized figures computed from a one-week sample are meaningless โ€” ignore Return (Ann.) entirely here.

Where this backtest falls short

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.

Where to go next

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.

Skipping TA-Lib

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.

Resources

  • Public Python SDK on GitHub: github.com/PublicDotCom/publicdotcom-py
  • Get your API key: public.com/settings/v2/api/api-keys
  • backtesting.py docs: kernc.github.io/backtesting.py