Skip to content

Build a backtest with point-in-time data

A backtest is only as honest as its inputs. The common failure is look-ahead: using a quarterly result on the day the quarter ended, weeks before the company published it. This guide builds a small daily panel of prices and trailing earnings for one stock where every value was public on its date.

Price history pages backwards with end_date. Each call serves up to 2,000 calendar days.

import os, requests, pandas as pd
BASE = "https://api.tapetide.com/v1"
s = requests.Session()
s.headers["Authorization"] = f"Bearer {os.environ['TAPETIDE_TOKEN']}"
def price_history(symbol):
rows, end = [], None
while True:
params = {"days": 2000, **({"end_date": end} if end else {})}
body = s.get(f"{BASE}/stocks/{symbol}/price", params=params, timeout=60).json()
rows.extend(body["data"])
win = body["meta"]["window"]
if not win["has_more"]:
break
end = win["next_cursor"]
df = pd.DataFrame(rows).drop_duplicates("date")
df["date"] = pd.to_datetime(df["date"])
return df.sort_values("date").set_index("date")
px = price_history("TCS")

close is adjusted for splits and bonuses, so returns computed from it are continuous across corporate actions. It is not dividend-adjusted: add dividends separately if you need total return. raw_close is the unadjusted print, null where unavailable.

2. Pull fundamentals with their availability dates

Section titled “2. Pull fundamentals with their availability dates”
fin = s.get(f"{BASE}/stocks/TCS/financials", params={"section": "quarterly"}, timeout=60).json()
q = fin["data"][0] if isinstance(fin["data"], list) else fin["data"]
eps = pd.Series(q["data"]["EPS in Rs"]) # {"Jun 2026": 36.9, ...}
avail = {a["period"]: a["available_from"] for a in q["availability"]}
eps_pit = pd.DataFrame({
"eps": eps,
"available_from": pd.to_datetime(pd.Series(avail)),
}).dropna().sort_values("available_from")

Each period’s available_from is the result-declaration date when basis is reported, or a regulatory-lag estimate when it is estimated. Treat estimated rows with care, or drop them, in sensitive tests.

3. Join on the date the data became public

Section titled “3. Join on the date the data became public”
eps_pit["ttm_eps"] = eps_pit["eps"].rolling(4).sum()
panel = pd.merge_asof(
px[["close"]].reset_index(),
eps_pit[["available_from", "ttm_eps"]].rename(columns={"available_from": "date"}),
on="date",
direction="backward", # only rows already published on each date
)
panel["pe"] = panel["close"] / panel["ttm_eps"]

merge_asof(..., direction="backward") gives each trading day the latest quarter that had been published by then, never one from the future.

Use the holiday list rather than a weekday calendar. Exchanges sometimes open on weekends for special sessions and close on weekdays.

holidays, url = [], "/v1/market/holidays?from=2015-01-01&limit=500"
while url:
body = s.get("https://api.tapetide.com" + url, timeout=30).json()
holidays += [h["date"] for h in body["data"]]
url = body["links"]["next"]

A trading date with no bar is not a zero return. Mask it, do not fill it with 0.

  • Statement values are the latest reported figures and can include restatements made after available_from.
  • Today’s index constituents are not yesterday’s. Building a universe from a current index list adds survivorship bias.
  • Prices are end-of-day. Intraday bars are not served by these routes.

Each call above costs one request unit. A full price history is a handful of calls; cache it locally rather than refetching per run.