How do I avoid look-ahead and survivorship bias when backtesting Taiwan stocks?

Treat them separately. Look-ahead: do not align financials on the period end date — align them on the date the figure could first have been known. Taiwan monthly revenue must be published by the 10th of the following month, and quarterly reports have a statutory filing deadline, so those dates give you a conservative knowledge date without needing any special field. Survivorship: your universe has to include tickers that have since delisted, or your history contains only the companies that made it.

Look-ahead bias

Using information in a backtest that was not yet public on the date the decision is being simulated.

These are two different mistakes

  • Look-ahead bias: at some simulated date you used a number that had not been published yet. The signal may be sound, but you cannot reproduce it in live trading.
  • Survivorship bias: your universe contains only companies that still exist. Delisted, merged and failed names were dropped, so what is left looks better than the market did.
  • They compound. Rank on unpublished earnings, inside a universe of companies that never went under, and the equity curve stops resembling anything achievable.

Look-ahead: align on when a figure became knowable

A company's Q1 results carry a period end date of 31 March, but the filing lands in the middle of May. Aligning EPS to 31 March lets an April decision use a May number. The fix is to compute a knowledge date for every figure and use only what was knowable by your simulated date.

Monthly revenue: use the 10th of the following month

Taiwan-listed companies must publish monthly revenue by the 10th of the month after. So October revenue is conservatively knowable on 10 November and not before. This rule needs no special field — it works today, on the response as it currently stands.

import requests
from datetime import date

def knowledge_date_for_revenue(revenue_month: str) -> date:
"""Monthly revenue must be published by the 10th of the following month."""
year, month = (int(part) for part in revenue_month.split("-"))
return date(year + (month // 12), (month % 12) + 1, 10)

r = requests.get(
"https://api.twmarketdata.com/v2/datasets/monthly-revenue",
headers={"X-API-Key": "sk_live_your_key"},
params={"symbol": "2330", "limit": 6},
timeout=20,
)
r.raise_for_status()

as_of = date(2026, 7, 1)
for row in r.json()["rows"]:
known_on = knowledge_date_for_revenue(row["revenue_month"])
if known_on <= as_of:
print(row["revenue_month"], row["revenue"], row["yoy"], "usable, knowable", known_on)
else:
print(row["revenue_month"], "-> not yet knowable on", as_of, ", skip")

Quarterly reports: use the statutory filing deadline

Same shape, different clock. Under Article 36 of the Securities and Exchange Act, Q1 to Q3 reports are filed within 45 days of quarter end and the annual report within three months of year end. Add that to period_end_date and you have a knowledge date that cannot see the future.

from datetime import date, timedelta

# Q1-Q3: within 45 days of quarter end. Q4/annual: within three months —
# 90 days is the conservative approximation of three calendar months.
DEADLINE_DAYS = {1: 45, 2: 45, 3: 45, 4: 90}

def knowledge_date_for_report(period_end: str, quarter: int) -> date:
y, m, d = (int(part) for part in period_end.split("-"))
return date(y, m, d) + timedelta(days=DEADLINE_DAYS[quarter])

rows = requests.get(
"https://api.twmarketdata.com/v2/datasets/income-statement",
headers={"X-API-Key": "sk_live_your_key"},
params={"symbol": "2330", "limit": 8},
timeout=20,
).json()["rows"]

as_of = date(2026, 7, 1)
for row in rows:
known_on = knowledge_date_for_report(row["period_end_date"], row["fiscal_quarter"])
state = "usable" if known_on <= as_of else "not yet knowable"
print(row["fiscal_year"], "Q" + str(row["fiscal_quarter"]),
"ends", row["period_end_date"], "knowable", known_on, state, "EPS", row["eps"])

Why 90 days rather than the shorter deadline

Companies with paid-in capital of NT$10 billion or more — 2330 among them — have filed the annual report within 75 days since the 2022 financial year. Using 90 therefore dates the knowledge later than strictly required, which is the direction you want: a knowledge date that is too late costs you a few days of data, one that is too early is look-ahead. Set it per company if you need that precision.

A simpler trap: signalling on the same day's close

A close is only known after the close. Computing a signal from today's close and assuming a fill at today's close means deciding and executing with the same information. Compute on T and fill on T+1, or the backtest will not match live trading.

Survivorship: your universe must accept delisted tickers

Delisted tickers use the same endpoint as listed ones — only the symbol changes. 311 tickers that have stopped trading keep their price history, 264 of them with an official delisting date, and listed prices reach back to 2004-02-11. You can test this directly: query a ticker you know has delisted and see whether it answers.

def price_history(symbol: str, limit: int = 5):
r = requests.get(
"https://api.twmarketdata.com/v2/datasets/twse-daily-price",
headers={"X-API-Key": "sk_live_your_key"},
params={"symbol": symbol, "limit": limit},
timeout=20,
)
r.raise_for_status()
return r.json()["rows"]

# Swap in any delisted ticker: the call is identical
for row in price_history("2330"):
print(row["date"], row["close"], row["price_method"], row["price_confidence"])

How to test any data source for both problems

  • Query a ticker you know has delisted. No answer means your backtest carries survivorship bias.
  • Check whether you can establish when each figure became public. If all you get is a period end date and no way to reason about publication timing, look-ahead is not avoidable — not because you are careless, but because the data will not let you.
  • Look at how gaps are represented. A hole quietly filled with the previous value is more dangerous than the hole, because you cannot see it afterwards.

What we do not have

  • No real-time quotes. This is historical and post-close data.
  • No as_of query parameter that filters by knowledge date on the server. The approach above computes the knowledge date on your side, which is what works today.
  • The disclosure-date fields (announcement_date on monthly revenue, report_date on financials) are still being backfilled and currently read empty, which is exactly why this page uses statutory timing rather than reading those fields.
  • Gaps are marked, never interpolated or forward-filled. A series that has been quietly repaired will flatter a backtest.

Common questions

1Which bias costs more?
It depends on the strategy. Anything ranking on fundamentals is hit hardest by look-ahead, because every quarter uses a figure that was not public yet. Long-horizon or whole-market backtests suffer more from survivorship, because the names removed are the worst performers. Both need handling.
2Why can I not align financials on the period end date?
Because the report does not exist on that date. A Q1 period ends 31 March and is filed in mid-May, so aligning on the period end hands your strategy the result about six weeks early.
3Where do I find delisted tickers?
The same endpoint as any listed ticker — change the symbol. 311 stopped-trading tickers keep their full price history, 264 of them with an official delisting date.
4Is there an as_of parameter?
Yes. Pass as_of=YYYY-MM-DD and the cutoff is applied server-side, against knowledge_date for disclosures and the trade date for prices. The response echoes as_of_applied, names the field it cut on, and counts the rows removed, so you can confirm it took effect rather than assume it. The knowledge date is set to the statutory filing deadline rather than the observed announcement, so it is conservative — data never appears earlier than it truly was.

Want to query this data yourself? Create a free account.

Related articles