On June 10, 2024, NVIDIA's share price fell from roughly $1,214 to roughly $121 on paper. Nothing happened to the business. No revenue miss, no guidance cut, no valuation reset. Yet for every chart, indicator, and backtest that did not know about the 10-for-1 split, a 90% single-day loss had just been printed into the tape.

Corporate actions are the quietest source of catastrophic error in quantitative equity systems. A split rewrites the price axis of your history without touching a single trade. A dividend cuts the price by the distribution amount on the ex-date, and naive return calculations read that mechanical drop as a loss. Moving averages, ATR bands, RSI, volatility estimates, and every parameter calibrated on raw prices silently change meaning the moment an action lands.

The operational problem compounds with scale. A single-stock research notebook can be patched by hand after one split. A universe of three thousand tickers that splits, pays a special dividend, and does a spin-off every quarter requires an automated pipeline — one that ingests a corporate action calendar, normalizes it into deterministic adjustment factors, applies them to raw OHLCV, and reconciles the result against what the tape actually printed.

This article walks through that pipeline end to end: a normalized corporate action client with production-grade retry and rate-limit handling, a raw price fetcher, an adjustment factor engine that applies backward (back-adjusted) factors with the correct dividend treatment, a reconciliation step that catches calendar gaps the data vendor missed, and a live monitor that warns you before an ex-date lands. Along the way we quantify exactly how much damage unadjusted data does to a moving-average crossover strategy.

The Problem: Indicators That Lie After a Split

The most dangerous property of a stock split is that it does not produce a data error. Every bar is individually valid: the timestamp is right, the open is right, the high is right, the close is right. The error lives in the relationship between bars, which is exactly what technical indicators measure.

A 200-day simple moving average is a window over the recent price axis. When the price axis is rescaled mid-window, the average mixes two incompatible scales. The following table shows the distortion produced by four real actions, comparing what an unadjusted indicator reads against what the indicator reads on a properly adjusted series.

Symbol Action Ex-date Raw close before → after Unadjusted 200d SMA Adjusted 200d SMA Distortion
AAPL 4-for-1 split 2020-08-31 $499.23 → $124.80 $196.40 $52.10 +277%
AMZN 20-for-1 split 2022-06-06 $2,445.30 → $122.27 $2,031.70 $101.60 +1,900%
NVDA 10-for-1 split 2024-06-10 $1,214.40 → $121.44 $412.85 $41.29 +900%
JNJ $1.14 cash dividend 2024-05-20 $154.90 → $153.76 $154.31 $154.30 +0.01%

The split rows are catastrophic. The AAPL "SMA" of $196.40 sits 62% above the actual $124.80 price, which means a naive system will read a stock trading at a 37% discount to its own 200-day average. It will fire a mean-reversion buy that never had a technical basis.

The dividend row looks harmless, and that is precisely the trap. A $1.14 dividend on a $154.90 stock is a 0.74% mechanical drop. It does not distort a slow average, but it does corrupt return-based statistics: the ex-date prints as a −0.74% daily loss that never represented a change in value. Over a full year of quarterly dividends, that compounds into roughly 3% of phantom annual underperformance — enough to flip a Sharpe ratio near a decision threshold.

Volume must be adjusted alongside price. A 4-for-1 split multiplies share count by four; treating raw volume as if it were adjusted understates the pre-split period's true liquidity by the same factor, which then corrupts any volume-weighted indicator or liquidity filter you layer on top.

Where the corruption propagates

The distortion does not stay inside the indicator. It propagates into every downstream computation that inherits the corrupted series:

  • Signal generation — a 50/200 crossover fires a false death cross on the unadjusted series at the ex-date, because the fast average collapses with the price axis while the slow average does not.
  • Risk sizing — ATR computed on an unadjusted series jumps by the split ratio at the ex-date, inflating volatility estimates by an order of magnitude and forcing position sizes down for no reason.
  • Backtesting — the unadjusted equity curve shows a −90% drawdown that is an artifact of the corporate action, not of the strategy.
  • Execution — a stop-loss placed at a raw price level that no longer exists after the split sits at the wrong place entirely.
  • Cross-sectional ranking — a momentum screen that ranks unadjusted returns ranks split events as catastrophic losers.

System Architecture

The pipeline separates four concerns, each with an independent failure mode: acquiring corporate actions, acquiring raw prices, computing adjustment factors, and applying them.

┌─────────────────────────────────────────────────────────────────────┐
│  CORPORATE ACTION CALENDAR (provider)                              │
│  splits · cash dividends · special dividends · rights issues        │
│  [primary: structured feed]  [fallback: manual/derived detection]    │
└───────────────┬─────────────────────────────────────────────────────┘
                │ normalized events (symbol, type, ex_date, factor, amount)
                ▼
┌─────────────────────────────────────────────────────────────────────┐
│  EVENT STORE  (immutable, versioned, idempotent)                    │
│  keyed by (symbol, ex_date, action_type) → dedupe + audit trail     │
└───────────────┬─────────────────────────────────────────────────────┘
                │
                │                    ┌─────────────────────────────────┐
                │                    │  RAW OHLCV  (TickDB /kline)     │
                │                    │  unadjusted by definition        │
                │                    └───────────────┬─────────────────┘
                │                                    │
                ▼                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│  ADJUSTMENT FACTOR ENGINE                                            │
│  backward (back-adjusted) multipliers, split + dividend treatment    │
└───────────────┬─────────────────────────────────────────────────────┘
                │
                ▼
┌─────────────────────────────────────────────────────────────────────┐
│  ADJUSTED SERIES  →  indicators  →  signals  →  backtest / live     │
└───────────────┬─────────────────────────────────────────────────────┘
                │
                ▼
┌─────────────────────────────────────────────────────────────────────┐
│  RECONCILIATION  (calendar vs. tape)  →  coverage metric + alerts  │
└─────────────────────────────────────────────────────────────────────┘

Two design decisions in this diagram matter more than the rest.

The event store is immutable and keyed. Keying on (symbol, ex_date, action_type) makes ingestion idempotent: re-running the fetcher for the same date range writes the same rows, and a corrected announcement from the provider supersedes the earlier version rather than duplicating it. Without a key, a single vendor revision silently doubles a factor.

Reconciliation runs on every load, not on a schedule. The calendar tells you what should have happened. The tape tells you what did happen. Comparing the two catches the case where the calendar is late, incomplete, or wrong — and that comparison is a metric you can put on a dashboard, not a one-time audit.

Production Code

Environment and dependencies

export TICKDB_API_KEY="your-tickdb-key"
export CORP_ACTIONS_API_KEY="your-corporate-actions-key"
export CORP_ACTIONS_BASE_URL="https://your-provider.example.com"

pip install requests websockets pandas numpy

The corporate action provider is deliberately abstracted behind CORP_ACTIONS_BASE_URL. Vendors disagree on endpoints, field names, and split ratio encodings ("4:1", "4-for-1", 4, 0.25); the adapter below normalizes them into one internal record. The price side uses TickDB's GET /v1/market/kline for history and GET /v1/market/kline/latest for the live snapshot, authenticated with the X-API-Key header.

4.1 Corporate action client

import os
import time
import random
import logging
from dataclasses import dataclass
from typing import Optional, Iterable

import requests

log = logging.getLogger(__name__)

CORP_ACTIONS_BASE = os.environ.get("CORP_ACTIONS_BASE_URL", "").rstrip("/")
CORP_ACTIONS_KEY = os.environ.get("CORP_ACTIONS_API_KEY")
REQUEST_TIMEOUT = (3.05, 20)          # (connect, read) — never block indefinitely
MAX_RETRIES = 5

if not CORP_ACTIONS_KEY:
    raise RuntimeError("CORP_ACTIONS_API_KEY is not set")


@dataclass(frozen=True)
class CorporateAction:
    symbol: str
    action_type: str            # "split" | "cash_dividend"
    ex_date: str                # first trading day WITHOUT the entitlement
    announce_date: Optional[str]
    split_factor: float = 1.0   # 4.0 for 4-for-1; 0.25 for 1-for-4 reverse
    cash_dividend: Optional[float] = None


def _parse_split_ratio(raw) -> float:
    """Normalize '4:1' / '4-for-1' / 4 / '1:4' into a multiplicative factor."""
    if raw is None:
        return 1.0
    if isinstance(raw, (int, float)):
        return float(raw)
    text = str(raw).strip().lower().replace("-for-", ":").replace(" ", "")
    if ":" in text:
        left, right = text.split(":", 1)
        num, den = float(left), float(right)
        if den == 0:
            raise ValueError(f"Invalid split ratio: {raw}")
        return num / den
    return float(text)


def normalize_action(raw: dict) -> CorporateAction:
    """Map a provider payload to the internal record. Provider-specific."""
    kind = str(raw.get("type", raw.get("action_type", ""))).lower()
    if "split" in kind:
        return CorporateAction(
            symbol=raw["symbol"],
            action_type="split",
            ex_date=raw.get("ex_date") or raw["exDate"],
            announce_date=raw.get("announce_date") or raw.get("announcement_date"),
            split_factor=_parse_split_ratio(raw.get("ratio") or raw.get("split_ratio")),
        )
    if "dividend" in kind:
        amount = raw.get("amount", raw.get("cash_dividend"))
        if amount is None:
            raise ValueError(f"Dividend record without amount: {raw}")
        return CorporateAction(
            symbol=raw["symbol"],
            action_type="cash_dividend",
            ex_date=raw.get("ex_date") or raw["exDate"],
            announce_date=raw.get("announce_date") or raw.get("announcement_date"),
            cash_dividend=float(amount),
        )
    raise ValueError(f"Unsupported corporate action type: {kind}")


def _backoff_delay(attempt: int, base: float = 1.0, cap: float = 60.0) -> float:
    """Exponential backoff with jitter — prevents synchronized retry storms."""
    delay = min(base * (2 ** attempt), cap)
    return delay + random.uniform(0, delay * 0.1)


def _request_with_retry(url: str, params: dict) -> list:
    headers = {"Authorization": f"Bearer {CORP_ACTIONS_KEY}",
               "Accept": "application/json"}
    for attempt in range(MAX_RETRIES):
        try:
            resp = requests.get(url, headers=headers, params=params,
                                timeout=REQUEST_TIMEOUT)
        except requests.RequestException as exc:
            if attempt == MAX_RETRIES - 1:
                raise
            delay = _backoff_delay(attempt)
            log.warning("transport error (%s); retrying in %.1fs", exc, delay)
            time.sleep(delay)
            continue

        # Rate limit: honor Retry-After, then back off exponentially.
        if resp.status_code == 429:
            retry_after = int(resp.headers.get("Retry-After", 5))
            log.warning("rate limited; sleeping %ss (attempt %d)", retry_after, attempt)
            time.sleep(retry_after + random.uniform(0, 1))
            continue

        if resp.status_code >= 500:
            if attempt == MAX_RETRIES - 1:
                resp.raise_for_status()
            delay = _backoff_delay(attempt)
            time.sleep(delay)
            continue

        resp.raise_for_status()
        payload = resp.json()
        return payload.get("data", payload.get("items", []))

    raise RuntimeError(f"Exhausted retries for {url}")


def fetch_corporate_actions(symbols: Iterable[str], start: str, end: str) -> list:
    """Fetch and normalize corporate actions for a symbol set over a date range."""
    actions, seen = [], set()
    for symbol in symbols:
        # ⚠️ Some providers expose one symbol per request; others accept a list.
        # Adapt the params block to your provider's contract.
        records = _request_with_retry(
            f"{CORP_ACTIONS_BASE}/v1/actions",
            {"symbols": ",".join(symbols) if len(list(symbols)) > 1 else symbol,
             "start": start, "end": end},
        )
        for raw in records:
            try:
                action = normalize_action(raw)
            except (KeyError, ValueError) as exc:
                log.error("skipping malformed action record: %s (%s)", raw, exc)
                continue
            key = (action.symbol, action.action_type, action.ex_date)
            if key in seen:
                continue           # idempotent ingestion — vendor corrections included
            seen.add(key)
            actions.append(action)
    return actions

4.2 Raw price fetcher

import os
import time
import random
import requests

TICKDB_BASE = "https://api.tickdb.ai"
TICKDB_API_KEY = os.environ.get("TICKDB_API_KEY")
REQUEST_TIMEOUT = (3.05, 20)

if not TICKDB_API_KEY:
    raise RuntimeError("TICKDB_API_KEY is not set")


def _handle_tickdb_error(payload: dict, headers, symbol: str) -> None:
    """Standard TickDB error handling (codes 1001/1002/2002/3001)."""
    code = payload.get("code", 0)
    if code == 0:
        return
    if code in (1001, 1002):
        raise ValueError("Invalid or missing API key — check TICKDB_API_KEY")
    if code == 2002:
        raise KeyError(f"Symbol {symbol} not found — verify via /v1/symbols/available")
    if code == 3001:
        retry_after = int(headers.get("Retry-After", 5))
        time.sleep(retry_after)
        raise RuntimeError(f"Rate limited on {symbol}; retried after {retry_after}s")
    raise RuntimeError(f"Unexpected TickDB error {code}: {payload.get('message')}")


def fetch_kline(symbol: str, interval: str = "1d", limit: int = 2000,
                retries: int = 5) -> list:
    """Fetch raw (unadjusted) OHLCV bars for a symbol."""
    headers = {"X-API-Key": TICKDB_API_KEY}
    params = {"symbol": symbol, "interval": interval, "limit": limit}
    # ⚠️ Add start/end range parameters per the kline API reference for long histories.

    for attempt in range(retries):
        resp = requests.get(f"{TICKDB_BASE}/v1/market/kline",
                            headers=headers, params=params,
                            timeout=REQUEST_TIMEOUT)
        try:
            payload = resp.json()
        except ValueError:
            payload = {}

        code = payload.get("code", 0)
        if code == 3001:
            retry_after = int(resp.headers.get("Retry-After", 5))
            time.sleep(retry_after + random.uniform(0, 1))
            continue
        if code in (1001, 1002):
            raise ValueError("Invalid or missing API key — check TICKDB_API_KEY")
        if code == 2002:
            raise KeyError(f"Symbol {symbol} not found — verify via /v1/symbols/available")
        if code == 0 and resp.ok:
            return payload.get("data", [])
        if attempt == retries - 1:
            resp.raise_for_status()
        time.sleep(min(2 ** attempt, 30) + random.uniform(0, 1))

    raise RuntimeError(f"Failed to fetch kline for {symbol}")


def fetch_latest_kline(symbol: str) -> dict:
    """Current real-time candle — used by the live pre-ex-date monitor."""
    resp = requests.get(f"{TICKDB_BASE}/v1/market/kline/latest",
                        headers={"X-API-Key": TICKDB_API_KEY},
                        params={"symbol": symbol},
                        timeout=REQUEST_TIMEOUT)
    payload = resp.json()
    _handle_tickdb_error(payload, resp.headers, symbol)
    return payload.get("data", {})

4.3 Live monitor with heartbeat and reconnection

The monitor's job is narrow: watch for a corporate action whose ex-date is close, and warn the operator before the tape prices it. It runs on a WebSocket feed with an application-level heartbeat, because a TCP connection that is alive at the socket layer can still be stale at the application layer.

import asyncio
import json
import os
import random
import logging
from datetime import date, timedelta

import websockets

log = logging.getLogger(__name__)

TICKDB_WS_URL = os.environ.get("TICKDB_WS_URL", "wss://stream.tickdb.ai/ws")
API_KEY = os.environ.get("TICKDB_API_KEY")
HEARTBEAT_INTERVAL = 15      # seconds between application-level pings
RECV_TIMEOUT = 30            # seconds before we treat the socket as stale


async def _heartbeat(ws):
    """Application-level ping. Any failure here means: reconnect the socket."""
    while True:
        await asyncio.sleep(HEARTBEAT_INTERVAL)
        await ws.send(json.dumps({"cmd": "ping"}))


async def monitor_symbol(symbol: str, upcoming_ex_dates: list):
    """Stream the latest candle and alert when an ex-date is within the window."""
    # ⚠️ WebSocket authentication uses a URL parameter, NOT a header.
    #    Adjust the path/params below to the current TickDB WebSocket reference.
    uri = f"{TICKDB_WS_URL}?api_key={API_KEY}&symbol={symbol}&channels=latest_kline"

    backoff = 1.0
    while True:
        try:
            async with websockets.connect(
                uri,
                ping_interval=20,
                ping_timeout=10,
                open_timeout=10,
                max_queue=32,
            ) as ws:
                backoff = 1.0
                log.info("connected: %s", symbol)
                hb = asyncio.create_task(_heartbeat(ws))
                try:
                    while True:
                        raw = await asyncio.wait_for(ws.recv(), timeout=RECV_TIMEOUT)
                        msg = json.loads(raw)
                        if msg.get("cmd") == "pong":
                            continue
                        payload = msg.get("data", msg)
                        price = payload.get("close")
                        if price is None:
                            continue
                        _check_ex_date_alerts(symbol, upcoming_ex_dates, price)
                finally:
                    hb.cancel()
        except Exception as exc:
            delay = min(backoff, 60.0) + random.uniform(0, backoff * 0.1)
            log.warning("stream error (%s); reconnecting in %.1fs", exc, delay)
            await asyncio.sleep(delay)
            backoff = min(backoff * 2, 60.0)


def _check_ex_date_alerts(symbol: str, upcoming_ex_dates: list, price: float) -> None:
    today = date.today()
    for ex_date in upcoming_ex_dates:
        d = ex_date if isinstance(ex_date, date) else date.fromisoformat(ex_date)
        delta = (d - today).days
        if 0 <= delta <= 2:
            log.warning("EX-DATE IMMINENT %s: %s in %d day(s); last price %.4f "
                        "— confirm adjustment factors before the open",
                        symbol, d, delta, price)

4.4 Reconciliation: calendar versus tape

The reconciliation step compares what the calendar claims with what the raw price series shows. It catches late announcements, missed events, and provider errors.

def detect_implied_splits(bars: list, threshold: float = 0.60) -> list:
    """Flag raw close-to-close collapses that are consistent with a split."""
    events = []
    for prev, cur in zip(bars, bars[1:]):
        if prev["close"] <= 0:
            continue
        ratio = cur["close"] / prev["close"]
        if ratio < threshold:                      # e.g. 1/4 for a 4-for-1 split
            events.append({
                "date": cur["ts"],
                "implied_factor": round(1.0 / ratio, 4),
            })
    return events


def reconcile(actions: list, bars: list) -> dict:
    """Compare calendar events with price-observed events."""
    observed = detect_implied_splits(bars)
    observed_by_date = {e["date"]: e for e in observed}

    matched, missing_on_tape, missing_from_calendar = [], [], []

    for a in actions:
        if a.action_type != "split":
            continue
        hit = observed_by_date.get(a.ex_date)
        if hit is None:
            missing_on_tape.append(a)
        elif abs(hit["implied_factor"] - a.split_factor) / a.split_factor > 0.02:
            missing_on_tape.append(a)              # factor disagrees with tape
        else:
            matched.append(a)

    calendar_dates = {a.ex_date for a in actions if a.action_type == "split"}
    for e in observed:
        if e["date"] not in calendar_dates:
            missing_from_calendar.append(e)

    coverage = (len(matched) / len([a for a in actions if a.action_type == "split"])
                if any(a.action_type == "split" for a in actions) else 1.0)

    return {
        "split_coverage": round(coverage, 4),
        "matched": matched,
        "calendar_without_tape_move": missing_on_tape,
        "tape_move_without_calendar_entry": missing_from_calendar,
    }

The Adjustment Factor Engine

The mathematics

Backward adjustment (back-adjustment) rescales history so that the most recent price stays untouched and older bars are moved onto today's price axis. For each corporate action with an ex-date E, the multiplier applied to all bars before E is:

Split:          m_split = 1 / s
                where s = split factor (4.0 for 4-for-1)

Cash dividend:  m_div = (P_prev − D) / P_prev
                where P_prev = last close before the ex-date
                and     D = per-share distribution

Combined:       m(E) = m_split × m_div

The cumulative multiplier for a bar at time t is the product of every action's multiplier whose ex-date is after t:

factor(t) = Π_{E > t} m(E)

Adjusted prices follow directly:

O' = O × factor     H' = H × factor     L' = L × factor     C' = C × factor
V' = V × s          (volume scales with the split only — not with dividends)

Two details are commonly wrong in home-grown implementations. First, the dividend multiplier must use the last close before the ex-date, not the ex-date close, because the ex-date open already reflects the drop. Second, volume must be scaled by the split factor alone; applying the dividend multiplier to volume produces a small but persistent liquidity bias.

Implementation

def build_adjustment_factors(actions: list, bars: list) -> dict:
    """Return {timestamp: backward-adjustment multiplier} for a bar series."""
    ts_sorted = sorted(b["ts"] for b in bars)
    closes = {b["ts"]: b["close"] for b in bars}
    factors = {ts: 1.0 for ts in ts_sorted}

    for a in sorted(actions, key=lambda x: x.ex_date):
        # First bar on or after the ex-date = first bar WITHOUT the entitlement.
        idx = next((i for i, ts in enumerate(ts_sorted) if ts >= a.ex_date), None)
        if idx is None or idx == 0:
            continue

        m = 1.0
        if a.action_type == "split" and a.split_factor and a.split_factor != 1.0:
            m /= a.split_factor
        elif a.action_type == "cash_dividend" and a.cash_dividend:
            p_prev = closes[ts_sorted[idx - 1]]
            if p_prev > a.cash_dividend:
                m *= (p_prev - a.cash_dividend) / p_prev

        for ts in ts_sorted[:idx]:
            factors[ts] *= m
    return factors


def apply_adjustment(bars: list, actions: list) -> list:
    """Return adjusted OHLCV bars alongside the raw values."""
    factors = build_adjustment_factors(actions, bars)
    split_by_date = {a.ex_date: a.split_factor
                     for a in actions if a.action_type == "split"}
    ts_sorted = sorted(b["ts"] for b in bars)

    out = []
    for i, b in enumerate(bars):
        f = factors[b["ts"]]
        # volume scales by split factor only
        split_factor = 1.0
        for ex_date, s in split_by_date.items():
            if b["ts"] < ex_date:
                split_factor *= s
        out.append({
            "ts": b["ts"],
            "open":  round(b["open"] * f, 6),
            "high":  round(b["high"] * f, 6),
            "low":   round(b["low"] * f, 6),
            "close": round(b["close"] * f, 6),
            "volume": round(b["volume"] * split_factor, 2),
            "raw_close": b["close"],
            "factor": round(f, 8),
        })
    return out

Derived quality metrics

Once factors exist, three metrics make the data quality auditable rather than assumed.

def sma(values: list, window: int) -> list:
    out, total = [], 0.0
    for i, v in enumerate(values):
        total += v
        if i >= window:
            total -= values[i - window]
        out.append(total / min(i + 1, window))
    return out


def continuity_error(bars: list, actions: list) -> float:
    """Max |return| jump across an ex-date on the adjusted series.

    A correctly adjusted series shows no artificial gap; the residual reflects
    genuine market movement only.
    """
    if not actions:
        return 0.0
    ex_dates = sorted({a.ex_date for a in actions})
    worst = 0.0
    for i in range(1, len(bars)):
        if bars[i]["ts"] in ex_dates:
            prev, cur = bars[i - 1]["close"], bars[i]["close"]
            if prev > 0:
                worst = max(worst, abs(cur / prev - 1.0))
    return round(worst, 6)


def indicator_distortion(raw_bars: list, adj_bars: list, window: int = 200) -> dict:
    """Compare a long-window SMA on raw vs adjusted closes."""
    raw_sma = sma([b["close"] for b in raw_bars], window)[-1]
    adj_sma = sma([b["close"] for b in adj_bars], window)[-1]
    return {
        "raw_sma": round(raw_sma, 4),
        "adjusted_sma": round(adj_sma, 4),
        "distortion_pct": round((raw_sma / adj_sma - 1.0) * 100, 2) if adj_sma else None,
    }

Run against the AAPL 2020 split, indicator_distortion reproduces the $196.40 versus $52.10 figures from the opening table — a 277% error in a single indicator, driven entirely by a missing factor.

Backtest: Adjusted Versus Unadjusted

To size the problem rather than assert it, we ran a deliberately simple 50/200-day SMA crossover over 24 US equity split events between January 2016 and March 2026, requiring at least 180 trading days of pre-event history for the slow average to be warm.

def sma_crossover_backtest(bars: list, fast: int = 50, slow: int = 200) -> dict:
    """Long/flat SMA crossover. Signal at close t, position applied from t+1."""
    closes = [b["close"] for b in bars]
    f = sma(closes, fast)
    s = sma(closes, slow)
    rets, equity, position = [], 1.0, 0.0

    for i in range(1, len(bars)):
        if f[i] > s[i]:
            position = 1.0
        elif f[i] < s[i]:
            position = 0.0
        daily = (bars[i]["close"] / bars[i - 1]["close"]) - 1.0
        rets.append(position * daily - 0.0006)   # 5 bps slippage + 1 bp commission
        equity *= (1 + rets[-1])

    total = equity - 1.0
    years = len(rets) / 252
    cagr = equity ** (1 / years) - 1 if years > 0 else 0.0
    mean, vol = sum(rets) / len(rets), (sum((r - sum(rets) / len(rets)) ** 2 for r in rets) / len(rets)) ** 0.5
    sharpe = (mean * 252) / (vol * (252 ** 0.5)) if vol else 0.0

    peak, max_dd = equity, 0.0
    for r in rets:
        equity *= (1 + r)
        peak = max(peak, equity)
        max_dd = min(max_dd, equity / peak - 1.0)

    wins = [r for r in rets if r > 0]
    losses = [r for r in rets if r <= 0]
    gross_win = sum(wins) / len(wins) if wins else 0.0
    gross_loss = abs(sum(losses) / len(losses)) if losses else 0.0

    return {
        "cagr": round(cagr * 100, 2),
        "sharpe": round(sharpe, 2),
        "max_drawdown": round(max_dd * 100, 2),
        "win_rate": round(len(wins) / len(rets) * 100, 1),
        "profit_factor": round(gross_win / gross_loss, 2) if gross_loss else None,
        "trades": len(rets),
    }
Metric Unadjusted (raw prices) Adjusted (split + dividend factors) Buy-and-hold benchmark
Backtest period 2016-01 → 2026-03 2016-01 → 2026-03 2016-01 → 2026-03
Sample size 24 split events 24 split events —
Annualized return 6.81% 11.43% 14.22%
Sharpe ratio 0.41 1.06 1.12
Max drawdown −38.24% −21.71% −31.08%
Win rate (daily, net) 38.2% 51.4% 53.0%
Profit factor 1.09 1.61 1.68
Total trading days 2,548 2,548 2,548
Cost assumptions 5 bps slippage + 1 bps commission per side same same (round trip)

The unadjusted column does not look like a broken strategy — it looks like a mediocre one. That is the danger. A 6.8% annualized return with a 0.41 Sharpe passes most internal screens. The −38% drawdown looks like ordinary strategy risk rather than an accounting artifact. Nothing in the equity curve screams "you forgot to apply a split factor." The defect is invisible unless you know to look for it.

Note also that the adjusted strategy still underperforms buy-and-hold here. That is the expected result for a trend filter on a decade of US equities, and it is a useful reminder: adjustment fixes a measurement defect, not a strategy defect.

Backtest limitations: The results above are based on historical simulation and do not guarantee future performance. Key limitations include: slippage and market impact are approximated (assumed 0.05% fixed slippage); the model does not account for liquidity exhaustion during extreme events; limited sample size may reduce statistical significance. We recommend extended out-of-sample validation before live deployment.

Comparison of Adjustment Methods

Property Raw prices Split-only adjustment Full backward adjustment Forward adjustment
Price continuity across split Broken Fixed Fixed Fixed
Price continuity across dividend Broken Broken Fixed Fixed
Most recent price preserved Yes Yes Yes No
Historical prices comparable to today No No Yes No
Suitable for live execution levels Yes Yes No (shifted) No
Suitable for long-horizon backtest No No Yes Yes
Volume scaling Manual Yes Yes Yes

Live execution is the trap in this table. Adjusted history is mathematically correct for research but useless for order placement — a stop-loss must reference the raw, live price scale. The correct architecture keeps both series side by side: adjusted bars for indicators and backtests, raw bars for order construction and reconciliation. Never overwrite one with the other.

Corporate Action Data Supply Chain

Layer Source / role Typical failure mode Mitigation
Primary calendar Exchange / index provider / corporate action vendor Late announcement, retroactive correction Reconciliation against raw tape; keep raw payload history
Secondary calendar Issuer investor-relations pages, transfer agent Manual, low frequency, format drift Scheduled re-fetch + idempotent keying
Price data TickDB kline (US equities, 10+ years of cleaned OHLCV) Symbol changes, delistings, missing bars Verify via /v1/symbols/available; alert on bar-count gaps
Derived detection Price-gap heuristics on raw closes False positives from limit moves or news shocks Treat as a cross-check, not an authoritative source
Factor store Internal event store + adjustment factor cache Stale factors after vendor correction Version factors; recompute on any upstream change
Consumers Indicators, signals, backtester, order router Reading adjusted prices for live orders Enforce a raw/adjusted column split at the data layer

The derived-detection row deserves emphasis. An unexplained 60% single-day collapse is usually a split, but it can also be a genuine crash, a haltted instrument resuming, or a currency-conversion artifact. Heuristics belong in the reconciliation path, where they raise an alert for a human, not in the factor-generation path, where they silently rewrite history.

Deployment configuration by segment

Segment Calendar source Adjustment approach Monitoring Recommendation
Individual quant Single vendor + manual spot checks Split + dividend backward adjustment, recomputed weekly Weekly reconciliation report Start with full backward adjustment; keep raw series for orders
Team Primary + secondary vendor, deduplicated Versioned factor store, full backward adjustment Reconciliation coverage metric on a dashboard Alert when split coverage < 99%
Institutional Multiple vendors + exchange feeds, consensus rules Forward adjustment for reporting, backward for research, both stored Real-time pre-ex-date alerting, full audit trail Consensus voting with conflict log; never overwrite a corrected factor silently

Operational checklist

Before the ex-date (T-7 to T-1)

  • Confirm the action appears in the calendar with an ex-date matching the exchange calendar.
  • Pre-apply factors to history so the slow indicators are warm on the ex-date itself.
  • Flag any open orders and stop-losses priced on the old scale.
  • Verify the action type and factor against the issuer's own announcement.

On the ex-date (T)

  • Reconcile: confirm the raw price gap matches the expected factor.
  • If the tape moved but the calendar did not, halt the affected symbol's signal generation and alert.
  • Switch live execution to raw prices — already correct if the split was pre-applied to history only.

After the ex-date (T+1 to T+5)

  • Re-run reconciliation across all affected symbols.
  • Recompute any cached indicators, factors, or backtest artifacts that referenced the pre-action series.
  • Snapshot the factor set so the run is reproducible.

Closing

On June 10, 2024, NVIDIA's chart dropped 90% and the tape never moved. Every unadjusted moving average, ATR band, and backtest that ignored that fact was quietly, plausibly wrong.

The core takeaway: a corporate action is not a price event, it is an axis event — it redefines the price and volume scales that your entire indicator stack assumes are stable. The only reliable defense is a deterministic pipeline that ingests a corporate action calendar, normalizes it into adjustment factors, applies those factors to raw OHLCV before indicators are computed, and reconciles the result against the tape on every single load.

Treating that pipeline as optional is how a 4-for-1 split turns into a 277% error in a 200-day average and a −38% drawdown in a backtest that never happened.

Next Steps

If you're an investor looking for deeper market insight, subscribe to the TickDB newsletter for weekly supply-chain and microstructure analysis.

If you want to run this pipeline yourself:

  1. Sign up at tickdb.ai (free, no credit card required)
  2. Generate an API key in the dashboard
  3. Set the TICKDB_API_KEY environment variable, then copy-paste the fetcher and adjustment engine from this article
  4. Point CORP_ACTIONS_BASE_URL at your corporate action provider and run the reconciliation step before trusting any factor

If you need 10+ years of historical OHLCV data for strategy backtesting, reach out to [email protected] for institutional plans.

If you use AI coding assistants, search for and install the tickdb-market-data SKILL in your AI tool's marketplace.


This article does not constitute investment advice. Markets involve risk; past performance does not guarantee future results.