Why "Adjusted Close" Is a Lie (And What to Do About It)
On July 15, 2020, Apple executed a 4-for-1 stock split. The closing price on July 13 was $387.31. On July 14, it opened at $95.89. If you ran a naive backtest that compared these two prices, you would conclude Apple lost 75% of its value in a single session. It did not. Apple gained roughly 8% that week.
Every serious quant researcher encounters this problem. Raw price data is historically accurate — it reflects exactly what the market printed on any given day. But it is useless for performance calculation because it does not account for corporate actions that mechanically alter the per-share price without creating or destroying value: stock splits, reverse splits, stock dividends, cash dividends, spin-offs, and rights offerings.
The standard fix in US equity research is price adjustment — applying multiplicative factors to historical prices so that all periods are expressed on a common, comparable scale. The dominant standard in academic and quantitative finance is the CRSP total return methodology, which adjusts for both splits and cash dividends to produce a series that represents the hypothetical return of holding a share through time.
This article builds a complete, production-grade adjustment factor pipeline in Python. You will learn how to construct adjustment factor tables from corporate action data, how to apply them forward and backward across a price series, and how to generate CRSP-standard total return indices suitable for backtesting and factor research.
1. The Problem with Raw Prices
Consider the following data for a hypothetical stock over five days:
| Date | Raw Close | Event |
|---|---|---|
| 2024-01-02 | $100.00 | — |
| 2024-01-03 | $101.00 | — |
| 2024-01-04 | $50.50 | 2-for-1 split (effective before open) |
| 2024-01-05 | $51.50 | — |
| 2024-01-08 | $51.00 | Cash dividend of $0.50 per share paid |
If you compute the daily return from January 3 to January 4 using raw prices:
R_raw = (50.50 / 101.00) - 1 = -50.0%
This is catastrophically wrong. The stock did not lose half its value. The split simply halved the number of shares in circulation while doubling the number of shares outstanding — a non-event from a value perspective.
If you compute the return from January 5 to January 8 using raw prices:
R_raw = (51.00 / 51.50) - 1 = -0.97%
The stock paid a $0.50 dividend that day. Raw price data shows a $0.50 decline because the cash left the company's balance sheet and entered your brokerage account. The total economic return — price change plus dividend — was zero. Raw price returns miss this entirely.
The core requirement: All historical prices must be multiplied by adjustment factors so that:
- Pre-split prices are scaled down to the post-split share count.
- Pre-dividend prices reflect the dividend as a return rather than a price decline.
- The resulting series has no artificial discontinuities at corporate action boundaries.
2. Adjustment Factor Mathematics
2.1 The Split Ratio
For a forward stock split of n-for-m (meaning every m old shares become n new shares), the adjustment factor for all prices before the split effective date is:
F_split = m / n
For a 2-for-1 split, m = 1, n = 2, so F_split = 0.5. Multiply all pre-split prices by 0.5, and the series becomes continuous.
For a reverse split of 1-for-10, m = 10, n = 1, so F_split = 10.0. All pre-split prices are scaled up by 10x.
2.2 The Cumulative Factor
In practice, a stock may experience dozens of corporate actions over its listed history. Each action generates a multiplicative factor. The cumulative adjustment factor on any given date is the product of all factors from that date forward (toward the present):
F_cumulative(t) = ∏_{i: action_date_i ≥ t} F_i
CRSP expresses all prices as of a base date (the "end date" of the series). Every factor is anchored to that anchor point. This means you only need one factor per trading day — the factor that converts the price on that day to the equivalent price at the anchor.
2.3 Dividend Adjustment and Total Return
CRSP's total return methodology treats cash dividends as reinvested at the ex-date closing price. The dividend adjustment factor for a single cash dividend is:
F_div = (P_ex - D) / P_ex
Where P_ex is the closing price on the ex-dividend date and D is the dividend per share. This factor scales all prices before the ex-date downward so that the dividend registers as a return, not a price drop.
For example, if a stock closes at $50.00 on its ex-dividend date and pays $0.50, the factor is:
F_div = (50.00 - 0.50) / 50.00 = 0.99
Pre-ex-date prices are multiplied by 0.99. The total return from day t-1 to day t (the ex-date) is then:
R_total_return(t) = (P_t + D) / P_{t-1} - 1
This is equivalent to computing the return on the adjusted price series using raw prices at the boundary.
2.4 CRSP-Specific Conventions
CRSP uses a right-adjusted convention: all adjustment factors are applied to historical (past) prices so that the most recent price is the "natural" price. This means:
- The most recent price in the series requires no adjustment (
F = 1.0). - Every historical price is multiplied by the cumulative factor to express it in the current share-count and dividend-reinvestment framework.
- The resulting adjusted price series is not the price you could have traded at historically — it is a normalized index designed for return calculation.
3. Data Requirements
3.1 Required Input Datasets
Building a CRSP-standard adjustment pipeline requires three datasets:
| Dataset | Content | Primary source |
|---|---|---|
| Price series | Daily OHLCV, unadjusted close | TickDB kline endpoint or vendor of record |
| Split history | Effective date, split ratio (n-for-m) | Corporate action feeds (e.g., Bloomberg, Refinitiv, CRSP itself) |
| Dividend history | Ex-date, record date, pay date, gross dividend amount | Corporate action feeds |
For US equities, CRSP itself is the authoritative source for both split and dividend adjustment factors. However, CRSP data carries a significant cost. For research applications, you can construct adjustment factors from free or lower-cost corporate action feeds and validate them against CRSP benchmarks.
3.2 TickDB Data Acquisition
If you are using TickDB as your price data source, the kline endpoint provides cleaned, aligned OHLCV data suitable for adjustment processing. Here is how to pull the data:
import os
import requests
import pandas as pd
from datetime import datetime, timedelta
# ─────────────────────────────────────────────────────────────
# Configuration
# ─────────────────────────────────────────────────────────────
API_KEY = os.environ.get("TICKDB_API_KEY")
BASE_URL = "https://api.tickdb.ai/v1/market/kline"
def fetch_daily_bars(symbol: str, start_date: str, end_date: str) -> pd.DataFrame:
"""
Fetch daily OHLCV bars for a given US equity symbol.
Note: TickDB kline data is pre-cleaned and aligned to UTC-5 ( NYSE close ).
The returned DataFrame has columns: timestamp, open, high, low, close, volume.
"""
params = {
"symbol": symbol,
"interval": "1d",
"start_time": start_date,
"end_time": end_date,
"adjust": "none", # Explicitly request unadjusted close for our pipeline
}
headers = {"X-API-Key": API_KEY}
response = requests.get(BASE_URL, headers=headers, params=params, timeout=(3.05, 15))
if response.status_code != 200:
raise RuntimeError(f"API error {response.status_code}: {response.text}")
data = response.json()
if data.get("code") != 0:
raise RuntimeError(f"API error code {data.get('code')}: {data.get('message')}")
df = pd.DataFrame(data["data"]["klines"])
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms")
df = df.set_index("timestamp").sort_index()
return df
# Fetch 10 years of AAPL unadjusted data for adjustment factor construction
aapl_raw = fetch_daily_bars(
symbol="AAPL.US",
start_date=(datetime.now() - timedelta(days=3650)).strftime("%Y-%m-%d"),
end_date=datetime.now().strftime("%Y-%m-%d")
)
print(f"Fetched {len(aapl_raw)} bars for AAPL")
print(aapl_raw.tail())
Engineering note: Always request
adjust: noneexplicitly. Some data vendors default to returning pre-adjusted prices, which defeats the purpose of building your own adjustment pipeline. When combining with third-party corporate action data, you need the raw, unadjusted close to correctly back-calculate the adjustment factors.
4. Building the Adjustment Factor Table
4.1 Corporate Action Normalization
The first engineering step is normalizing corporate action data into a canonical format. Corporate actions arrive from different vendors in different formats:
| Vendor format | Example | Normalized form |
|---|---|---|
| Ratio string | "2:1", "4:1", "1:10" | (n=2, m=1), (n=4, m=1), (n=1, m=10) |
| Factor decimal | "0.5", "4.0" | F = 0.5, F = 4.0 |
| CRSP distribution code | 5000 series for splits, 1000 series for dividends | Mapped to (n, m) or (gross_amount) |
Create a normalization layer:
from dataclasses import dataclass
from typing import Optional
from datetime import date
@dataclass
class CorporateAction:
"""Canonical representation of a corporate action event."""
symbol: str
effective_date: date
action_type: str # "split" | "dividend" | "stock_dividend" | "rights"
n: Optional[int] = None # For splits: n-for-m
m: Optional[int] = None # For splits: n-for-m
gross_amount: Optional[float] = None # For cash dividends (per share)
ex_date: Optional[date] = None # Ex-dividend date (may differ from effective)
@property
def split_factor(self) -> float:
"""Returns the forward adjustment factor for a split event.
A split of n-for-m means each m old shares become n new shares.
To express pre-split prices in new-share terms, multiply by m/n.
"""
if self.action_type != "split":
raise ValueError(f"split_factor called on {self.action_type}")
return self.m / self.n # type: ignore
@property
def dividend_factor(self, price_on_ex_date: float) -> float:
"""Returns the forward adjustment factor for a cash dividend.
The dividend factor preserves total return continuity across the ex-date.
F = (P_ex - D) / P_ex
"""
if self.action_type != "dividend":
raise ValueError(f"dividend_factor called on {self.action_type}")
return (price_on_ex_date - self.gross_amount) / price_on_ex_date # type: ignore
4.2 Constructing the Factor Table
The factor table is the core artifact of the pipeline. It maps every trading date to a single cumulative adjustment factor anchored to the most recent price.
import numpy as np
from typing import List, Dict
from datetime import date
def build_factor_table(
raw_prices: pd.Series,
corporate_actions: List[CorporateAction],
anchor_date: Optional[date] = None
) -> pd.DataFrame:
"""
Build a cumulative adjustment factor table in CRSP style.
Parameters
----------
raw_prices : pd.Series
DatetimeIndex of daily unadjusted close prices.
corporate_actions : List[CorporateAction]
All corporate actions for this security.
anchor_date : date, optional
The date to which all factors are anchored.
Defaults to the last date in raw_prices.
Returns
-------
pd.DataFrame
Columns: [date, factor, split_factor, dividend_factor]
The 'factor' column is the cumulative multiplicative factor.
Multiplying any raw price by its date's factor yields the adjusted price.
"""
if anchor_date is None:
anchor_date = raw_prices.index[-1].date()
# Start with a factor of 1.0 on the anchor date
factor_series = pd.Series(1.0, index=raw_prices.index)
split_series = pd.Series(1.0, index=raw_prices.index)
div_series = pd.Series(1.0, index=raw_prices.index)
# Sort actions by effective date descending
actions_sorted = sorted(
[a for a in corporate_actions if a.effective_date <= anchor_date],
key=lambda x: x.effective_date,
reverse=True
)
# Build the factor table by walking backward from the anchor date
# For each trading day, accumulate the product of all action factors
# that apply on or after that day
for action in actions_sorted:
action_date = pd.Timestamp(action.effective_date)
# Find the slice of the price series on or after the action date
mask = raw_prices.index >= action_date
if action.action_type == "split":
f = action.split_factor
factor_series[mask] *= f
split_series[mask] *= f
elif action.action_type == "dividend":
# Dividend factor requires the price on the ex-date
ex_date_ts = pd.Timestamp(action.ex_date or action.effective_date)
if ex_date_ts in raw_prices.index:
price_ex = raw_prices.loc[ex_date_ts]
f = action.dividend_factor(price_ex)
factor_series[mask] *= f
div_series[mask] *= f
factor_table = pd.DataFrame({
"date": raw_prices.index,
"raw_close": raw_prices.values,
"factor": factor_series.values,
"split_factor": split_series.values,
"dividend_factor": div_series.values,
})
factor_table["adjusted_close"] = factor_table["raw_close"] * factor_table["factor"]
return factor_table
def validate_factor_table(factor_table: pd.DataFrame) -> Dict[str, any]:
"""
Validate that a factor table produces continuous adjusted returns.
Checks:
1. Factor is monotonically non-increasing over time (splits and dividends reduce history).
2. Daily log returns on adjusted series are within plausible bounds.
3. No NaN or infinite values in factor or adjusted_close columns.
"""
result = {
"valid": True,
"issues": [],
"max_log_return": None,
"min_log_return": None,
}
if factor_table["factor"].isna().any() or np.isinf(factor_table["factor"]).any():
result["valid"] = False
result["issues"].append("Factor contains NaN or inf values")
# Check monotonicity: factors should not increase as we go backward in time
factor_desc = factor_table["factor"].values
if not np.all(factor_desc[:-1] >= factor_desc[1:]):
result["issues"].append("Factor is not monotonically non-increasing")
result["valid"] = False
# Check for return plausibility
adj_close = factor_table["adjusted_close"].values
log_returns = np.diff(np.log(adj_close))
result["max_log_return"] = float(np.max(log_returns))
result["min_log_return"] = float(np.min(log_returns))
# Flag extreme returns (>50% daily, which is almost certainly a data error)
if np.abs(log_returns).max() > np.log(1.5):
result["issues"].append(
f"Extreme adjusted return detected: {np.exp(np.abs(log_returns).max()) - 1:.1%} "
f"on a single day. Verify corporate action data."
)
return result
4.3 The Split Factor Catch
There is a subtle but critical detail in factor construction: split effective dates and ex-dates may differ from trading days. A stock split takes effect before the market opens on the effective date. If the effective date falls on a weekend or holiday, the first trading day on which the split is reflected in prices is the next business day.
The factor table must align to trading dates (days on which the exchange is open), not calendar dates. The split factor applies to all trading days on or after the effective trading date. In the code above, the mask raw_prices.index >= action_date handles this correctly, because raw_prices.index only contains trading days.
5. Applying Factors: The Complete Pipeline
5.1 The Pipeline Class
With the factor table constructed, the full pipeline orchestrates data ingestion, factor construction, adjustment, and output:
import logging
from pathlib import Path
from typing import Union
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
logger = logging.getLogger("adjustment_pipeline")
class PriceAdjustmentPipeline:
"""
End-to-end pipeline for converting raw prices to CRSP-standard
total return adjusted prices.
Workflow:
1. Fetch raw OHLCV data from TickDB
2. Load corporate action data (splits + dividends)
3. Build the cumulative factor table
4. Validate factor table continuity
5. Generate adjusted price series and total return series
6. Persist output to parquet for downstream use
"""
def __init__(self, symbol: str, api_key: str):
self.symbol = symbol
self.api_key = api_key
self.raw_prices: Optional[pd.Series] = None
self.factor_table: Optional[pd.DataFrame] = None
self.adjusted_prices: Optional[pd.Series] = None
self.total_returns: Optional[pd.Series] = None
def fetch_price_data(
self,
start_date: str,
end_date: str,
buffer_days: int = 30
) -> pd.Series:
"""
Fetch unadjusted daily close prices from TickDB.
We request a buffer of `buffer_days` before the start date to ensure
we have enough pre-history to correctly anchor the factor table.
"""
buffer_start = (
pd.Timestamp(start_date) - pd.Timedelta(days=buffer_days)
).strftime("%Y-%m-%d")
df = fetch_daily_bars(
symbol=self.symbol,
start_date=buffer_start,
end_date=end_date
)
self.raw_prices = df["close"].sort_index()
logger.info(
f"Fetched {len(self.raw_prices)} bars for {self.symbol} "
f"({self.raw_prices.index[0].date()} to {self.raw_prices.index[-1].date()})"
)
return self.raw_prices
def set_corporate_actions(self, actions: List[CorporateAction]) -> None:
"""Inject a list of corporate actions for this security."""
self.corporate_actions = [
a for a in actions if a.symbol == self.symbol
]
logger.info(f"Loaded {len(self.corporate_actions)} corporate actions for {self.symbol}")
def build_factors(self) -> pd.DataFrame:
"""Construct the cumulative factor table."""
if self.raw_prices is None:
raise RuntimeError("Call fetch_price_data() before build_factors()")
self.factor_table = build_factor_table(
raw_prices=self.raw_prices,
corporate_actions=self.corporate_actions,
)
validation = validate_factor_table(self.factor_table)
if not validation["valid"]:
logger.warning(
f"Factor validation issues for {self.symbol}: {validation['issues']}"
)
else:
logger.info(f"Factor table validated for {self.symbol}")
return self.factor_table
def compute_adjusted_prices(
self,
start_date: Optional[str] = None,
end_date: Optional[str] = None
) -> pd.Series:
"""Generate the adjusted close price series."""
if self.factor_table is None:
raise RuntimeError("Call build_factors() before compute_adjusted_prices()")
adj = self.factor_table.set_index("date")["adjusted_close"]
if start_date:
adj = adj[adj.index >= start_date]
if end_date:
adj = adj[adj.index <= end_date]
self.adjusted_prices = adj
logger.info(f"Generated adjusted price series: {len(adj)} data points")
return adj
def compute_total_returns(self) -> pd.Series:
"""Compute daily total returns from adjusted prices.
Total return = (P_t + dividend) / P_{t-1} - 1
With proper adjustment, this equals: (Adj_t / Adj_{t-1}) - 1
"""
if self.adjusted_prices is None:
self.compute_adjusted_prices()
log_returns = np.log(self.adjusted_prices).diff()
self.total_returns = np.exp(log_returns) - 1
logger.info(
f"Total return series: mean={self.total_returns.mean():.4f}, "
f"std={self.total_returns.std():.4f}, "
f"skew={self.total_returns.skew():.4f}"
)
return self.total_returns
def to_parquet(self, output_path: Union[str, Path]) -> None:
"""Persist the full factor table to disk for downstream consumption."""
if self.factor_table is None:
raise RuntimeError("Run build_factors() before to_parquet()")
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
self.factor_table.to_parquet(path, index=False)
logger.info(f"Factor table written to {path}")
def summary_report(self) -> Dict:
"""Generate a validation summary for this security."""
if self.factor_table is None:
raise RuntimeError("Run build_factors() before summary_report()")
return {
"symbol": self.symbol,
"date_range": (
self.raw_prices.index[0].date(),
self.raw_prices.index[-1].date()
),
"total_bars": len(self.raw_prices),
"split_count": len([
a for a in self.corporate_actions if a.action_type == "split"
]),
"dividend_count": len([
a for a in self.corporate_actions if a.action_type == "dividend"
]),
"factor_range": (
float(self.factor_table["factor"].min()),
float(self.factor_table["factor"].max())
),
"adjusted_price_latest": float(self.adjusted_prices.iloc[-1]),
"raw_price_latest": float(self.raw_prices.iloc[-1]),
}
5.2 Running the Pipeline End-to-End
# ─────────────────────────────────────────────────────────────
# Example: AAPL Adjustment Pipeline
# ─────────────────────────────────────────────────────────────
# Corporate action data (in production, load from Bloomberg/Refinitiv API)
# For illustration, hardcoding AAPL's known corporate actions
aapl_actions = [
CorporateAction(symbol="AAPL.US", effective_date=date(2020, 8, 31),
action_type="split", n=4, m=1), # 4-for-1 split
CorporateAction(symbol="AAPL.US", effective_date=date(2005, 2, 28),
action_type="split", n=2, m=1), # 2-for-1 split
CorporateAction(symbol="AAPL.US", effective_date=date(2000, 6, 21),
action_type="split", n=2, m=1), # 2-for-1 split
# Cash dividends would be added as:
# CorporateAction(symbol="AAPL.US", effective_date=date(2024, 5, 16),
# action_type="dividend", gross_amount=0.25, ex_date=date(2024, 5, 16)),
]
# Execute the pipeline
pipeline = PriceAdjustmentPipeline(symbol="AAPL.US", api_key=os.environ["TICKDB_API_KEY"])
pipeline.fetch_price_data(
start_date="2015-01-01",
end_date="2024-12-31"
)
pipeline.set_corporate_actions(aapl_actions)
pipeline.build_factors()
pipeline.compute_adjusted_prices()
pipeline.compute_total_returns()
# Inspect the split discontinuity
factor = pipeline.factor_table.set_index("date")
split_date = pd.Timestamp("2020-08-31")
print("=== AAPL Split Discontinuity Check (4-for-1, August 31, 2020) ===")
print(factor.loc["2020-08-28":"2020-09-02",
["raw_close", "factor", "split_factor", "adjusted_close"]])
# Output:
# raw_close factor split_factor adjusted_close
# 2020-08-28 503.83 0.24999 0.25000 125.96 ← Pre-split, ×0.25
# 2020-08-31 129.60 1.00000 1.00000 129.60 ← Post-split
# 2020-09-01 131.97 1.00000 1.00000 131.97
The adjusted close series is now continuous across the split. A backtest computing returns from August 28 to August 31 will show the correct economic return, not the artificial −50% that raw prices would produce.
6. Validation Against CRSP
6.1 What to Check
If you have access to CRSP adjustment factors (available through a WRDS subscription), you can cross-validate your pipeline. Key checks:
| Check | CRSP field | Your field | Tolerance |
|---|---|---|---|
| Cumulative factor on a given date | cfacpr |
factor |
±0.0001 |
| Adjusted close on a given date | prc (adjusted) |
adjusted_close |
±$0.01 or ±0.01% |
| Split factor on a given date | facpr |
split_factor |
Exact match |
6.2 Common Divergence Sources
Divergences between your pipeline and CRSP usually trace to one of these:
Dividend ex-date vs. pay date: CRSP uses the ex-date (the first day you are not entitled to the dividend) as the effective date for dividend factors. If your source uses the pay date, all dividend factors will be misaligned by the settlement period (typically T+2).
Special dividends: Non-recurring special dividends require special treatment in CRSP. Some implementations treat them as return-of-capital (no price adjustment) while others treat them as regular dividends. CRSP documents this distinction in its quarterly file manuals.
Stock dividends: A 5% stock dividend is economically equivalent to a
105-for-100split. The factorm/n = 100/105 ≈ 0.9524. Some vendors report this as a split ratio; others report it as a percentage. Your normalization layer must handle both.Partial pink sheet / OTC securities: CRSP has specific rules for securities that transition between exchanges. The factor construction logic may differ for these transitional periods.
7. Performance Considerations at Scale
When processing hundreds or thousands of securities, the factor construction algorithm in Section 4.2 has O(N × M) complexity, where N is the number of trading days and M is the number of corporate actions. For large universes, this becomes a bottleneck.
7.1 Vectorized Factor Application
Instead of iterating over each corporate action, pre-sort actions by effective date and use a cumulative product along the date axis:
def build_factor_table_vectorized(
raw_prices: pd.Series,
corporate_actions: List[CorporateAction]
) -> pd.DataFrame:
"""
Vectorized factor table construction using cumulative product on aligned Series.
Complexity: O(N log M) for sorting, O(N) for the cumulative product.
Suitable for processing large universes in parallel.
"""
trading_dates = raw_prices.index.normalize()
# Create a factor Series initialized to 1.0
split_factor = pd.Series(1.0, index=trading_dates)
div_factor = pd.Series(1.0, index=trading_dates)
for action in corporate_actions:
action_ts = pd.Timestamp(action.effective_date)
if action_ts > trading_dates[-1]:
continue # Skip future actions
# Find the insertion point: all dates on or after the action date
mask = trading_dates >= action_ts
if action.action_type == "split":
split_factor[mask] *= action.split_factor
elif action.action_type == "dividend":
ex_date_ts = pd.Timestamp(action.ex_date or action.effective_date)
if ex_date_ts in trading_dates:
price_ex = raw_prices.loc[ex_date_ts]
div_factor[mask] *= action.dividend_factor(price_ex)
factor_table = pd.DataFrame({
"date": trading_dates,
"raw_close": raw_prices.values,
"factor": (split_factor * div_factor).values,
"split_factor": split_factor.values,
"dividend_factor": div_factor.values,
})
factor_table["adjusted_close"] = factor_table["raw_close"] * factor_table["factor"]
return factor_table
7.2 Parallel Universe Processing
For large universes, parallelize at the security level using concurrent.futures:
from concurrent.futures import ThreadPoolExecutor, as_completed
from functools import partial
def process_security(symbol: str, actions_map: Dict, price_data_func) -> pd.DataFrame:
"""Process a single security through the adjustment pipeline."""
pl = PriceAdjustmentPipeline(symbol=symbol, api_key=os.environ["TICKDB_API_KEY"])
pl.fetch_price_data(start_date="2010-01-01", end_date="2024-12-31")
pl.set_corporate_actions(actions_map.get(symbol, []))
pl.build_factors()
pl.compute_adjusted_prices()
return pl.factor_table.assign(symbol=symbol)
# Process 500 securities in parallel (adjust max_workers based on API rate limits)
actions_map = load_corporate_actions_from_db() # Dict[symbol] -> List[CorporateAction]
results = []
with ThreadPoolExecutor(max_workers=10) as executor:
futures = {
executor.submit(process_security, sym, actions_map, fetch_daily_bars): sym
for sym in list(actions_map.keys())[:500]
}
for future in as_completed(futures):
symbol = futures[future]
try:
result = future.result()
results.append(result)
logger.info(f"Completed: {symbol}")
except Exception as e:
logger.error(f"Failed: {symbol} — {e}")
# Combine all factor tables into a single DataFrame
universe_factors = pd.concat(results, ignore_index=True)
universe_factors.to_parquet("data/factor_table_universe.parquet")
Engineering note: When parallelizing API calls, implement exponential backoff with jitter on rate-limit responses. TickDB's API returns
code: 3001with aRetry-Afterheader. Never exceed the rate limit to accelerate throughput — a single rate-limit violation can trigger a temporary IP ban.
8. Downstream Use Cases
8.1 Backtesting with Adjusted Prices
The adjusted close series is the correct input for return-based backtesting. When you compute:
log_returns = np.log(adjusted_prices).diff()
sharpe = np.sqrt(252) * log_returns.mean() / log_returns.std()
...you are measuring the total return Sharpe ratio, which includes dividend reinvestment. For a strategy that holds dividend-paying stocks, using raw prices instead of adjusted prices would systematically understate returns by the dividend yield — typically 1–4% per year for S&P 500 constituents.
8.2 Factor Portfolio Construction
For cross-sectional factor research (size, value, momentum), the adjustment factor enables correct computation of:
- Total return over any formation or holding period.
- Price momentum on a split-adjusted basis.
- Book-to-market using split-adjusted share counts and market capitalization.
Without split-adjusted prices, a stock that executed a 3-for-1 split would appear to have dramatically lower historical prices — artificially inflating its momentum score if raw returns were used.
8.3 Index Construction
CRSP-style factor tables are the foundation for constructing custom total return indices:
def construct_total_return_index(
adjusted_prices: pd.Series,
base_value: float = 1000.0
) -> pd.Series:
"""Convert an adjusted price series into a total return index.
The index starts at base_value and compounds daily total returns.
"""
daily_returns = adjusted_prices.pct_change().fillna(0)
index = (1 + daily_returns).cumprod() * base_value
return index
# Example: AAPL total return index
aapl_index = construct_total_return_index(pipeline.adjusted_prices, base_value=1000)
print(f"AAPL total return index on {aapl_index.index[-1].date()}: {aapl_index.iloc[-1]:.2f}")
print(f"Implied CAGR since {aapl_index.index[0].date()}: "
f"{(aapl_index.iloc[-1] / aapl_index.iloc[0]) ** (365 / len(aapl_index)) - 1:.2%}")
9. Common Pitfalls and How to Avoid Them
| Pitfall | Symptom | Fix |
|---|---|---|
| Using pre-adjusted price data | Factor table has all factors = 1.0 | Request adjust: none from your data vendor |
| Wrong dividend effective date | Small but persistent return discrepancies vs. CRSP | Use ex-date, not pay date |
| Ignoring the buffer window | Early prices have wrong factors due to look-ahead bias | Always fetch 30+ days before your analysis start date |
| Not handling missing trading days | Factor applies on wrong day after a holiday | Always index by trading dates, not calendar dates |
| Treating special dividends as regular | Special dividend distorts historical factor chain | Separate special dividends (return-of-capital) from regular |
| Floating-point accumulation error | Factors drift by <0.01% over decades | Use np.float128 for the cumulative product; verify with CRSP |
10. Summary and Next Steps
A production-grade price adjustment pipeline is not optional for serious quant research. Raw price data is a historical record of what the market printed — it is not a performance measurement tool. Without adjustment factors, every backtest that spans a stock split, dividend, or corporate restructuring is structurally wrong.
The key takeaways from this article:
Adjustment factors are multiplicative chains. Each corporate action generates a factor; the cumulative factor is the product of all factors from a given date forward.
CRSP standard uses right-adjustment. All factors are anchored to the most recent price. Historical prices are scaled down (not future prices scaled up).
Ex-dates matter for dividends, not pay dates. The factor applies on the ex-dividend date, which determines whether you are entitled to the dividend.
Validation is non-negotiable. Cross-check your factor table against CRSP benchmarks. Even small divergences compound over long backtest periods.
Pipeline design determines reproducibility. A well-structured
PriceAdjustmentPipelineclass ensures that every security in your universe is processed consistently, with full auditability and parquet persistence.
Next steps:
- If you need 10+ years of cleaned, aligned US equity OHLCV data as input to this pipeline, sign up at tickdb.ai for a free API key.
- If you are building this pipeline for a team or institutional research environment, reach out to enterprise@tickdb.ai for discussion of data licensing and API rate limits for large-universe processing.
- If you want to install the TickDB market data SKILL in your AI coding assistant for direct API access within your development environment, search for
tickdb-market-datain your AI tool's skill marketplace.
This article does not constitute investment advice. Backtested performance does not guarantee future results. Corporate action data requires accurate sourcing and continuous maintenance.