A factor that looks brilliant on a five-year backtest but falls apart in live trading is not a factor. It is a historical artifact.

This distinction — between genuine alpha and data mining fiction — separates productive quant research from the graveyard of overfitted strategies. The methodology that separates the two is systematic, reproducible, and disciplined at every step: from raw data ingestion to signal construction to statistical validation to live deployment.

This article walks through the complete factor mining pipeline. We cover data preprocessing, factor construction techniques, information coefficient analysis for signal quality, Fama-MacBeth regression for risk-adjusted returns, and stratified portfolio backtesting. Throughout, we provide production-grade Python implementations and discuss the methodological choices that prevent your factors from becoming statistical mirages.

The target reader is a quant researcher with intermediate Python skills and familiarity with linear factor models. By the end, you will have a complete workflow you can adapt to your own data infrastructure.


1. The Factor Research Pipeline: An Overview

Factor research is not a single step. It is a pipeline with distinct stages, each with its own failure modes and quality gates.

The canonical pipeline has five stages:

  1. Data acquisition and cleaning — Raw price, volume, and fundamental data must be aligned across securities and time, with survivorship bias and look-ahead bias eliminated.
  2. Signal construction — Raw data is transformed into a cross-sectional predictor. This is the "factor" in its mathematical form.
  3. Signal validation section 3 of this article covers IC analysis, which measures how well your signal predicts forward returns.
  4. Risk model integration — Fama-MacBeth regression isolates the factor's pure alpha contribution after controlling for known risk exposures.
  5. Portfolio backtesting — Stratified backtesting applies the factor in a simulated portfolio to measure realistic returns, turnover, and transaction costs.

Skipping any stage is the single most common cause of factor failure in production. Researchers who skip Fama-MacBeth validation, for instance, often discover that their "factor" was simply a disguised bet on market beta or size.


2. Stage One: Data Acquisition and Preprocessing

2.1 Survivorship Bias: The Silent Killer

Survivorship bias occurs when your dataset includes only securities that survived to the present day. This systematically overstates returns because it excludes the companies that went bankrupt, were acquired at discounts, or simply delisted.

Consider: if you backtest a long-short strategy on the S&P 500 using only current constituents, your long portfolio will be filled with survivors of selection bias. The strategy appears to work because the companies that would have dragged returns down simply are not in your data.

Correct approach: Use a point-in-time survivorship-bias-free dataset. This means including delisted securities with their delisting returns. If you are building your own dataset, maintain a record of when each security entered and exited the investable universe.

2.2 Look-Ahead Bias

Look-ahead bias occurs when your signal uses data that was not yet publicly available at the simulated trade date. Common sources:

  • Using earnings announcement dates that were announced after the trading date
  • Incorporating fundamental data with reporting delays
  • Aligning prices from different time zones incorrectly

Standard fix: Apply a mandatory lag. For fundamental data, apply at least one trading day lag between the report publication date and when the data enters your signal calculation.

2.3 Data Alignment and Missing Values

For US equities, prices must be adjusted for splits, dividends, and corporate actions. Use total returns (including dividends) rather than price returns for factor research, because dividend policy changes carry information.

Missing values require explicit handling. Do not fill forward prices across earnings announcement gaps. Instead, treat extended periods without trading as non-computable for that security.

2.4 Data Acquisition with TickDB

For historical OHLCV data covering the US equity market, the TickDB /v1/market/kline endpoint provides 10+ years of cleaned, split-adjusted data suitable for cross-cycle factor backtesting. The following code demonstrates fetching daily klines for a universe of tickers:

import os
import time
import random
import requests
from typing import Optional

class TickDBClient:
    """Production-grade TickDB client with retry logic and rate-limit handling."""

    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 environment variable is not set")
        self.base_url = "https://api.tickdb.ai/v1"

    def _request(self, method: str, endpoint: str, params: dict = None,
                 retries: int = 5, base_delay: float = 1.0) -> dict:
        """HTTP request with exponential backoff, jitter, and rate-limit handling."""
        url = f"{self.base_url}{endpoint}"
        headers = {"X-API-Key": self.api_key}

        for attempt in range(retries):
            try:
                response = requests.request(
                    method, url, headers=headers, params=params,
                    timeout=(3.05, 10)  # (connect_timeout, read_timeout)
                )
                response.raise_for_status()
                return response.json()

            except requests.exceptions.Timeout:
                print(f"[Attempt {attempt+1}] Request timed out. Retrying...")
                delay = min(base_delay * (2 ** attempt), 30)

            except requests.exceptions.HTTPError as e:
                if e.response.status_code == 429:
                    retry_after = int(e.response.headers.get("Retry-After", 5))
                    print(f"[Attempt {attempt+1}] Rate limited. Waiting {retry_after}s.")
                    time.sleep(retry_after)
                    continue
                raise

            # Exponential backoff with jitter to prevent thundering herd
            delay = min(base_delay * (2 ** attempt), 30)
            jitter = random.uniform(0, delay * 0.1)
            time.sleep(delay + jitter)

        raise RuntimeError(f"Request failed after {retries} retries")

    def get_klines(self, symbol: str, interval: str = "1d",
                   start_time: int = None, end_time: int = None,
                   limit: int = 1000) -> list:
        """Fetch historical klines for a symbol."""
        params = {"symbol": symbol, "interval": interval, "limit": limit}
        if start_time:
            params["start"] = start_time
        if end_time:
            params["end"] = end_time

        data = self._request("GET", "/market/kline", params=params)
        return data.get("data", [])

    def get_available_symbols(self, market: str = "US") -> list:
        """Retrieve list of available symbols for a given market."""
        data = self._request("GET", "/symbols/available", params={"market": market})
        return data.get("data", [])


# Fetch daily data for a set of US equity tickers
client = TickDBClient()

# Get available US equity symbols
symbols = client.get_available_symbols(market="US")
print(f"Available US symbols: {len(symbols)}")

# Fetch 3 years of daily klines for a sample ticker
end_time = int(time.time() * 1000)
start_time = end_time - (3 * 365 * 24 * 60 * 60 * 1000)  # 3 years ago

aapl_data = client.get_klines("AAPL.US", interval="1d",
                               start_time=start_time, end_time=end_time, limit=1000)
print(f"AAPL data points retrieved: {len(aapl_data)}")

Engineering note: The _request method implements exponential backoff with jitter and respects the Retry-After header returned on rate-limit responses (code: 3001). Always load API keys from environment variables; never hardcode credentials in production code.


3. Stage Two: Factor Construction

3.1 What Makes a Factor?

A factor is a cross-sectional mapping from security-level data to a scalar score. At time $t$, for security $i$, the factor value $f_{i,t}$ is computed from data available at or before $t$.

Factors can be constructed from:

Data category Examples
Price-based Momentum (1-month, 12-month), reversal (short-term), volatility ratios
Volume-based Turnover rate, volume-to-advance-decline ratio, on-balance volume
Fundamental Book-to-market, earnings yield, free cash flow yield, revenue growth
Analyst-based Consensus revision direction, forecast dispersion, recommendation changes
Alternative Short interest, options implied volatility surface, ESG scores

The choice of factor category depends on your data infrastructure and the signal-to-noise ratio you can tolerate.

3.2 Factor Construction Example: 12-Month Price Momentum with Skip-Day Adjustment

Classic momentum factors suffer from short-term reversal contamination. A standard improvement is to skip the most recent month (the "momentum crust") and measure returns over the preceding 11 months.

import numpy as np
import pandas as pd

def compute_momentum_factor(price_df: pd.DataFrame,
                             skip_days: int = 21,
                             lookback_days: int = 252) -> pd.DataFrame:
    """
    Compute 12-month momentum factor with skip-day adjustment.

    Momentum = prod(1 + r_{t-skip-11m : t-skip}) - 1

    Parameters:
        price_df: DataFrame with columns [symbol, timestamp, close]
        skip_days: Days to skip at the end (avoids short-term reversal)
        lookback_days: Total lookback period in trading days

    Returns:
        DataFrame with columns [timestamp, symbol, momentum_score]
    """
    df = price_df.sort_values(["symbol", "timestamp"])

    # Compute daily returns
    df["return"] = df.groupby("symbol")["close"].pct_change()

    def momentum_for_group(group):
        """Compute momentum for one symbol's time series."""
        n = len(group)
        if n < lookback_days:
            return pd.Series(np.nan, index=group.index)

        result = pd.Series(np.nan, index=group.index)

        for i in range(lookback_days, n):
            # Skip the most recent `skip_days` days
            start_idx = i - lookback_days
            end_idx = i - skip_days

            if end_idx <= start_idx:
                continue

            cumulative_return = (1 + group["return"].iloc[start_idx:end_idx]).prod() - 1
            result.iloc[i] = cumulative_return

        return result

    # Apply momentum calculation per symbol
    df["momentum_score"] = df.groupby("symbol", group_keys=False).apply(
        momentum_for_group
    )

    return df[["timestamp", "symbol", "momentum_score"]].dropna()

Key design choice: We compute momentum over a rolling window per symbol rather than using vectorized numpy operations. This is slower but avoids alignment bugs when symbols have missing trading days.

3.3 Cross-Sectional Normalization

Factor values are not comparable across securities without normalization. Raw factor values have different scales across factor types (price momentum is a return ratio; fundamental factors are often in dollar units).

Two standard normalization approaches:

  1. Z-score normalization: $f'{i,t} = \frac{f{i,t} - \mu_t}{\sigma_t}$ — where $\mu_t$ and $\sigma_t$ are computed cross-sectionally at each time $t$. This is fast but sensitive to outliers.

  2. Rank normalization: Replace raw factor values with their cross-sectional percentile rank. This is robust to outliers and non-normal distributions but loses information about factor magnitude.

For most quant strategies, rank normalization is preferred because factor monotonicity in the rank dimension is sufficient for portfolio construction, and rank-based signals are more robust to outliers in live trading.

def normalize_factor_rank(factor_df: pd.DataFrame,
                          factor_col: str = "factor") -> pd.DataFrame:
    """
    Normalize factor to cross-sectional rank percentile.
    Result is in [0, 1] — higher values indicate stronger signal.
    """
    df = factor_df.copy()
    df[factor_col] = df.groupby("timestamp")[factor_col].rank(pct=True)
    return df

4. Stage Three: Information Coefficient Analysis

4.1 What Is IC?

The Information Coefficient (IC) measures the correlation between your factor's cross-sectional ranking and the subsequent period's returns. Formally:

$$IC_t = \text{corr}\left(f_{i,t}, r_{i,t+1}\right)$$

where $f_{i,t}$ is the factor value for security $i$ at time $t$, and $r_{i,t+1}$ is the forward return.

IC ranges from -1 to 1. Key thresholds:

IC range Interpretation
0.05 – 0.10 Moderate signal; worth investigating with risk controls
0.10 – 0.20 Strong signal; typical for well-known factors like momentum
> 0.20 Very strong; verify it is not data mining artifact
< 0.00 consistently Factor is inverted — try flipping the sign

4.2 IC Computation

from scipy.stats import spearmanr, pearsonr

def compute_ic_series(factor_df: pd.DataFrame,
                      return_df: pd.DataFrame,
                      factor_col: str = "factor",
                      method: str = "spearman") -> pd.DataFrame:
    """
    Compute time-series of Information Coefficients.

    Parameters:
        factor_df: DataFrame with [timestamp, symbol, factor_value]
        return_df: DataFrame with [timestamp, symbol, forward_return]
        factor_col: Name of the factor column
        method: 'spearman' (rank correlation) or 'pearson' (linear correlation)

    Returns:
        DataFrame with IC time series and summary statistics
    """
    # Merge factor values with forward returns
    merged = factor_df.merge(
        return_df,
        on=["timestamp", "symbol"],
        how="inner"
    )

    ic_series = []
    correlation_fn = spearmanr if method == "spearman" else pearsonr

    for ts, group in merged.groupby("timestamp"):
        if len(group) < 10:  # Require minimum sample size
            continue

        factor_vals = group[factor_col].values
        forward_rets = group["forward_return"].values

        # Drop NaN values
        mask = ~(np.isnan(factor_vals) | np.isnan(forward_rets))
        if mask.sum() < 10:
            continue

        corr, pvalue = correlation_fn(
            factor_vals[mask],
            forward_rets[mask]
        )

        ic_series.append({
            "timestamp": ts,
            "ic": corr,
            "pvalue": pvalue,
            "n_securities": mask.sum()
        })

    ic_df = pd.DataFrame(ic_series)

    # Summary statistics
    summary = {
        "mean_ic": ic_df["ic"].mean(),
        "std_ic": ic_df["ic"].std(),
        "ir": ic_df["ic"].mean() / ic_df["ic"].std() if ic_df["ic"].std() > 0 else np.nan,
        "positive_ratio": (ic_df["ic"] > 0).mean(),
        "count": len(ic_df)
    }

    return ic_df, summary


def compute_rolling_ic(ic_df: pd.DataFrame,
                       window: int = 60) -> pd.DataFrame:
    """
    Compute rolling Information Ratio over a trailing window.
    """
    ic_df = ic_df.copy()
    ic_df = ic_df.sort_values("timestamp")
    ic_df["rolling_mean_ic"] = ic_df["ic"].rolling(window=window).mean()
    ic_df["rolling_std_ic"] = ic_df["ic"].rolling(window=window).std()
    ic_df["rolling_ir"] = ic_df["rolling_mean_ic"] / ic_df["rolling_std_ic"]
    return ic_df

4.3 IC Decay Analysis

A common failure mode is a factor that has strong IC in-sample but decays rapidly out-of-sample. IC decay analysis plots IC as a function of holding period to detect this:

import matplotlib.pyplot as plt

def compute_ic_by_horizon(factor_df: pd.DataFrame,
                          return_dfs: dict,
                          factor_col: str = "factor") -> pd.DataFrame:
    """
    Compute IC at multiple holding period horizons.
    return_dfs: dict mapping horizon (e.g., 1, 5, 20 days) to DataFrame
    """
    results = []

    for horizon, ret_df in return_dfs.items():
        ic_df, summary = compute_ic_series(factor_df, ret_df, factor_col)
        results.append({
            "horizon_days": horizon,
            "mean_ic": summary["mean_ic"],
            "ir": summary["ir"],
            "positive_ratio": summary["positive_ratio"]
        })

    return pd.DataFrame(results)


# Example: Plot IC decay curve
horizon_results = compute_ic_by_horizon(
    factor_df=momentum_factors,
    return_dfs={
        1: forward_returns_1d,
        5: forward_returns_5d,
        10: forward_returns_10d,
        20: forward_returns_20d
    }
)

plt.figure(figsize=(10, 5))
plt.plot(horizon_results["horizon_days"], horizon_results["mean_ic"], marker="o")
plt.axhline(y=0, color="gray", linestyle="--", alpha=0.5)
plt.xlabel("Holding Period (Days)")
plt.ylabel("Mean IC")
plt.title("IC Decay Across Holding Periods")
plt.grid(True, alpha=0.3)
plt.show()

A well-constructed factor typically shows IC that decays gradually with holding period. A sharp drop-off within the first 5 days suggests your signal is dominated by microstructure noise rather than fundamental predictive power.


5. Stage Four: Fama-MacBeth Regression

5.1 Why Fama-MacBeth?

IC analysis tells you whether your factor predicts forward returns. It does not tell you whether the factor provides independent alpha — that is, alpha that is not already explained by known risk factors.

Fama-MacBeth (FM) regression addresses this. The two-pass FM procedure:

  1. First pass: Run cross-sectional regressions of returns on factors at each time period to estimate factor risk premia.
  2. Second pass: Time-series average of the first-pass coefficients to test significance.

This isolates the factor's marginal contribution after controlling for exposures to market, size, value, momentum, and other established factors.

5.2 The Model

At each time $t$, estimate the cross-sectional regression:

$$r_{i,t+1} = \lambda_{0,t} + \lambda_{1,t} \beta_{i,t}^{MKT} + \lambda_{2,t} \beta_{i,t}^{SMB} + \lambda_{3,t} \beta_{i,t}^{HML} + \cdots + \gamma_{t} f_{i,t} + \epsilon_{i,t}$$

where $\gamma_t$ is your custom factor's risk premium at time $t$, and the $\lambda$ terms are standard risk factor premia.

The second pass tests whether $\bar{\gamma} = \frac{1}{T} \sum_t \gamma_t$ is statistically significant.

5.3 FM Regression Implementation

import statsmodels.api as sm

def fama_macbeth_regression(returns_df: pd.DataFrame,
                             factor_df: pd.DataFrame,
                             risk_factor_cols: list,
                             custom_factor_col: str,
                             significance_level: float = 0.05) -> dict:
    """
    Two-pass Fama-MacBeth regression.

    Parameters:
        returns_df: DataFrame with [timestamp, symbol, return]
        factor_df: DataFrame with [timestamp, symbol] + factor columns
        risk_factor_cols: List of known risk factor column names
        custom_factor_col: Name of the custom factor to test
        significance_level: p-value threshold for statistical significance

    Returns:
        Dictionary with regression results and significance tests
    """
    # Merge returns with all factors
    merged = returns_df.merge(factor_df, on=["timestamp", "symbol"], how="inner")

    all_factor_cols = risk_factor_cols + [custom_factor_col]

    # First pass: cross-sectional regressions per time period
    gammas = []  # Custom factor coefficients
    lambdas = {col: [] for col in all_factor_cols}  # All factor risk premia

    for ts, group in merged.groupby("timestamp"):
        if len(group) < all_factor_cols.__len__() + 5:
            continue

        y = group["return"].values
        X = group[all_factor_cols].values

        # Drop rows with missing values
        mask = ~(np.isnan(y) | np.any(np.isnan(X), axis=1))
        if mask.sum() < 20:
            continue

        y_clean, X_clean = y[mask], X[mask]

        try:
            X_with_const = sm.add_constant(X_clean, has_constant="add")
            model = sm.OLS(y_clean, X_with_const).fit()

            # Index 0 is the intercept; factor indices start at 1
            gammas.append(model.params[all_factor_cols.index(custom_factor_col) + 1])

            for i, col in enumerate(all_factor_cols):
                lambdas[col].append(model.params[i + 1])

        except Exception as e:
            print(f"Regression failed at {ts}: {e}")
            continue

    # Second pass: t-tests on time-series means
    results = {}
    for col in all_factor_cols:
        coef_series = np.array(lambdas[col])
        mean_coef = np.nanmean(coef_series)
        std_coef = np.nanstd(coef_series)
        t_stat = mean_coef / (std_coef / np.sqrt(len(coef_series))) if std_coef > 0 else np.nan
        p_value = 2 * (1 - stats.t.cdf(abs(t_stat), df=len(coef_series) - 1))

        results[col] = {
            "mean_premium": mean_coef,
            "std_premium": std_coef,
            "t_statistic": t_stat,
            "p_value": p_value,
            "significant": p_value < significance_level,
            "n_periods": len(coef_series)
        }

    # Custom factor summary
    custom_results = results[custom_factor_col]
    print(f"\n=== Fama-MacBeth Results for '{custom_factor_col}' ===")
    print(f"Mean risk premium:     {custom_results['mean_premium']:.6f}")
    print(f"t-statistic:           {custom_results['t_statistic']:.3f}")
    print(f"p-value:              {custom_results['p_value']:.4f}")
    print(f"Significant at {significance_level}: {'YES' if custom_results['significant'] else 'NO'}")

    return results


# Example usage
fm_results = fama_macbeth_regression(
    returns_df=forward_returns,
    factor_df=merged_factors,  # Includes MKT, SMB, HLM, MOM + custom factor
    risk_factor_cols=["beta_mkt", "beta_smb", "beta_hml", "beta_mom"],
    custom_factor_col="momentum_score"
)

5.4 Interpreting FM Results

Scenario Interpretation
High IC, but FM p-value > 0.05 Factor is not independent alpha — it is likely a combination of existing risk factors
Moderate IC, but FM p-value < 0.05 Genuine independent alpha — the factor contributes unique predictive power
FM coefficient positive but shrinking over time Factor is experiencing decay — consider dynamic weighting or retirement

A factor that cannot pass the Fama-MacBeth significance test should not be used as a standalone alpha source. It can still serve as a risk factor or as part of a multifactor model, but it does not provide independent alpha.


6. Stage Five: Stratified Portfolio Backtesting

6.1 Why Stratified Backtesting?

IC analysis and Fama-MacBeth regression measure signal quality in isolation. Neither tells you how the factor behaves in a portfolio context: turnover, transaction costs, sector concentration, market impact, and drawdown patterns all affect realized returns in ways that cross-sectional correlations cannot capture.

Stratified backtesting addresses this by constructing portfolios based on factor quintiles or deciles, measuring performance at each tier and computing spread portfolio returns.

6.2 Portfolio Construction

def stratified_portfolio_backtest(factor_df: pd.DataFrame,
                                  return_df: pd.DataFrame,
                                  factor_col: str = "factor",
                                  n_portfolios: int = 5,
                                  rebalance_frequency: str = "20D",
                                  long_top_pct: float = 0.2,
                                  short_bottom_pct: float = 0.2,
                                  transaction_cost_bps: float = 5.0) -> pd.DataFrame:
    """
    Construct long-short portfolios from factor quintiles and compute returns.

    Parameters:
        factor_df: Cross-sectional factor values at each rebalance date
        return_df: Forward returns for the next period
        n_portfolios: Number of quantile portfolios
        long_top_pct: Fraction of portfolio allocated to top factor stocks
        short_bottom_pct: Fraction allocated to short portfolio
        transaction_cost_bps: Round-trip transaction cost in basis points

    Returns:
        DataFrame with portfolio returns and cumulative performance
    """
    # Merge factor and return data
    merged = factor_df.merge(
        return_df.rename(columns={"forward_return": "return"}),
        on=["timestamp", "symbol"],
        how="inner"
    )

    portfolio_returns = []
    position_records = []

    for ts, group in merged.groupby("timestamp"):
        # Assign factor ranks within the cross-section
        group = group.dropna(subset=[factor_col, "return"]).copy()
        if len(group) < n_portfolios * 2:
            continue

        group["factor_rank"] = group[factor_col].rank(pct=True)

        # Portfolio assignment
        group["portfolio"] = pd.cut(
            group["factor_rank"],
            bins=n_portfolios,
            labels=range(1, n_portfolios + 1)
        )

        # Compute equal-weight portfolio returns
        portfolio_ret = group.groupby("portfolio")["return"].mean()

        # Long-short spread portfolio
        top_ret = portfolio_ret.iloc[-1]  # Top quintile
        bottom_ret = portfolio_ret.iloc[0]  # Bottom quintile
        spread_ret = top_ret - bottom_ret

        # Compute turnover (fraction of portfolio that changed positions)
        # Simplified: use factor rank change as proxy
        portfolio_returns.append({
            "timestamp": ts,
            "long_return": top_ret,
            "short_return": bottom_ret,
            "spread_return": spread_ret,
            "n_long": (group["portfolio"] == n_portfolios).sum(),
            "n_short": (group["portfolio"] == 1).sum()
        })

        # Record long and short positions for turnover calculation
        longs = group[group["portfolio"] == n_portfolios]["symbol"].tolist()
        shorts = group[group["portfolio"] == 1]["symbol"].tolist()
        position_records.append({"timestamp": ts, "longs": longs, "shorts": shorts})

    ret_df = pd.DataFrame(portfolio_returns)

    # Apply transaction costs (round-trip = 2 * one-way cost)
    # Assume rebalancing costs apply to changed positions only
    ret_df["net_spread_return"] = ret_df["spread_return"] - (2 * transaction_cost_bps / 10000)

    # Cumulative returns
    ret_df["cumulative_long"] = (1 + ret_df["long_return"]).cumprod()
    ret_df["cumulative_short"] = (1 + ret_df["short_return"]).cumprod()
    ret_df["cumulative_spread"] = (1 + ret_df["net_spread_return"]).cumprod()

    return ret_df, position_records


def compute_backtest_metrics(ret_df: pd.DataFrame) -> dict:
    """Compute comprehensive backtest performance metrics."""
    spread = ret_df["net_spread_return"]

    total_return = ret_df["cumulative_spread"].iloc[-1] - 1
    annualized_return = (1 + total_return) ** (252 / len(spread)) - 1
    annualized_vol = spread.std() * np.sqrt(252)
    sharpe = annualized_return / annualized_vol if annualized_vol > 0 else np.nan

    # Maximum drawdown
    cumulative = ret_df["cumulative_spread"]
    running_max = cumulative.cummax()
    drawdown = (cumulative - running_max) / running_max
    max_drawdown = drawdown.min()

    # Win rate
    win_rate = (spread > 0).mean()

    # Average win / average loss
    avg_win = spread[spread > 0].mean()
    avg_loss = spread[spread < 0].mean()
    profit_factor = abs(avg_win / avg_loss) if avg_loss != 0 else np.nan

    return {
        "total_return": f"{total_return:.2%}",
        "annualized_return": f"{annualized_return:.2%}",
        "annualized_volatility": f"{annualized_vol:.2%}",
        "sharpe_ratio": f"{sharpe:.2f}",
        "max_drawdown": f"{max_drawdown:.2%}",
        "win_rate": f"{win_rate:.2%}",
        "profit_factor": f"{profit_factor:.2f}",
        "n_rebalances": len(ret_df)
    }

6.3 Backtest Disclosure Requirements

Any backtest result published or used for strategy decisions must include the following disclosures:

Metric Minimum acceptable Best practice
Backtest period At least one full market cycle 3+ years, covering both bull and bear markets
Sample size 20+ rebalance events 50+ rebalance events
Win rate Must report Gross and net-of-costs
Sharpe ratio Must report Also report Sortino ratio
Maximum drawdown Must report Report drawdown duration
Benchmark Buy-and-hold of the universe Universe index + factor-mimicking portfolio
Transaction costs Must state assumption Sensitivity analysis across cost ranges

Backtest limitations: Historical results do not guarantee future performance. Transaction costs are approximated using fixed round-trip assumptions; actual market impact may differ significantly in live trading. The backtest does not account for liquidity exhaustion during market stress periods. Results assume equal-weighting within portfolios; other weighting schemes may produce different outcomes.


7. Avoiding Data Mining Bias

7.1 The Multiple Testing Problem

If you test 1,000 random factors on the same dataset, you expect roughly 50 to show "significant" IC at the 5% level purely by chance. Publishing only the winners is a form of selection bias.

Mitigations:

  • In-sample / out-of-sample split: Reserve at least 30% of your data for out-of-sample testing. Never tune parameters on out-of-sample data.
  • Walk-forward validation: Use expanding or rolling windows. Each window's in-sample trains the model; the following window tests it.
  • Hypothesis-free screening: If you must screen many factors, apply a Bonferroni correction or false discovery rate adjustment.
  • Economic intuition before statistical significance: A factor with IC = 0.08 that has a clear economic mechanism (e.g., analyst revision momentum) is more credible than a factor with IC = 0.12 that has no theoretical grounding.

7.2 Factor Correlations

Highly correlated factors provide redundant signals. Adding a second factor with 0.95 correlation to your first adds negligible alpha but increases turnover and execution complexity.

Check factor correlations regularly:

def factor_correlation_matrix(factor_df: pd.DataFrame,
                               factor_cols: list) -> pd.DataFrame:
    """Compute cross-sectional correlation between factor scores."""
    # Pivot to cross-sectional format: rows = timestamp, columns = factor scores
    pivoted = factor_df.pivot_table(
        index="timestamp",
        columns="symbol",
        values=factor_cols
    )

    # Compute average cross-sectional correlation per factor pair
    n = len(factor_cols)
    corr_matrix = np.zeros((n, n))

    for i, col1 in enumerate(factor_cols):
        for j, col2 in enumerate(factor_cols):
            correlations = []
            for ts, group in factor_df.groupby("timestamp"):
                f1 = group[col1]
                f2 = group[col2]
                mask = ~(f1.isna() | f2.isna())
                if mask.sum() > 20:
                    corr, _ = spearmanr(f1[mask], f2[mask])
                    if not np.isnan(corr):
                        correlations.append(corr)
            corr_matrix[i, j] = np.mean(correlations)

    return pd.DataFrame(corr_matrix, index=factor_cols, columns=factor_cols)

8. A Complete Workflow Example

Combining all stages into a production-ready pipeline:

def run_factor_research_pipeline(tickers: list,
                                  start_date: str,
                                  end_date: str,
                                  factor_config: dict) -> dict:
    """
    End-to-end factor research pipeline.

    factor_config: dict with keys:
        - 'factor_type': 'momentum' | 'value' | 'quality' | 'custom'
        - 'skip_days': days to skip at end of momentum lookback
        - 'lookback_days': total lookback period
        - 'rebalance_days': days between portfolio rebalancing
    """
    client = TickDBClient()

    # Step 1: Fetch historical data for all tickers
    print("Fetching historical price data...")
    all_prices = []
    for ticker in tickers:
        data = client.get_klines(f"{ticker}.US", interval="1d",
                                  start_time=start_date, end_time=end_date)
        df = pd.DataFrame(data)
        if not df.empty:
            df["symbol"] = ticker
            all_prices.append(df)

    price_df = pd.concat(all_prices, ignore_index=True)
    print(f"Total price records: {len(price_df)}")

    # Step 2: Compute factor
    print("Computing factor...")
    factor_df = compute_momentum_factor(
        price_df,
        skip_days=factor_config.get("skip_days", 21),
        lookback_days=factor_config.get("lookback_days", 252)
    )

    # Step 3: Compute forward returns
    print("Computing forward returns...")
    return_df = compute_forward_returns(price_df, horizons=[5, 20])

    # Step 4: IC analysis
    print("Running IC analysis...")
    ic_df, ic_summary = compute_ic_series(
        factor_df,
        return_df[return_df["horizon"] == 5],
        method="spearman"
    )
    print(f"Mean IC: {ic_summary['mean_ic']:.4f}, IR: {ic_summary['ir']:.2f}")

    # Step 5: Fama-MacBeth regression
    print("Running Fama-MacBeth regression...")
    fm_results = fama_macbeth_regression(
        returns_df=return_df[return_df["horizon"] == 5],
        factor_df=factor_df,
        risk_factor_cols=["beta_mkt"],
        custom_factor_col="momentum_score"
    )

    # Step 6: Stratified backtest
    print("Running stratified backtest...")
    ret_df, positions = stratified_portfolio_backtest(
        factor_df=factor_df,
        return_df=return_df[return_df["horizon"] == 5],
        rebalance_frequency=f"{factor_config.get('rebalance_days', 20)}D"
    )
    metrics = compute_backtest_metrics(ret_df)

    return {
        "ic_summary": ic_summary,
        "fama_macbeth": fm_results,
        "backtest_metrics": metrics,
        "portfolio_returns": ret_df
    }


if __name__ == "__main__":
    # Sample run with common US large-cap tickers
    tickers = ["AAPL", "MSFT", "GOOGL", "AMZN", "META", "NVDA",
               "JPM", "BAC", "GS", "XOM", "CVX", "JNJ", "PFE",
               "PG", "KO", "DIS", "NFLX", "AMD", "INTC", "CRM"]

    results = run_factor_research_pipeline(
        tickers=tickers,
        start_date=int((pd.Timestamp.now() - pd.DateOffset(years=3)).timestamp() * 1000),
        end_date=int(pd.Timestamp.now().timestamp() * 1000),
        factor_config={
            "factor_type": "momentum",
            "skip_days": 21,
            "lookback_days": 252,
            "rebalance_days": 20
        }
    )

    print("\n=== Final Backtest Metrics ===")
    for k, v in results["backtest_metrics"].items():
        print(f"  {k}: {v}")

9. Conclusion

Factor research is a disciplined pipeline, not an exploratory search. The researchers who consistently find durable factors are the ones who respect the methodological constraints at every stage:

  • They build survivorship-bias-free datasets before writing a single line of factor code.
  • They normalize signals cross-sectionally and measure IC before claiming predictive power.
  • They run Fama-MacBeth regressions to confirm that alpha is independent, not just a disguised bet on market beta.
  • They backtest in portfolio context, with realistic transaction costs and turnover constraints.
  • They hold back out-of-sample data as an unforgiving final test, not as a post-hoc justification.

The factors that survive this gauntlet are not guaranteed to work forever — markets adapt, and factor decay is a real phenomenon documented across decades of quant research. But they are built on a foundation that is far more likely to generalize than a backtest optimized on a single in-sample window.


Next Steps

If you are building your factor research infrastructure, TickDB provides historical OHLCV data for US equities covering 10+ years of cleaned, split-adjusted records. The /v1/market/kline endpoint supports flexible interval and date-range parameters, making it straightforward to pull the data volumes needed for multi-year cross-cycle backtesting.

If you want to test this workflow with live data:

  1. Sign up at tickdb.ai — the free tier provides sufficient API access for factor discovery prototyping
  2. Set the TICKDB_API_KEY environment variable
  3. Adapt the code examples in this article to your factor universe and rebalance schedule

If you are evaluating institutional-grade data for a quant team, TickDB offers Professional and Enterprise plans with expanded historical coverage, higher rate limits, and dedicated support for multifactor research pipelines. Contact enterprise@tickdb.ai for plan details.

If you use AI coding assistants, search for and install the tickdb-market-data SKILL in your AI tool's marketplace to integrate TickDB data calls directly into your research workflow.


This article does not constitute investment advice. Factor performance is subject to market regime changes, data mining bias, and execution constraints. Past performance of backtests does not guarantee future returns.