Factor Investing and Long/Short Equity › Implementing a Complete Factor Strategy

Michael BrenndoerferDecember 29, 20255 min read
Quantitative FinanceAll chapters
Part 11 of 15Quantitative Trading Strategies
Chapter 62Factor Investing and Long/Short Equity › Implementing a Complete Factor Strategy
Reading section 8 of 12 · Implementing a Complete Factor Strategy · 5 minSection 8 of 12 · 5 min

Book contents

Section 8 of 12 · 5 min read

Factor Investing and Long/Short Equity › Implementing a Complete Factor StrategyOne section · Sections in this chapter8 / 12 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let us bring together all the concepts covered by implementing a complete multi-factor long/short strategy with proper risk controls. This implementation demonstrates how the theoretical concepts translate into working code that could form the foundation of an actual trading system.

In[26]:
Code
class MultiFactorStrategy:
    """
    A complete multi-factor long/short equity strategy.

    Features:
    - Multiple factor combination
    - Sector neutralization
    - Position limits
    - Exposure constraints
    """

    def __init__(
        self,
        factors,
        factor_weights=None,
        gross_exposure=1.0,
        net_exposure=0.0,
        max_position=0.03,
        sector_neutral=True,
    ):
        self.factors = factors
        self.factor_weights = factor_weights or [1 / len(factors)] * len(
            factors
        )
        self.gross_exposure = gross_exposure
        self.net_exposure = net_exposure
        self.max_position = max_position
        self.sector_neutral = sector_neutral

    def compute_signals(self, df):
        """Compute composite factor signal for each stock."""
        df = df.copy()

        # Z-score normalize each factor
        for factor in self.factors:
            col = f"{factor}_z"
            df[col] = (df[factor] - df[factor].mean()) / df[factor].std()

        # Compute weighted composite
        df["signal"] = sum(
            w * df[f"{f}_z"] for f, w in zip(self.factors, self.factor_weights)
        )

        return df

    def construct_portfolio(self, df):
        """Build portfolio weights from signals."""
        df = self.compute_signals(df)

        if self.sector_neutral:
            weights = self._sector_neutral_weights(df)
        else:
            weights = self._simple_weights(df)

        # Apply position limits
        weights = np.clip(weights, -self.max_position, self.max_position)

        # Scale to target exposures
        weights = self._scale_exposures(weights)

        df["weight"] = weights
        return df[df["weight"] != 0].copy()

    def _sector_neutral_weights(self, df):
        """Compute sector-neutral weights."""
        weights = np.zeros(len(df))

        for sector in df["sector"].unique():
            mask = df["sector"] == sector
            sector_signals = df.loc[mask, "signal"].values

            # Long top 20%, short bottom 20% within sector
            n_sector = mask.sum()
            n_each_side = max(1, int(n_sector * 0.2))

            sorted_idx = np.argsort(sector_signals)

            # Short positions (bottom ranked)
            for i in range(n_each_side):
                local_idx = sorted_idx[i]
                global_idx = df.index[mask][local_idx]
                weights[df.index.get_loc(global_idx)] = -1.0 / n_each_side

            # Long positions (top ranked)
            for i in range(n_each_side):
                local_idx = sorted_idx[-(i + 1)]
                global_idx = df.index[mask][local_idx]
                weights[df.index.get_loc(global_idx)] = 1.0 / n_each_side

        # Normalize sector weights
        n_sectors = df["sector"].nunique()
        weights /= n_sectors

        return weights

    def _simple_weights(self, df):
        """Compute simple long/short weights without sector neutrality."""
        n_stocks = len(df)
        n_each_side = max(1, int(n_stocks * 0.2))

        weights = np.zeros(n_stocks)
        sorted_idx = np.argsort(df["signal"].values)

        # Short bottom quintile
        weights[sorted_idx[:n_each_side]] = -1.0 / n_each_side

        # Long top quintile
        weights[sorted_idx[-n_each_side:]] = 1.0 / n_each_side

        return weights

    def _scale_exposures(self, weights):
        """Scale weights to achieve target gross and net exposures."""
        long_weights = weights.copy()
        short_weights = weights.copy()
        long_weights[weights < 0] = 0
        short_weights[weights > 0] = 0

        current_long = long_weights.sum()
        current_short = abs(short_weights.sum())

        if current_long == 0 or current_short == 0:
            return weights

        # Target: gross = long + short, net = long - short
        target_long = (self.gross_exposure + self.net_exposure) / 2
        target_short = (self.gross_exposure - self.net_exposure) / 2

        long_weights *= target_long / current_long
        short_weights *= target_short / current_short

        return long_weights + short_weights


# Create and run strategy
strategy = MultiFactorStrategy(
    factors=["book_to_price", "momentum_12m", "roe"],
    factor_weights=[0.4, 0.3, 0.3],
    gross_exposure=1.5,
    net_exposure=0.1,
    max_position=0.04,
    sector_neutral=True,
)

portfolio = strategy.construct_portfolio(stocks)

# Calculate statistics for display
long_pos = portfolio[portfolio["weight"] > 0]
short_pos = portfolio[portfolio["weight"] < 0]

long_exposure = long_pos["weight"].sum()
short_exposure = abs(short_pos["weight"].sum())
gross_exposure = portfolio["weight"].abs().sum()
net_exposure = portfolio["weight"].sum()

sector_exp = portfolio.groupby("sector")["weight"].sum()
top_long_positions = long_pos.nlargest(5, "weight")
Out[27]:
Console
Multi-Factor Strategy Portfolio Summary:
==================================================
Long positions: 98
Short positions: 98
Long exposure: 80.00%
Short exposure: 70.00%
Gross exposure: 150.00%
Net exposure: 10.00%

Sector Exposures:
  Consumer: +0.020
  Finance: +0.020
  Healthcare: +0.020
  Industrial: +0.020
  Tech: +0.020

Top 5 Long Positions:
  STOCK_033: 0.89% (signal: 0.61)
  STOCK_039: 0.89% (signal: 0.68)
  STOCK_052: 0.89% (signal: 1.52)
  STOCK_053: 0.89% (signal: 0.60)
  STOCK_062: 0.89% (signal: 0.75)

The strategy produces a diversified portfolio with controlled exposures. The sector neutralization ensures that sector bets do not dominate factor returns, while position limits prevent excessive concentration in any single stock. This implementation demonstrates how the theoretical concepts we have discussed translate into a practical, risk-controlled investment strategy.

Strategy Performance Attribution

Understanding where returns come from is essential for evaluating and improving a factor strategy. Performance attribution decomposes returns into factor contributions, allowing investors to understand whether their returns come from intended factor exposures or unintended bets:

In[28]:
Code
def attribute_returns(portfolio_df, factor_returns_dict):
    """
    Decompose portfolio return into factor contributions.

    Parameters:
    -----------
    portfolio_df : DataFrame with portfolio weights and factor exposures
    factor_returns_dict : dict mapping factor names to period returns
    """
    attribution = {}

    for factor, factor_return in factor_returns_dict.items():
        # Portfolio exposure to this factor
        if f"{factor}_z" in portfolio_df.columns:
            exposure = (
                portfolio_df["weight"] * portfolio_df[f"{factor}_z"]
            ).sum()
        elif factor in portfolio_df.columns:
            z_col = (
                portfolio_df[factor] - portfolio_df[factor].mean()
            ) / portfolio_df[factor].std()
            exposure = (portfolio_df["weight"] * z_col).sum()
        else:
            exposure = 0

        contribution = exposure * factor_return
        attribution[factor] = {
            "exposure": exposure,
            "factor_return": factor_return,
            "contribution": contribution,
        }

    return pd.DataFrame(attribution).T


# Simulate one month's factor returns
monthly_factor_returns = {
    "book_to_price": 0.008,  # Value up 0.8%
    "momentum_12m": -0.012,  # Momentum down 1.2%
    "roe": 0.005,  # Quality up 0.5%
}

attribution = attribute_returns(portfolio, monthly_factor_returns)
total_contribution = attribution["contribution"].sum()
Out[29]:
Console
Monthly Return Attribution:
============================================================
Factor                 Exposure   Factor Ret Contribution
------------------------------------------------------------
Book To Price             1.178       0.80%       0.94%
Momentum 12M              1.212      -1.20%      -1.45%
Roe                       0.892       0.50%       0.45%
------------------------------------------------------------
Total Factor Return                               -0.07%

The attribution reveals that despite strong performance from value and quality factors, the portfolio's negative contribution from momentum (through the negative correlation between value and momentum stocks) reduced overall returns. This insight could inform factor weight adjustments or the addition of explicit momentum hedging. Attribution analysis is essential for diagnosing strategy performance and understanding why returns deviate from expectations.