At 9:30 AM on August 31, 2020, NVIDIA opened at roughly one-quarter of its prior close. Anyone holding a chart with a 50-day moving average watched the price fall through the average within seconds — a 62% "collapse" that never touched a single trade. Six days earlier the stock had closed at $497.36. That morning it closed at $124.34. Nothing happened. The company simply issued four shares for every one it previously had, and every downstream indicator inherited the discontinuity as if it were a market event.

The uncomfortable part is not the chart. It is the backtest. A 50/200-day crossover system, a volatility-scaled position sizer, and a 52-week-high breakout filter all read the split as a price shock. The system enters short at the open, sizes the position off an inflated ATR, and exits days later at a price that has no economic meaning. Every metric downstream of the raw series inherits the error, and the error is not random — it is deterministic, repeatable, and correlated with the exact event you were trying to study.

For quant traders, the lesson is narrower and more useful than "adjust your data": the adjustment convention is not a formatting detail, it is part of the definition of your signal. This article dissects what a split does to a price series mathematically, separates the forward-adjusted (前复权) and backward-adjusted (后复权) conventions by what they actually change, and provides a production-grade reference implementation for factor application, indicator recomputation, and split-day detection.

1. Anatomy of a Split: The Numbers

A split changes the number of shares, not the value of the company. A 4-for-1 split turns one share priced at $100 into four shares priced at $25. Every pre-split bar must be scaled by 1/4 to sit on the same economic footing as every post-split bar.

The registry below lists recent high-profile events with the raw consolidated close on the last pre-split session and the first post-split session. Figures are rounded for illustration.

Event Ticker Ratio Effective Close before (raw) Reference after (raw) Price factor for pre-event bars
4-for-1 AAPL 1 : 4 Aug 31, 2020 $499.23 $124.81 0.25
4-for-1 NVDA Aug 31, 2020 — $497.36 $124.34 0.25
20-for-1 AMZN 1 : 20 Jun 6, 2022 $2,448.23 $122.41 0.05
20-for-1 GOOGL 1 : 20 Jul 15, 2022 ≈$1,717.98 ≈$85.90 0.05
3-for-1 TSLA 1 : 3 Aug 25, 2022 $325.12 $108.37 0.3333
2-for-1 META 1 : 2 Jun 14, 2024 ≈$504.25 ≈$252.13 0.5
10-for-1 NVDA 1 : 10 Jun 10, 2024 ≈$1,260.00 ≈$126.00 0.1

Two mechanical consequences follow immediately.

Price and share volume move in opposite directions. A 20-for-1 split divides price by 20 and multiplies share volume by 20. Dollar turnover (price × volume) is continuous across the event. Any volume filter, liquidity screen, or VWAP anchor that treats raw share counts as comparable across a split boundary is measuring a share-count artifact, not a liquidity change.

The raw return series contains a −75% print that is not a return. On the split-effective session, the naive percentage change from the prior close is −75% for a 4-for-1 event and −95% for a 20-for-1 event. A single split injects a synthetic tail observation that dominates realized volatility, ATR, standard deviation, and any drawdown statistic computed on unadjusted closes.

1.1 What the Discontinuity Does to Each Indicator

The table below reconstructs the NVDA 4-for-1 boundary (August 31, 2020) with a 50-day window: 40 pre-split closes averaging approximately $382 and 10 post-split closes averaging approximately $128. The adjusted column applies the 0.25 factor to the pre-split segment.

Metric (Aug 31, 2020) Raw series Adjusted series Distortion
Close $124.34 $124.34 —
SMA(50) ≈ $331.60 ≈ $102.00 3.25× overstated
Close vs. SMA(50) −62.5% +21.9% Signal polarity flips
RSI(14) ≈ 4 (one −75% delta dominates) ≈ 58 Momentum regime inverted
ATR(14) ≈ $29.1 (one ~$373 true range) ≈ $2.5 11.6× inflated
20-day realized vol ≈ 121% annualized ≈ 31% Risk model breaks

Two of these deserve emphasis because they are counter-intuitive.

RSI is scale-invariant, yet it still breaks. RSI depends only on the ratio of average gains to average losses. Multiplying an entire series by a constant leaves it unchanged — which is exactly why an adjusted series produces the same RSI as an unadjusted-but-continuous series. The problem is not the scaling; it is the single discontinuity. One −75% delta inside a 14-period window drives average loss up by roughly two orders of magnitude and pins RSI near zero for two weeks. The adjustment removes the discontinuity, and RSI restores itself without any recomputation of its formula.

ATR is level-equivariant, and it breaks loudly. ATR is a linear function of price differences. If an entire window lies within one epoch, multiplying prices by 0.25 multiplies ATR by exactly 0.25. Across the split boundary, that scaling argument no longer applies: the true range on the split session is the distance between a $124 low and a $497 prior close — a value with no economic content — so ATR must be recomputed from adjusted bars, not rescaled.

This yields the central classification you need before you write any adjustment code.

Indicator class Examples Behavior under a constant price factor f
Level-equivariant (linear) SMA, EMA, MACD, ATR, close, Bollinger bands Value scales by f
Scale-invariant (ratio) RSI, Stochastic %K, z-score, Bollinger %B, close-to-close returns, realized vol Value identical
Volume-coupled VWAP, OBV, MFI, CMF Requires paired volume scaling by 1/f

The practical rule: never rescale an indicator across a split boundary; always recompute it from adjusted bars. Rescaling is only exact when the entire lookback window sits inside a single adjustment epoch.

1.2 The Second Distortion: Mixing Conventions Mid-Dataset

The larger practical hazard is not the split itself — it is the split in your data lineage. A system that keeps a cached history from one date, refreshes only the tail from another, and merges them on a shared key can easily produce a series where the front-adjusted history (say, $124 basis) meets raw history ($124 basis today, coincidentally) or where a back-adjusted segment ($2,448 basis) meets a front-adjusted segment ($122 basis). The resulting gap is −95% and it appears on no corporate-action calendar.

This is why the adjustment factor must be stored as a first-class, versioned object rather than recomputed ad hoc from whatever the vendor returned.

2. Where Adjustment Belongs: Reference Architecture

A correct pipeline separates four concerns that are frequently collapsed into a single "adjusted dataframe" step:

┌─────────────────────────────────────────────────────────────────────┐
│ Layer 1 — RAW INGESTION                                              │
│ TickDB /v1/market/kline → immutable raw bars (open/high/low/close) │
│ + declared adjustment convention of the feed (raw | front | back)   │
└──────────────────────────────┬──────────────────────────────────────┘
                               │ raw bars, never mutated
┌──────────────────────────────▼──────────────────────────────────────┐
│ Layer 2 — CORPORATE-ACTION REGISTRY                                │
│ SplitEvent(symbol, effective_date, ratio, source, as_of_version)    │
│ Derived adjustment factor per bar, per convention                   │
└──────────────────────────────┬──────────────────────────────────────┘
                               │
┌──────────────────────────────▼──────────────────────────────────────┐
│ Layer 3 — SERIES CONSTRUCTION                                        │
│ raw → front-adjusted (latest bar unchanged)                         │
│ raw → back-adjusted (earliest bar unchanged)                        │
│ volume normalization paired with price normalization                │
└──────────────────────────────┬──────────────────────────────────────┘
                               │
┌──────────────────────────────▼──────────────────────────────────────┐
│ Layer 4 — INDICATOR & SIGNAL LAYER                                   │
│ Indicators recomputed on the full adjusted history (correct warm-up)│
│ Absolute-threshold rules mapped back to the RAW price basis         │
└─────────────────────────────────────────────────────────────────────┘

The architecture has one non-negotiable property: Layer 1 output is never mutated. Every downstream artifact is a pure function of (raw bars, registry version). That single constraint eliminates the class of bugs where a script "adjusts twice" or where a re-run on partially adjusted data silently double-counts a factor.

2.1 Three Phases Around a Split Event

The event-driven framing is useful even when the strategy is not event-driven:

Pre-event phase. The order book and print tape carry no special signature; the only observable is the known effective date, which appears in the corporate-action feed days in advance. A system that knows the ratio can pre-normalize its threshold ladder so that the split session does not trigger the "price fell through support" branch.

Split-effective session. If your feed is unadjusted, the first post-split bar prints at a price that is discontinuous with the prior bar. A live monitor must detect this and suppress the bar rather than trade it. The detection is trivial: compare the implied split ratio against the registry. If the ratio matches a known event, normalize and continue. If it does not match, it is a real price move — or a data error — and the correct response is to alert, not to trade.

Post-event phase. Indicators that were computed across the boundary carry a 50-bar (SMA) to 200-bar (slow EMA) memory of the error. Nothing is "fixed" on the second day. The series must be rebuilt from adjusted history, and the rebuild must include enough pre-split bars to fill the indicator warm-up window. Rebalancing a live chart by splicing two separately computed indicator series is a common source of a spurious crossover signal on day one after the rebuild.

3. Production-Grade Data Access

Before any of this is worth implementing, you need raw bars with a declared convention and a reliable delivery path. The following client enforces the operational requirements: environment-variable authentication, a timeout on every request, exponential backoff with jitter on transient failures, and correct handling of TickDB's 3001 rate-limit code via the Retry-After header.

"""Production-grade TickDB REST client for US equity daily bars.

Enforced properties:
  - API key read from the environment, never hardcoded
  - Timeout on every request (connect, read)
  - Exponential backoff + jitter on transient failures
  - Rate-limit handling for TickDB error code 3001 (Retry-After)
  - Explicit errors for 1001/1002 (auth) and 2002 (unknown symbol)
"""

from __future__ import annotations

import os
import random
import time
from typing import Any, Dict, List, Optional

import requests

API_KEY = os.environ.get("TICKDB_API_KEY")
BASE_URL = "https://api.tickdb.ai/v1"

CONNECT_TIMEOUT = 3.05
READ_TIMEOUT = 10.0
MAX_RETRIES = 5
BASE_BACKOFF = 0.5
MAX_BACKOFF = 20.0

if not API_KEY:
    raise RuntimeError(
        "TICKDB_API_KEY is not set. Export the key from your TickDB dashboard first."
    )


class TickDBError(RuntimeError):
    """Non-recoverable TickDB API error."""


def _backoff_delay(retry: int) -> float:
    """Exponential backoff with jitter to avoid synchronized retry storms."""
    delay = min(BASE_BACKOFF * (2 ** retry), MAX_BACKOFF)
    return delay + random.uniform(0, delay * 0.1)


def fetch_kline(
    symbol: str,
    interval: str = "1d",
    limit: int = 5000,
    start: Optional[str] = None,
    end: Optional[str] = None,
) -> List[Dict[str, Any]]:
    """Fetch daily bars for a US equity.

    Returns the raw payload as delivered by the feed. The caller is
    responsible for confirming the feed's adjustment convention before
    applying any additional factor — see build_adjusted() below.
    """
    params: Dict[str, Any] = {"symbol": symbol, "interval": interval, "limit": limit}
    if start:
        params["start"] = start
    if end:
        params["end"] = end

    headers = {"X-API-Key": API_KEY}

    for attempt in range(MAX_RETRIES):
        response = requests.get(
            f"{BASE_URL}/market/kline",
            headers=headers,
            params=params,
            timeout=(CONNECT_TIMEOUT, READ_TIMEOUT),
        )

        # Transport-level rate limit surfaced as an HTTP status.
        if response.status_code == 429:
            retry_after = float(response.headers.get("Retry-After", BASE_BACKOFF))
            time.sleep(retry_after)
            continue

        try:
            payload = response.json()
        except ValueError:
            time.sleep(_backoff_delay(attempt))
            continue

        code = payload.get("code", 0)

        if code == 0:
            return payload.get("data") or []

        if code in (1001, 1002):
            raise TickDBError(
                "Invalid or missing API key — check the TICKDB_API_KEY environment variable."
            )

        if code == 2002:
            raise KeyError(
                f"Symbol {symbol} not found — verify via /v1/symbols/available."
            )

        if code == 3001:
            retry_after = float(response.headers.get("Retry-After", BASE_BACKOFF))
            time.sleep(retry_after)
            continue

        raise TickDBError(f"Unexpected error {code}: {payload.get('message')}")

    raise TickDBError(f"Exhausted {MAX_RETRIES} retries for {symbol}.")

Convention guard. The critical operational question is whether the series you downloaded is already split-adjusted. A well-behaved pipeline asserts this rather than assuming it, because applying a second factor to an already-adjusted series double-counts the split and corrupts every level-based indicator.

from typing import Literal

Convention = Literal["raw", "front", "back"]

def require_raw(feed_convention: Optional[Convention]) -> None:
    """Fail loudly rather than double-adjusting an already-adjusted series.

    If your feed delivers a pre-adjusted series, set FEED_CONVENTION='front'
    and skip build_adjusted() entirely — but then you must trust the vendor's
    factor chain, and you lose the ability to audit the split registry.
    """
    if feed_convention != "raw":
        raise ValueError(
            "Feed convention is not 'raw'. Either configure the vendor's "
            "adjustment metadata or disable local factor application."
        )

3.1 Live Split-Day Detection

A monitoring client catches the discontinuity at the source, so a trading decision is never taken on a stale or unnormalized print. The WebSocket layer below uses a heartbeat for keepalive, exponential backoff with jitter for reconnection, and URL-parameter authentication.

"""WebSocket split-day guard.

The endpoint URL and subscription payload are supplied via environment
variables so that this module stays correct across API revisions — copy
both values from your TickDB dashboard.

Environment:
  TICKDB_WS_URL             e.g. wss://<host>/<stream-path>
  TICKDB_API_KEY            your API key (sent as the api_key URL parameter)
  TICKDB_WS_SUBSCRIBE_JSON  JSON subscription payload for the target symbol
"""

import asyncio
import json
import os
import random
from typing import Any, Dict, Optional

import websockets

WS_URL = os.environ.get("TICKDB_WS_URL")
API_KEY = os.environ.get("TICKDB_API_KEY")
SUBSCRIBE_JSON = os.environ.get("TICKDB_WS_SUBSCRIBE_JSON")

HEARTBEAT_SECONDS = 15
BASE_BACKOFF = 0.5
MAX_BACKOFF = 30.0

if not WS_URL or not API_KEY or not SUBSCRIBE_JSON:
    raise RuntimeError(
        "TICKDB_WS_URL, TICKDB_API_KEY and TICKDB_WS_SUBSCRIBE_JSON must all be set."
    )


def _backoff_delay(retry: int) -> float:
    delay = min(BASE_BACKOFF * (2 ** retry), MAX_BACKOFF)
    return delay + random.uniform(0, delay * 0.1)


def implied_split_ratio(prev_close: float, close: float) -> float:
    """Ratio implied by a price discontinuity: 4.0 means a 4-for-1 split."""
    if prev_close <= 0:
        raise ValueError("prev_close must be positive")
    return prev_close / close


async def stream_with_guard(
    known_ratios: Dict[str, float],
    on_bar,
    stop_event: Optional[asyncio.Event] = None,
) -> None:
    """Stream bars, normalizing known splits and flagging unknown discontinuities."""
    retry = 0
    url = f"{WS_URL}?api_key={API_KEY}"

    while stop_event is None or not stop_event.is_set():
        try:
            async with websockets.connect(url, ping_interval=None) as ws:
                await ws.send(SUBSCRIBE_JSON)
                retry = 0  # reset backoff after a successful connection
                prev_close: Optional[float] = None

                while True:
                    try:
                        raw = await asyncio.wait_for(ws.recv(), timeout=60)
                    except asyncio.TimeoutError:
                        # No traffic for 60s — reconnect rather than sit blind.
                        break

                    message = json.loads(raw)

                    # Application-level heartbeat.
                    if message.get("cmd") == "ping":
                        await ws.send(json.dumps({"cmd": "ping"}))
                        continue

                    close = float(message.get("close", 0) or 0)
                    symbol = message.get("symbol", "")

                    if prev_close and close > 0:
                        ratio = implied_split_ratio(prev_close, close)
                        if ratio > 1.5 or ratio < 0.67:
                            expected = known_ratios.get(symbol)
                            if expected and abs(ratio / expected - 1.0) < 0.02:
                                # Known split: normalize and suppress the artifact.
                                close = close * expected
                                message["close"] = close
                                message["split_normalized"] = True
                            else:
                                # Unknown discontinuity — alert, do not trade.
                                message["split_anomaly"] = True
                                message["implied_ratio"] = ratio

                    on_bar(message)
                    if close > 0:
                        prev_close = close

        except (OSError, websockets.exceptions.WebSocketException) as exc:
            delay = _backoff_delay(retry)
            print(f"[ws] connection lost ({exc}); reconnecting in {delay:.2f}s")
            await asyncio.sleep(delay)
            retry += 1

        # Keepalive task: ping the server on a fixed interval.
        # (In production, run this as a concurrent task alongside the recv loop.)

The guard's logic is deliberately simple: a price discontinuity beyond ±50% is only benign if it matches a registered split ratio within 2%. Anything else is a genuine event or a data fault, and the correct engineering response in both cases is to log it and stop trading, not to silently trade through it.

4. The Core Algorithm: Factor Application and Indicator Recomputation

4.1 The Two Conventions, Formally

Let a split event s have ratio k_s (new shares per old share) and effective date e_s. Define the price factor for bar t as:

Forward-adjusted (前复权) — the most recent bar keeps its traded price, and history is scaled forward onto the current basis:

f_front(t) = Π_{s : e_s > t} (1 / k_s)
P̂_front(t) = P(t) × f_front(t)

Backward-adjusted (后复权) — the earliest bar keeps its original price, and all later bars are scaled backward onto the original basis:

f_back(t) = Π_{s : e_s ≤ t} k_s
P̂_back(t) = P(t) × f_back(t)

These two series are related by a single constant:

P̂_back(t) = C × P̂_front(t),   C = Π_s k_s

This proportionality is the most important fact in the article. It means:

  • Returns, RSI, Stochastic, z-scores, Bollinger %B, and realized volatility are identical between the two conventions.
  • SMA, EMA, MACD, ATR, and price levels differ by exactly the constant C.
  • Every signal computed on differences, ratios, or crossings between two indicators of the same series is identical between conventions.

So the choice between forward and backward adjustment is not a signal-selection decision. It is a units decision, and it matters only where an absolute level enters the system:

Rule that depends on absolute level Forward-adjusted Back-adjusted Raw
"Buy below $100" Correct Wrong by factor C Correct
Options strike selection Wrong Wrong Correct
Portfolio P&L and position value Wrong Wrong Correct
52-week high/low breakout Internally consistent Internally consistent Broken at the boundary
Cross-sectional screen on price level Comparable Not comparable across tickers Comparable but boundary-broken
MA-crossover timing Identical Identical Wrong
RSI / realized volatility Identical Identical Wrong

The correct policy is therefore: use an adjusted series for analytics and signal generation; use the raw price for execution, order pricing, strike selection, and account valuation. Most production failures in this area come from mixing the two