Factor Investing and Long/Short Equity › Implementing a Complete Factor Strategy
Quantitative FinanceAll chapters 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.
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")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:
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()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.