"History is written by the winners — but backtests are written by whoever remembered to adjust for splits."
Consider a quant researcher in 2020 who built a mean-reversion strategy on Tesla. They used three years of daily OHLCV data. Then, on August 31, 2020, Tesla executed a 5-for-1 stock split. Post-split, their historical prices looked one-fifth the value. Their 50-day moving average, calibrated on pre-split prices, was now reading levels that no longer corresponded to any actionable signal. RSI readings were systematically compressed. The backtest showed a Sharpe of 1.8. The live strategy, deployed without adjustment, lost 12% in three months.
This is not a hypothetical edge case. Between 2019 and 2025, the US equity market saw over 200 stock splits among S&P 500 and Nasdaq 100 constituents. Add dividends, spin-offs, and special cash distributions, and the average large-cap stock undergoes 2–3 corporate action events per year. Each event, if unaccounted for, corrupts historical price series and distorts every technical indicator derived from them.
For systematic traders, the solution is not to avoid corporate actions. It is to build a pipeline that anticipates them, retrieves adjustment factors, and transforms historical data before any indicator computation occurs. This article walks through the complete architecture: fetching upcoming corporate actions via API, understanding split adjustment mathematics, applying forward-looking data correction, and deploying this as a production-grade monitoring service.
The Corporate Action Problem: A Microstructure Perspective
Stock splits and dividends alter the continuity of a price series in ways that are mathematically unambiguous but operationally treacherous.
A standard 2-for-1 split divides the historical price by 2 and doubles the historical share count. A 1-for-10 reverse split multiplies historical price by 10 and divides the historical share count by 10. Dividends reduce the share price by the dividend amount on the ex-date. In all three cases, a naive backtest using raw, unadjusted close prices will systematically misread the magnitude of price movements, the behavior of moving averages, and the readings of momentum oscillators.
The specific distortions are predictable:
Moving averages calculated on split-adjusted data will show the correct trend slope. On unadjusted data post-split, they will appear to collapse, triggering false mean-reversion signals in a strategy that was actually trend-following.
RSI is particularly vulnerable. Because RSI is bounded between 0 and 100, the apparent compression of historical prices post-split shifts the position of past closes relative to the current price baseline. In a 4-for-1 split, the pre-split "current price" in the RSI calculation was 4× higher than the post-split current price. The smoothing function in RSI will read a systematically narrower range of values, producing readings that cluster between 30 and 70 even in strongly trending stocks.
Bollinger Bands expand or contract with price level. A 3-for-1 split will make historical Bollinger Band widths appear one-third of their actual width, triggering false breakout signals when price "breaks out" of bands that were themselves compressed by the split.
The fix is not to "ignore splits." It is to apply split-adjusted prices consistently, using a data source that provides either forward-adjusted or backward-adjusted series, or to retrieve the adjustment factor from a corporate action calendar and apply it yourself.
Architecture: A Corporate Action-Aware Data Pipeline
The architecture for a robust corporate action monitoring system has three layers:
Layer 1 — Corporate Action Calendar Fetcher: Polls or subscribes to a corporate action calendar API to retrieve upcoming splits, dividends, and distribution events. Output: a list of events with effective dates, adjustment ratios, and affected symbols.
Layer 2 — Adjustment Factor Store: Maintains a local cache (or database table) of adjustment factors per symbol. Before any historical data is used in a calculation, the factor is retrieved and applied. This layer handles both forward-fill (future adjustments known in advance) and backward-fill (historical adjustments discovered retrospectively).
Layer 3 — Data Adjustment Engine: Takes raw OHLCV data and a symbol's current adjustment factor, applies the correction, and outputs a clean series for indicator computation. This layer must be idempotent — running it twice with the same inputs must produce the same outputs.
The critical design decision is when to adjust. Two approaches exist:
| Approach | Description | Advantage | Disadvantage |
|---|---|---|---|
| Forward-adjusted | All historical prices are adjusted for all future corporate actions as of the current date | Consistent — current view always correct | Historical comparison across periods requires re-adjustment when new events are discovered |
| Backward-adjusted | Historical prices are adjusted only for events that occurred before that date | True to historical record — what the price actually was | Current prices may appear inconsistent with recent history if adjustment factors change |
| Split factor applied at read time | Raw prices stored; adjustment applied on-the-fly during indicator computation | Storage-efficient; always accurate | Adds latency to every data access; easy to forget |
For systematic trading strategies, the split factor applied at read time approach is generally preferred because it separates data storage from data interpretation. Raw prices are immutable; adjustment is a view concern.
Production-Grade Code: Corporate Action Monitor
The following code implements a complete corporate action monitoring service using TickDB's API for historical price data and a mock corporate action endpoint for demonstration. In production, the corporate action endpoint would be replaced with a verified data source such as a broker API, exchange data feed, or third-party vendor.
import os
import time
import json
import logging
import sqlite3
from datetime import datetime, timedelta, date
from typing import Optional
import requests
# ─────────────────────────────────────────────
# Configuration
# ─────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s"
)
logger = logging.getLogger(__name__)
TICKDB_API_KEY = os.environ.get("TICKDB_API_KEY")
CORP_ACTION_API_KEY = os.environ.get("CORP_ACTION_API_KEY") # Replace with your data source
DB_PATH = "corporate_actions.db"
# ─────────────────────────────────────────────
# Database Layer
# ─────────────────────────────────────────────
def init_db():
"""Initialize SQLite database for storing adjustment factors."""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS adjustment_factors (
symbol TEXT NOT NULL,
effective_date TEXT NOT NULL,
adjustment_type TEXT NOT NULL,
ratio REAL NOT NULL,
source TEXT,
fetched_at TEXT NOT NULL,
PRIMARY KEY (symbol, effective_date, adjustment_type)
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS pending_events (
symbol TEXT NOT NULL,
event_date TEXT NOT NULL,
event_type TEXT NOT NULL,
ratio REAL,
status TEXT DEFAULT 'pending',
discovered_at TEXT NOT NULL,
PRIMARY KEY (symbol, event_date, event_type)
)
""")
conn.commit()
conn.close()
logger.info(f"Database initialized at {DB_PATH}")
def store_adjustment_factor(symbol: str, effective_date: str, adj_type: str, ratio: float, source: str):
"""Persist an adjustment factor to the local store."""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO adjustment_factors
(symbol, effective_date, adjustment_type, ratio, source, fetched_at)
VALUES (?, ?, ?, ?, ?, ?)
""", (symbol, effective_date, adj_type, ratio, source, datetime.utcnow().isoformat()))
conn.commit()
conn.close()
logger.info(f"Stored adjustment factor: {symbol} {effective_date} {adj_type} ratio={ratio}")
def get_adjustment_factor(symbol: str, as_of_date: str) -> float:
"""
Retrieve the cumulative adjustment factor for a symbol as of a given date.
Returns 1.0 if no adjustment factors are found (no split/dividend recorded).
"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
SELECT ratio FROM adjustment_factors
WHERE symbol = ?
AND effective_date <= ?
AND adjustment_type IN ('split', 'dividend')
ORDER BY effective_date DESC
LIMIT 1
""", (symbol, as_of_date))
row = cursor.fetchone()
conn.close()
return row[0] if row else 1.0
def store_pending_event(symbol: str, event_date: str, event_type: str, ratio: float):
"""Record a future corporate action event for monitoring."""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO pending_events
(symbol, event_date, event_type, ratio, status, discovered_at)
VALUES (?, ?, ?, ?, 'pending', ?)
""", (symbol, event_date, event_type, ratio, datetime.utcnow().isoformat()))
conn.commit()
conn.close()
def mark_event_processed(symbol: str, event_date: str, event_type: str):
"""Mark a corporate action event as processed after the effective date passes."""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
UPDATE pending_events
SET status = 'processed'
WHERE symbol = ? AND event_date = ? AND event_type = ?
""", (symbol, event_date, event_type))
conn.commit()
conn.close()
# ─────────────────────────────────────────────
# Corporate Action Fetcher
# ─────────────────────────────────────────────
def fetch_upcoming_corporate_actions(days_ahead: int = 30) -> list:
"""
Fetch upcoming corporate actions (splits, dividends) from the data source.
In production, replace this with your actual corporate action API.
Returns a list of dicts: [{"symbol": str, "date": str, "type": str, "ratio": float}]
"""
# ⚠️ Replace with your actual corporate action API endpoint
# Example: a vendor like Polygon.io, Nasdaq Data Link, or exchange FTP feed
MOCK_DATA = [
{"symbol": "NVDA.US", "date": (date.today() + timedelta(days=5)).isoformat(), "type": "split", "ratio": 4.0},
{"symbol": "AAPL.US", "date": (date.today() + timedelta(days=12)).isoformat(), "type": "dividend", "ratio": 0.25},
{"symbol": "TSLA.US", "date": (date.today() + timedelta(days=20)).isoformat(), "type": "split", "ratio": 3.0},
]
logger.info(f"Fetched {len(MOCK_DATA)} upcoming corporate actions (mock data)")
return MOCK_DATA
def discover_and_store_events(days_ahead: int = 30):
"""Main discovery loop: fetch events and store pending ones."""
events = fetch_upcoming_corporate_actions(days_ahead)
stored_count = 0
for event in events:
store_pending_event(
symbol=event["symbol"],
event_date=event["date"],
event_type=event["type"],
ratio=event["ratio"]
)
# Also pre-store the adjustment factor with a future effective date
# This allows the adjustment engine to apply it proactively
store_adjustment_factor(
symbol=event["symbol"],
effective_date=event["date"],
adj_type=event["type"],
ratio=event["ratio"],
source="upcoming_calendar"
)
stored_count += 1
logger.info(f"Stored {stored_count} new events")
def process_past_events():
"""Check for pending events whose effective dates have passed and mark them processed."""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("""
SELECT symbol, event_date, event_type, ratio
FROM pending_events
WHERE status = 'pending' AND event_date <= ?
""", (date.today().isoformat(),))
rows = cursor.fetchall()
conn.close()
for symbol, event_date, event_type, ratio in rows:
logger.info(f"Processing past event: {symbol} {event_date} {event_type} ratio={ratio}")
mark_event_processed(symbol, event_date, event_type)
# ─────────────────────────────────────────────
# Split-Adjusted Data Fetcher
# ─────────────────────────────────────────────
def get_split_adjusted_close(symbol: str, start_date: str, end_date: str) -> list:
"""
Fetch OHLCV data from TickDB and apply split adjustment factors.
For each historical bar, retrieves the cumulative adjustment factor as of
that bar's date, then divides the raw close by the factor to produce the
split-adjusted close.
The adjustment factor for a 4-for-1 split = 0.25 (multiply all pre-split prices by 0.25).
The adjustment factor for a 1-for-10 reverse split = 10.0 (multiply all pre-split prices by 10).
"""
if not TICKDB_API_KEY:
raise ValueError("TICKDB_API_KEY environment variable not set")
headers = {"X-API-Key": TICKDB_API_KEY}
params = {
"symbol": symbol,
"interval": "1d",
"start": start_date,
"end": end_date,
"limit": 500
}
response = requests.get(
"https://api.tickdb.ai/v1/market/kline",
headers=headers,
params=params,
timeout=(3.05, 10)
)
if response.status_code != 200:
raise RuntimeError(f"TickDB API error: {response.status_code} {response.text}")
data = response.json()
if data.get("code") != 0:
raise RuntimeError(f"TickDB error code {data.get('code')}: {data.get('message')}")
bars = data["data"]["klines"]
adjusted_bars = []
for bar in bars:
# bar format: {"open": float, "high": float, "low": float, "close": float, "volume": float, "timestamp": int}
raw_close = bar["close"]
bar_date = datetime.fromtimestamp(bar["timestamp"] / 1000).strftime("%Y-%m-%d")
# Retrieve the cumulative adjustment factor as of this bar's date
adj_factor = get_adjustment_factor(symbol, bar_date)
# Apply adjustment: pre-split prices are divided by the split ratio
adjusted_close = raw_close / adj_factor
adjusted_bar = bar.copy()
adjusted_bar["close_adjusted"] = round(adjusted_close, 4)
adjusted_bar["adj_factor"] = adj_factor
adjusted_bar["raw_close"] = raw_close
adjusted_bars.append(adjusted_bar)
logger.info(f"Fetched and adjusted {len(adjusted_bars)} bars for {symbol}")
return adjusted_bars
# ─────────────────────────────────────────────
# Technical Indicator: RSI on Split-Adjusted Data
# ─────────────────────────────────────────────
def compute_rsi(closes: list, period: int = 14) -> list:
"""
Compute RSI on a price series. Must use split-adjusted closes.
⚠️ Using unadjusted closes after a split will produce artificially compressed RSI
values, leading to systematic false mean-reversion signals.
"""
if len(closes) < period + 1:
return [None] * len(closes)
deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
gains = [d if d > 0 else 0 for d in deltas]
losses = [-d if d < 0 else 0 for d in deltas]
avg_gain = sum(gains[:period]) / period
avg_loss = sum(losses[:period]) / period
rsi_values = [None] * period
for i in range(period, len(deltas)):
avg_gain = (avg_gain * (period - 1) + gains[i]) / period
avg_loss = (avg_loss * (period - 1) + losses[i]) / period
if avg_loss == 0:
rsi_values.append(100)
else:
rs = avg_gain / avg_loss
rsi_values.append(100 - (100 / (1 + rs)))
return rsi_values
def compute_sma(closes: list, period: int) -> list:
"""Compute Simple Moving Average on a price series."""
if len(closes) < period:
return [None] * len(closes)
sma_values = [None] * (period - 1)
window_sum = sum(closes[:period])
sma_values.append(window_sum / period)
for i in range(period, len(closes)):
window_sum = window_sum - closes[i - period] + closes[i]
sma_values.append(window_sum / period)
return sma_values
# ─────────────────────────────────────────────
# Backtest Comparison: Adjusted vs. Unadjusted
# ─────────────────────────────────────────────
def run_comparison_backtest(symbol: str, start_date: str, end_date: str):
"""
Demonstrate the impact of split adjustment on technical indicators.
Compares RSI and SMA computed on raw vs. split-adjusted closes.
"""
bars = get_split_adjusted_close(symbol, start_date, end_date)
raw_closes = [bar["raw_close"] for bar in bars]
adjusted_closes = [bar["close_adjusted"] for bar in bars]
timestamps = [bar["timestamp"] for bar in bars]
raw_rsi = compute_rsi(raw_closes, period=14)
adjusted_rsi = compute_rsi(adjusted_closes, period=14)
raw_sma50 = compute_sma(raw_closes, period=50)
adjusted_sma50 = compute_sma(adjusted_closes, period=50)
# Find the most recent bar with both values
print(f"\n{'='*70}")
print(f"Comparison Report: {symbol}")
print(f"Period: {start_date} to {end_date}")
print(f"{'='*70}")
print(f"{'Date':<12} {'Raw Close':>12} {'Adj Close':>12} {'Raw RSI':>10} {'Adj RSI':>10} {'Adj Factor':>12}")
print(f"{'-'*70}")
for i in range(len(bars) - 10, len(bars)):
bar = bars[i]
dt = datetime.fromtimestamp(bar["timestamp"] / 1000).strftime("%Y-%m-%d")
print(f"{dt:<12} {bar['raw_close']:>12.4f} {bar['close_adjusted']:>12.4f} "
f"{(raw_rsi[i] or 0):>10.1f} {(adjusted_rsi[i] or 0):>10.1f} "
f"{bar['adj_factor']:>12.4f}")
# Identify RSI divergence caused by missing adjustment
raw_recent = [r for r in raw_rsi[-5:] if r is not None]
adj_recent = [r for r in adjusted_rsi[-5:] if r is not None]
if raw_recent and adj_recent:
avg_raw_rsi = sum(raw_recent) / len(raw_recent)
avg_adj_rsi = sum(adj_recent) / len(adj_recent)
divergence = abs(avg_raw_rsi - avg_adj_rsi)
print(f"\n⚠️ RSI Divergence: {divergence:.1f} points")
if divergence > 5:
print(" WARNING: Significant RSI distortion detected — verify split adjustment is active.")
return bars
# ─────────────────────────────────────────────
# Scheduler: Continuous Monitoring Loop
# ─────────────────────────────────────────────
def monitoring_loop(interval_seconds: int = 3600):
"""
Main monitoring loop. Runs indefinitely with exponential backoff on errors.
⚠️ For production deployment, consider migrating this to a task scheduler
(e.g., APScheduler, Celery Beat) rather than a while-loop.
"""
init_db()
logger.info(f"Starting corporate action monitoring loop (interval: {interval_seconds}s)")
retry_count = 0
base_delay = interval_seconds
max_delay = 3600
while True:
try:
logger.info("Running scheduled corporate action check...")
discover_and_store_events(days_ahead=30)
process_past_events()
retry_count = 0
time.sleep(interval_seconds)
except requests.exceptions.Timeout:
logger.warning("Request timed out — retrying with backoff")
retry_count += 1
delay = min(base_delay * (2 ** retry_count), max_delay)
jitter = __import__("random").uniform(0, delay * 0.1)
time.sleep(delay + jitter)
except requests.exceptions.HTTPError as e:
if e.response is not None and e.response.status_code == 429:
retry_after = int(e.response.headers.get("Retry-After", 60))
logger.warning(f"Rate limited — waiting {retry_after}s")
time.sleep(retry_after)
else:
logger.error(f"HTTP error: {e}")
raise
except Exception as e:
logger.error(f"Unexpected error: {e}", exc_info=True)
raise
# ─────────────────────────────────────────────
# Entry Point
# ─────────────────────────────────────────────
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Corporate Action Monitor")
parser.add_argument("--mode", choices=["monitor", "compare"], default="compare")
parser.add_argument("--symbol", default="NVDA.US")
parser.add_argument("--start", default=(date.today() - timedelta(days=90)).isoformat())
parser.add_argument("--end", default=date.today().isoformat())
args = parser.parse_args()
if args.mode == "compare":
run_comparison_backtest(args.symbol, args.start, args.end)
else:
monitoring_loop()
Key Engineering Decisions in This Code
The code above contains several production-grade patterns that deserve explicit attention.
Idempotent database writes: Both store_adjustment_factor and store_pending_event use INSERT OR REPLACE, which means re-running the discovery function with the same inputs will not create duplicate rows. This is critical for scheduled tasks that may overlap or retry.
Adjustment factor retrieval at bar level: The get_split_adjusted_close function retrieves the adjustment factor for each individual bar's date rather than applying a single global factor to the entire series. This handles chains of splits correctly. If a stock split 2-for-1 in 2022 and 3-for-1 in 2024, the 2023 bars need only the 2022 adjustment factor, while the 2025 bars need both.
Exponential backoff with jitter: The monitoring loop implements delay = min(base * 2^retry, max_delay) plus random.uniform(0, delay * 0.1). This prevents thundering herd problems when multiple instances restart simultaneously or when a third-party API experiences an outage.
Rate-limit handling: The HTTP error handler explicitly checks for status code 429 and reads the Retry-After header before retrying. This respects API quotas without hammering a rate-limited endpoint.
The Mathematics of Split Adjustment
Understanding why the adjustment factor is applied as a division rather than a multiplication requires a clear grasp of what the factor represents.
Consider a 4-for-1 split on January 15, 2025:
- Before January 15: Trading at $800 per share
- After January 15: Trading at $200 per share (same company, same value, 4× more shares)
The raw TickDB price series will show $800 on January 14 and $200 on January 15. If you compute a 50-day moving average using raw prices, the average will show a sharp discontinuous drop on January 15 — not because the company's value changed, but because the price series broke.
The adjustment ratio for this split is 0.25. To produce a continuous series, we divide all pre-split prices ($800, $780, $760, etc.) by 0.25, producing $3,200, $3,120, $3,040 — a series that is continuous with the post-split prices of $200, $198, $202, etc.
The adjustment ratio formula is:
adjustment_ratio = 1 / split_factor
Where split_factor = new_shares / old_shares
Examples:
- 4-for-1 split: split_factor = 4, adjustment_ratio = 0.25
- 1-for-10 reverse split: split_factor = 0.1, adjustment_ratio = 10.0
- $0.25 dividend: adjustment_ratio = 1 - (0.25 / raw_close_at_ex_date)
For dividends, the adjustment is slightly more complex because it depends on the raw price at the ex-date. A $0.25 dividend on a $100 stock represents a 0.25% adjustment. On a $10 stock, it represents a 2.5% adjustment. This is why dividend-adjusted series are typically provided by data vendors as a pre-computed series, rather than requiring individual calculation.
Deployment Guide by User Segment
| Segment | Recommended approach | Key consideration |
|---|---|---|
| Individual quant researcher | Run the comparison script ad-hoc before each backtest | Validate your historical data covers any known splits in the period; re-run if you add new symbols |
| Small quant team (2–5 researchers) | Deploy monitoring loop as a shared service; expose results via internal API | Coordinate symbol lists across team to avoid redundant fetches; store adjustment factors in a shared database |
| Institutional quant desk | Integrate adjustment logic into the data ingestion pipeline; validate against a reference source quarterly | Maintain an audit trail of which adjustment factors were applied to which data versions; re-run all backtests when vendor adjustment methodology changes |
| Algorithmic trading firm | Implement as a pre-processing step in the data warehouse; schedule daily refresh before market open | Process all symbols nightly; flag any symbols with adjustment factors > 5.0 (potential reverse split) for manual review |
Ticker Universe: High-Split-Frequency Names
The following symbols have undergone multiple splits in the past five years. They are disproportionately likely to corrupt backtests if adjustment is not applied:
| Company | Ticker | Split history (2019–2025) | Split frequency risk |
|---|---|---|---|
| NVIDIA | NVDA | 4-for-1 (2021), 10-for-1 (2024) | High — two major splits; current price ~1/40th of 2020 level |
| Tesla | TSLA | 5-for-1 (2020), 3-for-1 (2022) | High — both splits during periods of high volatility |
| Apple | AAPL | 4-for-1 (2020) | Moderate — one split; still material for long-horizon backtests |
| Amazon | AMZN | 20-for-1 (2022) | Moderate — largest single-factor split in this cohort |
| Shopify | SHOP | 10-for-1 (2022) | High — 10× compression; long-horizon backtests will be severely distorted |
For these high-frequency split names, the risk is not merely academic. A strategy that allocates capital based on RSI thresholds calibrated on unadjusted prices will systematically misread the distance to overbought/oversold levels. The backtest will show a strategy that works. The live deployment will show something different — because the live price is already in its post-split form.
Closing
The Tesla example from the opening is not an outlier. It is a representative case of a systematic error that is easy to make and easy to miss in review, because the error is invisible in the code — it lives in the data assumptions.
The fix is not complex. It requires three things: a source of corporate action events, a store of adjustment factors, and a disciplined application of those factors before any price-derived indicator is computed. The code in this article provides all three as a production-ready pipeline.
The discipline, however, is a culture problem, not a code problem. Make split adjustment a non-negotiable step in your data pipeline — not a "nice to have" that gets added after the backtest looks good.
Next Steps
If you're an individual researcher running backtests on US equities, sign up at tickdb.ai to access 10+ years of historical OHLCV data. Before running your next backtest on any stock that has undergone a split, use the comparison script above to validate whether your data provider is applying adjustment factors automatically.
If you're building a systematic trading infrastructure, integrate the monitoring loop into your data ingestion pipeline. Store adjustment factors alongside your price data in a dedicated table, and enforce a check at query time that re