Forward or backward adjusted prices — which should a backtest use?
Use backward adjustment for backtesting. The difference is the anchor: forward adjustment anchors on the most recent price, so every new ex-dividend event rewrites the whole history — the same backtest run today and next month reads different historical prices. Backward adjustment anchors on the earliest price, so new events only extend the series and the past does not move.
Adjusted price
Prices with the gaps caused by dividends, rights issues and splits stitched back in, so figures from different dates are comparable.
The difference is the anchor, not the accuracy
- Forward-adjusted anchors on the latest bar and works backwards. Every new corporate action recomputes all earlier prices.
- Backward-adjusted anchors on the earliest bar and works forwards. A new event affects only prices after it; everything already computed stays put.
- Both produce the same return series shape. What differs is whether your historical numbers change under you.
Why a backtest needs the backward form
Forward-adjusted history is a moving target. You produce entries, exits and a drawdown in January; the stock goes ex-dividend in March; you re-run the identical code and January's prices are no longer what they were. Signals may not fire, stops may not trigger. Reproducibility is the minimum requirement of a backtest, and that is precisely what forward adjustment breaks.
An adjusted price is not a price you could have traded
Whichever convention you use, an adjusted price is a number computed for cross-time comparison. It is not what the market would have filled you at. Use it for returns; go back to raw prices for fills, slippage and daily limit checks.
What we serve is raw prices plus the official factors
There is no ready-made adjusted-price endpoint here. There are two things: raw OHLC from twse-daily-price, and the official ex-rights and ex-dividend adjustment factors from price-enhanced, sourced from TWSE. You compute the back-adjusted series — one extra step, in exchange for knowing exactly how every number was produced.
import requests
HEADERS = {"X-API-Key": "sk_live_your_key"}
BASE = "https://api.twmarketdata.com/v2/datasets"
def rows_of(payload):
"""Both envelopes: the price endpoint returns rows, the factor endpoint returns envelope.data."""
return payload.get("rows") or payload.get("envelope", {}).get("data", [])
factors = rows_of(requests.get(
f"{BASE}/price-enhanced",
headers=HEADERS,
# This endpoint's parameters differ from the price endpoint: the identifier is
# ticker (not symbol) and the range is date_from / date_to (not start_date / end_date).
params={"ticker": "2330", "date_from": "2024-01-01", "date_to": "2026-07-31"},
timeout=20,
).json())
for f in factors:
print(f["trade_date"], f["event_type"], "factor", f["factor"],
"pre-event close", f["pre_event_close"], "reference", f["reference_price"])The two endpoints disagree on names — and one mismatch fails silently
- twse-daily-price: parameter symbol, response fields symbol and date.
- price-enhanced: parameter ticker, range date_from / date_to, response fields ticker and trade_date.
- The failure modes differ. Sending symbol to price-enhanced returns 422 immediately. Sending start_date / end_date does not error — the range is dropped and you get the entire history back with a 200. Measured: asking for 2020 with start_date/end_date returned 45 rows spanning 2003 to 2026; with date_from/date_to it returned 4 rows inside 2020.
Verify the factor direction before you rely on it
On real rows, factor equals reference_price divided by pre_event_close — for example 5.84 / 5.93 = 0.98482293, matching that row's factor exactly. Do not take that on faith: every row carries both fields, so divide one by the other yourself on one event before applying it to a whole history.
for f in factors[:3]: pre, ref = f["pre_event_close"], f["reference_price"] if pre and ref: print(f["trade_date"], "ref/pre =", round(ref / pre, 8), "| factor =", f["factor"])Building the back-adjusted series
Multiply the factors cumulatively and apply them only to bars after each event, anchoring at the earliest bar. A new ex-dividend then appends to the end and leaves the computed past untouched.
prices = rows_of(requests.get( f"{BASE}/twse-daily-price", headers=HEADERS, params={"symbol": "2330", "start_date": "2024-01-01", "end_date": "2026-07-31"}, timeout=20,).json()) events = sorted(factors, key=lambda f: f["trade_date"])cumulative, out = 1.0, []for row in sorted(prices, key=lambda r: r["date"]): while events and events[0]["trade_date"] <= row["date"]: cumulative *= float(events.pop(0)["factor"]) out.append({**row, "close_back_adjusted": row["close"] * cumulative}) for row in out[-3:]: print(row["date"], "raw", row["close"], "back-adjusted", round(row["close_back_adjusted"], 2))What we do not have
- No ready-made adjusted-price endpoint. Raw prices and official factors are provided; the back-adjusted series is yours to compute, as above.
- No real-time quotes.
- Factors come from TWSE ex-rights and ex-dividend data. Coverage is whatever the dataset page states; nothing is estimated to fill a gap.
Common questions
1Why do charting platforms show forward-adjusted prices?
2Back-adjusted prices look nothing like the market price. Is that expected?
3With or without dividends?
4How does this relate to look-ahead bias?
Want to query this data yourself? Create a free account.