---
title: "Build a backtest with point-in-time data"
description: "Assemble prices and fundamentals for a backtest without look-ahead bias, using Tapetide's availability dates and adjusted price series."
canonical: "https://developers.tapetide.com/guides/backtest-point-in-time/"
---

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

## 1\. Pull the full price history

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

```python
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

```python
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

```python
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.

## 4\. Respect the trading calendar

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

```python
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`.

## Caveats to state in your results

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