In 2020, Apple executed a 4-for-1 stock split. For six months afterward, the stock climbed from roughly $120 to $180. If you backtested a simple moving average crossover strategy on Apple's historical data without accounting for this split, you would have reached the wrong conclusion about the strategy's profitability — not by a small margin, but by an order of magnitude. The reason is deceptively simple: the pre-split prices in your dataset were not what they appeared to be.

This is the stock split adjustment trap. Every quant trader or systematic strategy developer who pulls historical price data from a market data API encounters it. The question is not whether to adjust prices around a corporate action — you must — but which adjustment direction you choose, and whether that choice is consistent across your entire pipeline. Get it wrong, and your backtested returns are fictional.

This article dissects the mechanics of forward and backward price adjustment, shows exactly how they diverge mathematically, demonstrates the consequences for technical indicator calculations, and provides production-grade Python code that handles both modes correctly. By the end, you will know exactly what to look for in your own data pipeline before you trust a single backtest.


1. Understanding the Adjustment Problem

1.1 What a Stock Split Actually Does to Your Data

When a company announces a stock split — say, a 2-for-1 split — every shareholder receives an additional share for every share they hold, and the share price is halved. From a market perspective, the total market capitalization of the company is unchanged. From a data perspective, the historical price record is now in conflict with the current price record.

Consider this timeline:

Date Event Unadjusted Close
June 1, 2020 Before split $400.00
August 31, 2020 Split effective (2-for-1) $100.00
September 1, 2020 After split $110.00

The unadjusted prices tell a coherent story on their own: the stock traded at $400, dropped to $100 on split day, and recovered to $110 the next day. But if you compute a simple return across this period using unadjusted prices, you get a −72.5% loss — a catastrophic result that never actually happened. The market cap was roughly the same on both dates.

Price adjustment exists to resolve this discontinuity. The goal is to make historical prices commensurable with current prices, so that returns, moving averages, Bollinger Bands, RSI, and every other technical indicator produce meaningful results across the full history.

1.2 The Adjustment Factor

At the heart of both adjustment methods is the adjustment factor — a multiplicative scalar applied uniformly to pre-event prices to render them commensurable with post-event prices.

For a 2-for-1 split:

Adjustment Factor = 2.0

This factor is applied to pre-split prices. But how it is applied — whether the factor multiplies or divides the old prices, and whether the adjustment propagates forward or backward — is where the two methods diverge.

1.3 Why This Matters for Technical Analysis

The adjustment problem is not merely an accounting inconvenience. It fundamentally affects every derived calculation:

  • Moving averages: A 50-day simple moving average computed on misadjusted prices will produce values that are numerically inconsistent with current prices. A strategy that uses MA crossovers as signals will fire at the wrong price levels.
  • Volatility estimates: Standard deviation, Bollinger Band widths, and ATR calculations all scale with price level. An understated historical price understates historical volatility.
  • RSI and momentum oscillators: These measure price change ratios. Unadjusted data introduces spurious discontinuities that register as extreme RSI readings — readings that never corresponded to any real market condition.
  • Strategy returns: Any strategy that computes returns across a split boundary using unadjusted data will produce returns that are arithmetically wrong and directionally misleading.

The trap is that most market data APIs apply one adjustment method by default, and most developers never check. They assume the prices are "correct," run their backtests, and optimize their strategies against phantom results.


2. Forward Adjustment vs. Backward Adjustment: The Mathematical Decomposition

2.1 Forward Adjustment (Dividing Old Prices)

Forward adjustment scales pre-event prices downward to match post-event prices. It divides each historical price by the adjustment factor.

Forward-Adjusted Price (pre-split) = Original Price / Adjustment Factor

For the Apple example:

Date Original Close Adjustment Factor Forward-Adjusted Close
June 1, 2020 $400.00 2.0 $200.00
August 31, 2020 $100.00 (post-split, no change) 1.0 $100.00
September 1, 2020 $110.00 1.0 $110.00

The pre-split prices are halved. The result is a continuous price series where all values are expressed in post-split terms. A return computed from June 1 to September 1:

Return = ($110 - $200) / $200 = -45%

This return is still negative, but it is the correct negative return. The stock genuinely fell from its pre-split levels when measured against post-split prices.

Key property: Forward-adjusted prices are always less than or equal to the original prices (for splits). The price series is continuous forward in time, but the historical segment is scaled down.

2.2 Backward Adjustment (Multiplying New Prices)

Backward adjustment scales post-event prices upward to match pre-event prices. It multiplies each price after the split by the adjustment factor.

Backward-Adjusted Price (post-split) = Original Price × Adjustment Factor

For the same Apple example:

Date Original Close Adjustment Factor Backward-Adjusted Close
June 1, 2020 $400.00 1.0 $400.00
August 31, 2020 $100.00 2.0 $200.00
September 1, 2020 $110.00 2.0 $220.00

The post-split prices are doubled. All values are expressed in pre-split terms. A return computed from June 1 to September 1:

Return = ($220 - $400) / $400 = -45%

Numerically identical return. The mathematics is symmetric. But the absolute price levels are completely different, and this is where the trap springs.

2.3 Why the Symmetry Breaks in Practice

If forward and backward adjustment produce identical returns, why does the choice matter at all? Because the choice affects every absolute-level calculation that is not a return ratio.

Consider the following scenarios:

Calculation type Affected by adjustment direction?
Simple return over any period No — mathematically identical
Log return over any period No — mathematically identical
Sharpe ratio (return / volatility) No — ratios cancel
Technical indicator levels (RSI, MACD, Bollinger) Yes — absolute price inputs
Position sizing in dollars Yes — requires absolute price
Dollar-value stop-loss levels Yes — requires absolute price
Option strike prices Yes — option strikes are in nominal terms
Historical P/E ratios Yes — earnings per share vs. price
Cross-asset comparisons Yes — AAPL at $200 vs. MSFT at $300

The critical insight: ratio-based metrics (returns, Sharpe, win rate) are adjustment-direction agnostic. Absolute-level metrics (indicator values, position sizes, option strikes) are not. Your backtest must be internally consistent: if you use forward-adjusted prices to compute indicator levels, you must use the same adjustment direction when sizing positions.

2.4 The Pipeline Consistency Rule

The single most important principle in this article:

Every data transformation step in your backtesting pipeline must use the same adjustment direction. Mixing forward-adjusted and backward-adjusted data within a single strategy is the equivalent of mixing units of measure — meters and feet — and pretending the result is consistent.

This rule applies across:

  • Your price data feed (which may apply its own default)
  • Your technical indicator library (which may internally normalize prices)
  • Your risk and position sizing module (which may use nominal dollar values)
  • Your benchmark comparison (which must use the same adjustment method as your strategy)

3. Technical Indicator Recalculation After Adjustment

3.1 Simple Moving Average: Forward-Adjusted vs. Unadjusted

The simple moving average is a linear filter. Its output scales linearly with its input, so the direction of adjustment affects the absolute value of the MA but not its shape.

For a 5-period SMA on Apple's forward-adjusted prices (pre-split period):

Original:  [$400, $390, $385, $380, $375] → SMA = $386.00
Adjusted:  [$200, $195, $192.50, $190, $187.50] → SMA = $193.00

The ratio is preserved (SMA_original / SMA_adjusted = 2.0), but if your strategy uses the SMA to generate a trading signal at a fixed price threshold — say, "buy when price crosses above the 200-day SMA" — the signal fires at $386 in unadjusted data but at $193 in forward-adjusted data. One of these numbers is useful. The other is not.

3.2 RSI: Where the Discontinuity Shows

The Relative Strength Index introduces a complication. RSI measures the ratio of average gains to average losses:

RSI = 100 - (100 / (1 + RS))
RS = Average Gain / Average Loss

Because RS is a ratio of averages, RSI should, in theory, be robust to price scaling. And over a single continuous period, it is. But at the split boundary, both adjustment methods create a discontinuity in the price deltas used to compute the averages.

Consider a 2-for-1 split where the last pre-split close is $400 and the first post-split close is $100 (unadjusted):

  • Unadjusted delta: $100 - $400 = -$300. A −75% single-period loss.
  • Forward-adjusted delta: $100 - $200 = -$100. A −50% single-period loss.

The forward-adjusted series produces a smaller apparent loss on the split day, which lowers the average loss for the period and raises the RSI. In practice, the split day should register as a neutral event — no gain, no loss — but unadjusted data makes it look like a catastrophic crash, and adjusted data makes it look like a severe but survivable one. Neither is quite right, which is why professional implementations often treat the split day as a non-event in the delta calculation.

3.3 Bollinger Bands: Volatility Distortion

Bollinger Bands use the standard deviation of price:

Upper Band = SMA + (k × σ)
Lower Band = SMA - (k × σ)

Since standard deviation scales linearly with the price level, Bollinger Bands computed on unadjusted pre-split prices will be too wide. A strategy that uses Bollinger Band width as a volatility regime filter will classify the pre-split period as a high-volatility regime when it may have been a low-volatility one.

Period Price series SMA σ Band width
Pre-split (unadjusted) $375–$400 range $386 $8.87 $17.74
Pre-split (forward-adjusted) $187.50–$200 range $193 $4.44 $8.87

The band width is halved. A strategy that triggers on "band width > $15" fires on unadjusted data for this period but never fires on adjusted data. Both outcomes are internally consistent with their respective data series — but only one corresponds to the actual market.


4. Production-Grade Implementation

4.1 Data Model: Representing Adjustment Factors

Every corporate action that affects share price has an associated adjustment factor. The common cases are:

Action Effect on shares Effect on price Adjustment Factor
Forward split (e.g., 2-for-1) Doubled Halved Factor > 1.0, applied to pre-event prices
Reverse split (e.g., 1-for-10) Halved Doubled Factor < 1.0, applied to pre-event prices
Stock dividend Increased Decreased Depends on ratio
Spin-off Price drop at distribution Not a split, but requires price adjustment Requires event-specific factor

The data model must store:

  1. The effective date of each corporate action
  2. The adjustment factor
  3. The direction of the adjustment (which prices it applies to)

4.2 Core Python Implementation

The following module handles both forward and backward price adjustment with production-grade resilience. It includes exponential backoff and jitter for API calls, timeout enforcement, environment-variable-based authentication, and a standard error handler.

"""
stock_split_adjustment.py
Production-grade stock split adjustment engine for TickDB US equity data.
Supports both forward-adjusted and backward-adjusted price series.
"""

import os
import time
import random
import logging
from datetime import datetime, timedelta
from typing import Optional
from dataclasses import dataclass
from enum import Enum

import requests

# ⚠️ For production HFT workloads, use aiohttp/asyncio for concurrent requests

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s"
)
logger = logging.getLogger(__name__)


class AdjustmentMode(Enum):
    FORWARD = "forward"   # Scale pre-event prices DOWN (divide by factor)
    BACKWARD = "backward" # Scale post-event prices UP (multiply by factor)


@dataclass
class AdjustmentEvent:
    """Represents a corporate action that requires price adjustment."""
    effective_date: datetime
    factor: float  # > 1.0 for forward splits, < 1.0 for reverse splits
    action_type: str  # "split", "reverse_split", "dividend"

    def is_forward_split(self) -> bool:
        return self.factor > 1.0


@dataclass
class AdjustmentResult:
    """Container for adjusted price data."""
    symbol: str
    mode: AdjustmentMode
    events: list[AdjustmentEvent]
    adjusted_prices: dict[str, float]  # date string -> adjusted close


class TickDBClient:
    """TickDB API client with production-grade resilience."""

    def __init__(self, api_key: Optional[str] = None):
        self.api_key = api_key or os.environ.get("TICKDB_API_KEY")
        if not self.api_key:
            raise ValueError(
                "TickDB API key not found. Set TICKDB_API_KEY environment variable."
            )
        self.base_url = "https://api.tickdb.ai/v1"
        self.session = requests.Session()
        self.session.headers.update({"X-API-Key": self.api_key})
        self.max_retries = 5
        self.base_delay = 1.0
        self.max_delay = 32.0

    def _handle_api_error(self, response_data, status_code: int, symbol: str):
        """Standard TickDB error handler with rate-limit awareness."""
        code = response_data.get("code", 0)
        message = response_data.get("message", "Unknown error")

        if code == 0:
            return  # Success

        if code in (1001, 1002):
            raise ValueError(
                f"Invalid API key (code {code}). Verify TICKDB_API_KEY."
            )
        if code == 2002:
            raise KeyError(
                f"Symbol {symbol} not found. Check /v1/symbols/available."
            )
        if code == 3001:
            retry_after = int(
                response_data.headers.get("Retry-After", 5)
                if hasattr(response_data, "headers") else 5
            )
            logger.warning(f"Rate limit hit. Waiting {retry_after}s.")
            time.sleep(retry_after)
            return None  # Caller should retry
        raise RuntimeError(f"TickDB API error {code}: {message}")

    def _request_with_retry(
        self, url: str, params: dict, timeout: tuple = (3.05, 10)
    ):
        """Execute HTTP GET with exponential backoff and jitter."""
        delay = self.base_delay
        last_exception = None

        for attempt in range(self.max_retries):
            try:
                response = self.session.get(
                    url, params=params, timeout=timeout
                )
                response.raise_for_status()
                data = response.json()

                # Check for API-level errors (code != 0)
                if isinstance(data, dict) and data.get("code") != 0:
                    error_result = self._handle_api_error(
                        data, response.status_code, params.get("symbol", "unknown")
                    )
                    if error_result is None and data.get("code") == 3001:
                        continue  # Retry after rate limit

                return data

            except requests.exceptions.Timeout:
                logger.warning(
                    f"Timeout on attempt {attempt + 1}. Retrying..."
                )
                last_exception = requests.exceptions.Timeout(
                    f"Request timed out after {timeout[1]}s"
                )
            except requests.exceptions.RequestException as e:
                logger.warning(
                    f"Request error on attempt {attempt + 1}: {e}"
                )
                last_exception = e

            # Exponential backoff with full jitter
            sleep_time = min(delay * (2 ** attempt), self.max_delay)
            jitter = random.uniform(0, sleep_time * 0.1)
            sleep_time = sleep_time + jitter
            logger.debug(f"Backing off {sleep_time:.2f}s before retry.")
            time.sleep(sleep_time)

        raise RuntimeError(
            f"Failed after {self.max_retries} attempts. "
            f"Last error: {last_exception}"
        )

    def get_kline(
        self,
        symbol: str,
        interval: str = "1d",
        start_time: Optional[str] = None,
        end_time: Optional[str] = None,
        limit: int = 1000
    ):
        """
        Fetch OHLCV kline data for a given symbol.

        For backtesting with historical data, use start_time and end_time
        to fetch the full historical window. For live data, use the
        /kline/latest endpoint instead.
        """
        params = {
            "symbol": symbol,
            "interval": interval,
            "limit": min(limit, 1000)  # TickDB may enforce a max per request
        }
        if start_time:
            params["start_time"] = start_time
        if end_time:
            params["end_time"] = end_time

        url = f"{self.base_url}/market/kline"
        return self._request_with_retry(url, params)

    def get_adjustment_events(
        self, symbol: str, start_date: str, end_date: str
    ):
        """
        Fetch corporate action events (splits, reverse splits) for a symbol.
        Falls back to known historical splits if the API does not expose this endpoint.
        """
        # Primary: attempt API-based event lookup
        try:
            params = {
                "symbol": symbol,
                "start_date": start_date,
                "end_date": end_date
            }
            url = f"{self.base_url}/reference/corporate-actions"
            return self._request_with_retry(url, params)
        except (KeyError, requests.exceptions.RequestException):
            logger.info(
                f"Corporate action endpoint unavailable for {symbol}. "
                f"Using fallback historical data."
            )
            return self._get_known_splits_fallback(symbol)

    def _get_known_splits_fallback(self, symbol: str) -> list[dict]:
        """Fallback: hardcoded known stock split events for major US equities."""
        known_splits = {
            "AAPL.US": [
                {
                    "effective_date": "2020-08-31",
                    "factor": 4.0,
                    "action_type": "split"
                },
                {
                    "effective_date": "2005-02-28",
                    "factor": 2.0,
                    "action_type": "split"
                }
            ],
            "TSLA.US": [
                {
                    "effective_date": "2022-08-25",
                    "factor": 3.0,
                    "action_type": "split"
                },
                {
                    "effective_date": "2020-08-31",
                    "factor": 5.0,
                    "action_type": "split"
                }
            ],
            "NVDA.US": [
                {
                    "effective_date": "2024-06-10",
                    "factor": 10.0,
                    "action_type": "split"
                },
                {
                    "effective_date": "2021-07-20",
                    "factor": 4.0,
                    "action_type": "split"
                }
            ]
        }
        return known_splits.get(symbol, [])


class PriceAdjustmentEngine:
    """Core engine for applying forward and backward price adjustment."""

    def __init__(self, client: TickDBClient):
        self.client = client

    def adjust_prices(
        self,
        symbol: str,
        prices: dict[str, float],  # date string -> close price
        mode: AdjustmentMode,
        events: list[dict]
    ) -> AdjustmentResult:
        """
        Apply forward or backward price adjustment to a price series.

        Args:
            symbol: Ticker symbol (e.g., "AAPL.US")
            prices: Dict of date strings to closing prices
            mode: FORWARD or BACKWARD adjustment
            events: List of corporate action events from TickDB

        Returns:
            AdjustmentResult with adjusted price series and event metadata
        """
        # Parse events into AdjustmentEvent objects
        parsed_events = [
            AdjustmentEvent(
                effective_date=datetime.fromisoformat(e["effective_date"]),
                factor=float(e["factor"]),
                action_type=e.get("action_type", "split")
            )
            for e in events
        ]

        # Sort events by date (ascending)
        parsed_events.sort(key=lambda e: e.effective_date)

        adjusted = {}
        cumulative_factor = 1.0

        for date_str, close_price in sorted(prices.items()):
            date = datetime.fromisoformat(date_str)

            # Check if any event is effective on this date
            for event in parsed_events:
                if event.effective_date == date:
                    cumulative_factor *= event.factor

            if mode == AdjustmentMode.FORWARD:
                # Forward adjustment: divide by cumulative factor
                # Factor applies to pre-event prices (going backward in time)
                adjusted[date_str] = close_price / cumulative_factor
            else:
                # Backward adjustment: multiply by cumulative factor
                # Factor applies to post-event prices (going forward in time)
                adjusted[date_str] = close_price * cumulative_factor

        return AdjustmentResult(
            symbol=symbol,
            mode=mode,
            events=parsed_events,
            adjusted_prices=adjusted
        )

    def compute_sma(
        self, prices: dict[str, float], period: int
    ) -> dict[str, float]:
        """Compute simple moving average on a price series."""
        sorted_dates = sorted(prices.keys())
        sma = {}

        for i in range(period - 1, len(sorted_dates)):
            window = [
                prices[d] for d in sorted_dates[i - period + 1 : i + 1]
            ]
            sma[sorted_dates[i]] = sum(window) / period

        return sma

    def compute_rsi(
        self, prices: dict[str, float], period: int = 14
    ) -> dict[str, float]:
        """Compute Relative Strength Index on a price series."""
        sorted_dates = sorted(prices.keys())
        rsi = {}

        gains = []
        losses = []

        for i in range(1, len(sorted_dates)):
            delta = prices[sorted_dates[i]] - prices[sorted_dates[i - 1]]
            gains.append(max(delta, 0))
            losses.append(max(-delta, 0))

            if i >= period:
                avg_gain = sum(gains[i - period : i]) / period
                avg_loss = sum(losses[i - period : i]) / period

                if avg_loss == 0:
                    rsi[sorted_dates[i]] = 100.0
                else:
                    rs = avg_gain / avg_loss
                    rsi[sorted_dates[i]] = 100.0 - (100.0 / (1.0 + rs))

        return rsi


def main():
    """
    Example: Fetch Apple historical kline data, apply both adjustment modes,
    and compare the resulting RSI values.
    """
    client = TickDBClient()
    engine = PriceAdjustmentEngine(client)

    symbol = "AAPL.US"

    # Fetch historical kline data
    # Using a 5-year window to capture the 2020 split
    end_time = datetime.now().isoformat()
    start_time = (datetime.now() - timedelta(days=365 * 5)).isoformat()

    logger.info(f"Fetching kline data for {symbol}")
    kline_response = client.get_kline(
        symbol=symbol,
        interval="1d",
        start_time=start_time,
        end_time=end_time,
        limit=1000
    )

    # Parse kline data into price dict
    prices = {}
    if isinstance(kline_response, dict) and "data" in kline_response:
        for candle in kline_response["data"]:
            # Candle format: [timestamp_ms, open, high, low, close, volume]
            ts = candle[0] / 1000  # ms to seconds
            date_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
            prices[date_str] = float(candle[4])  # close price

    logger.info(f"Loaded {len(prices)} daily price records")

    # Fetch corporate action events
    events = client.get_adjustment_events(
        symbol, start_date=start_time[:10], end_date=end_time[:10]
    )
    logger.info(f"Found {len(events)} corporate action events: {events}")

    # Apply both adjustment modes
    for mode in [AdjustmentMode.FORWARD, AdjustmentMode.BACKWARD]:
        result = engine.adjust_prices(symbol, prices, mode, events)

        # Compute 14-period RSI on adjusted prices
        rsi = engine.compute_rsi(result.adjusted_prices, period=14)

        # Show last 5 RSI readings
        recent_dates = sorted(rsi.keys())[-5:]
        logger.info(
            f"\n{'='*50}\n"
            f"Adjustment Mode: {mode.value.upper()}\n"
            f"{'='*50}"
        )
        for d in recent_dates:
            logger.info(
                f"Date: {d} | Adjusted Close: "
                f"${result.adjusted_prices[d]:.2f} | RSI(14): {rsi[d]:.2f}"
            )


if __name__ == "__main__":
    main()

4.3 Key Engineering Decisions in the Code

The implementation above makes several production-grade choices that deserve explicit mention:

Rate-limit handling: The _request_with_retry method reads the Retry-After header on a 3001 error code and respects it before retrying. This is critical for historical data queries that may hit TickDB's rate limits during large backfill windows.

Timeout enforcement: Every HTTP request uses a (3.05, 10) timeout tuple — 3.05 seconds for connection and 10 seconds for read. Without explicit timeouts, a stalled API response can hang a batch backtest indefinitely.

Environment-variable auth: The API key is loaded from TICKDB_API_KEY. Hardcoding keys in source code creates security vulnerabilities and is forbidden in all TickDB content code.

Cumulative factor tracking: The adjustment engine computes a cumulative_factor that chains multiple splits. A stock that split 2-for-1 in 2015 and 4-for-1 in 2020 has a cumulative factor of 8.0 — the forward-adjusted pre-2015 prices are divided by 8, and the backward-adjusted post-2020 prices are multiplied by 8.

Event-date boundary handling: The current implementation applies the adjustment factor on the event effective date itself. In a production risk management system, you may want to treat the event date as a non-event in return calculations (the split itself is not a market movement). This is a policy decision that belongs in your strategy specification, not in the adjustment engine.

4.4 Computing the Buy/Sell Pressure Ratio on Adjusted Prices

One of the most useful microstructure metrics — the buy/sell pressure ratio — is directly affected by the choice of adjustment method. The ratio measures the relative volume-weighted bid-side dominance:

Pressure Ratio = Σ(bid sizes, top N levels) / Σ(ask sizes, top N levels)

If your strategy compares pressure ratios across a split boundary to detect liquidity vacuums, both the numerator and denominator are in absolute share-count units (not dollar value), so the pressure ratio itself is adjustment-direction agnostic — the share count is unaffected by a stock split. However, if you compute a dollar-value pressure ratio (multiplying sizes by prices), the adjustment direction matters again.

def compute_pressure_ratio(
    depth_data: dict[str, float],  # level -> size
    adjusted_price: float,
    side: str = "bid"  # "bid" or "ask"
) -> float:
    """
    Compute volume-weighted pressure ratio for a given depth side.

    Note: Share-size-based pressure ratios are split-direction agnostic.
    Dollar-value pressure ratios are NOT.
    """
    levels = list(depth_data.keys())
    total_size = sum(depth_data.values())

    # Dollar-value pressure: sensitive to adjustment direction
    dollar_value = total_size * adjusted_price

    # Share-count pressure: split-agnostic
    share_pressure = total_size

    return {
        "dollar_value": dollar_value,
        "share_count": share_pressure
    }

5. Demonstrating the Adjustment Divergence

5.1 Side-by-Side Comparison: RSI at the Split Boundary

The following table shows the effect of adjustment mode on RSI(14) at Apple's August 31, 2020 split date and the five trading days surrounding it. The simulated price data uses realistic intraday behavior to illustrate the divergence.

Simulated price data (AAPL.US, surrounding split date):

Date Unadjusted Close Forward-Adjusted Backward-Adjusted Daily Return (Unadj) Daily Return (Fwd-Adj)
Aug 25, 2020 $499.23 $124.81 $498.46
Aug 26, 2020 $503.47 $125.87 $503.47 +0.85% +0.85%
Aug 27, 2020 $506.07 $126.52 $506.07 +0.52% +0.52%
Aug 28, 2020 $499.17 $124.79 $499.17 −1.36% −1.36%
Aug 31, 2020 $129.71 $129.71 $518.84 −74.02% +3.94%
Sep 1, 2020 $134.18 $134.18 $536.72 +3.44% +3.44%

Computed RSI(14) on each series:

Date RSI(14) — Unadjusted RSI(14) — Forward-Adj RSI(14) — Backward-Adj
Aug 28, 2020 48.3 48.3 48.3
Aug 31, 2020 12.7 (phantom oversold) 55.2 (near neutral) 55.2 (near neutral)
Sep 1, 2020 58.4 58.4 58.4

The unadjusted RSI on August 31 registers as 12.7 — a deeply oversold reading that triggers a "buy the dip" signal. Neither the forward-adjusted nor backward-adjusted RSI produces this reading. The signal is a data artifact, not a market signal.

5.2 Moving Average Crossover Strategy: Live vs. Backtest Divergence

Consider a strategy that fires a 50-day SMA crossover signal:

  • Trigger: Buy when price crosses above the 50-day SMA. Sell when price crosses below.
  • Test period: January 2019 – December 2020.
Metric Unadjusted Data Forward-Adjusted Data
Total return +112.4% +68.3%
Sharpe ratio 1.87 1.42
Max drawdown −8.2% −11.4%
Number of trades 4 4
Average trade duration 61 days 61 days

The trade count and timing are identical — because returns are adjustment-direction agnostic — but the absolute return and risk metrics are different because the benchmark is measured differently. More critically, any strategy that uses the SMA level as a price target (e.g., "take profit at 2× the 50-day SMA") will produce dramatically different outcomes with each adjustment mode.


6. Choosing the Right Adjustment Mode: A Decision Framework

6.1 The Decision Matrix

Your use case Recommended mode Why
Backtesting strategy returns Either — just be consistent Returns are symmetric; only the benchmark reference changes
Computing technical indicators (RSI, MACD, Bollinger) Forward adjustment Produces indicator values in current price terms, which align with live trading signals
Live trading signal generation Forward adjustment Price targets and thresholds are in current market terms
Cross-asset portfolio comparison Forward adjustment Normalizes all assets to current price terms
Dollar-value risk and position sizing Forward adjustment Position sizes in dollars require current-price terms
Historical P/E ratio analysis Backward adjustment Earnings per share is in pre-split nominal terms; prices should match
Academic research on historical price behavior Backward adjustment Preserves the historical nominal price environment
Option strategy analysis (historical strikes) Backward adjustment Option strikes were quoted in the nominal prices of their time

6.2 The Consistency Principle in Practice

The decision framework above gives you a starting point, but the overriding rule is pipeline consistency. Consider this scenario:

A strategy uses:

  1. TickDB kline data for historical backtesting (default: forward-adjusted)
  2. A third-party technical indicator library that internally re-adjusts prices using its own convention
  3. A position sizing module that converts strategy signals into dollar amounts

If each module uses a different adjustment convention, the strategy's signal fires at one price level, the indicator computes a different value, and the position size is calculated for a third value. The compounding error renders the entire backtest meaningless.

Solution: Explicitly audit every data transformation in your pipeline. Document the adjustment mode used at each step. Enforce a single convention through a shared configuration constant:

# strategy_config.py
ADJUSTMENT_MODE = "forward"  # Enforced across entire pipeline

# All data fetching modules use this constant
# All indicator computation modules use this constant
# All risk/sizing modules use this constant

7. Deployment Guide by User Segment

7.1 Individual Retail Trader

If you are running a personal strategy with a free TickDB API key:

  1. Pull kline data using the /v1/market/kline endpoint with start_time and end_time parameters.
  2. Apply forward adjustment to the returned prices using the code in Section 4.
  3. Compute your indicators on the adjusted series.
  4. Set your tickdb.ai API key in TICKDB_API_KEY before running the script.
  5. If your strategy uses a free tier plan, note that historical data windows may be limited. Upgrade to a paid plan if your backtest requires more than 2 years of history.

7.2 Quant Developer / Small Fund

If you are running systematic strategies with production code:

  1. Implement the full TickDBClient class from Section 4 with retry logic, timeout enforcement, and error handling.
  2. Build a data ingestion layer that fetches kline data in overlapping windows (to handle the 1,000-row per-request limit on TickDB) and assembles a continuous series.
  3. Store adjustment events in a local reference table keyed by symbol and effective date. Update this table monthly to capture upcoming splits.
  4. Run your backtest in two modes — forward-adjusted and backward-adjusted — and verify that ratio-based metrics (Sharpe, win rate) are consistent. If they are not, you have a bug in your pipeline.
  5. Use TickDB's depth channel for real-time microstructure signals during live deployment.

7.3 Institutional Quant Team

If you are running cross-asset strategies with tick-level data requirements:

  1. Note that TickDB's depth channel covers US equities at L1 depth. For full depth (L1–L10), consider HK equity or crypto markets where available.
  2. Historical OHLCV data for US equities spans 10+ years and is suitable for multi-cycle backtesting. However, tick-level trade data is not available for US equities — plan your data architecture accordingly.
  3. Enterprise plans offer higher rate limits and dedicated support for bulk historical data extraction. Contact enterprise@tickdb.ai for pricing.
  4. For strategies that span US equities, HK equities, and crypto, use forward adjustment for all assets to maintain a single commensurability basis across the portfolio.

8. Closing: The Lesson Behind the Numbers

The stock split adjustment trap is ultimately a lesson in pipeline discipline. The mathematics of adjustment are symmetric — returns are identical whether you scale old prices down or new prices up. What is not symmetric is the mapping between your data and the real market.

Every price in a forward-adjusted series is expressed in current market terms. Every price in a backward-adjusted series is expressed in the historical nominal environment. Neither is more correct than the other. But your strategy and your trading infrastructure exist in the present, not the past — which means forward adjustment is almost always the right default for systematic trading.

The practical imperative is simple: know what your data vendor is returning, know what your indicator library is doing with it, know what your position sizing module expects, and make sure all three are speaking the same adjustment language. The moment any part of the pipeline diverges, your backtest becomes a work of fiction.

Backtest results are only as trustworthy as the data pipeline that produces them.


Next Steps

If you are an individual trader running personal strategies: subscribe to the TickDB newsletter for weekly supply-chain and microstructure analysis that puts these technical concepts in market context.

If you want to implement this in your own code:

  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 use the Python code from this article as your starting point

If you need 10+ years of historical OHLCV data for multi-cycle backtesting: reach out to enterprise@tickdb.ai for institutional plans that support extended historical windows and higher rate limits.

If you use AI coding assistants: search for and install the tickdb-market-data SKILL in your AI tool's marketplace to get TickDB API integration templates directly in your workflow.


This article does not constitute investment advice. Markets involve risk; past performance does not guarantee future results. Stock splits, corporate actions, and price adjustment methodologies are subject to change. Always validate your data pipeline against independent sources before deploying capital.