Factor-Constrained Portfolio and Attribution#

This tutorial shows how to build a dollar-neutral long-short portfolio with factor tilts, using the characteristics-based cross-sectional factor model CharacteristicsFactorModel and the optimizer MeanRisk. The methodology is covered in the Portfolio Construction and Attribution sections of the user guide.

We will:

  • optimize a portfolio with explicit factor exposure constraints

  • jointly tune the optimizer and factor model with online search

  • backtest it with monthly walk-forward rebalancing

  • perform ex-ante and ex-post attribution of exposures, risk and performance

Data#

We reuse the synthetic characteristics panel from the previous tutorial. It covers 500 assets over 1,500 trading days and includes late listings, delistings, holidays and missing characteristics:

import numpy as np
from plotly.io import show

from skfolio.datasets import make_synthetic_characteristics

panel = make_synthetic_characteristics(
    n_assets=500, n_observations=1500, random_state=0
)

Model Definition#

Next, we rebuild the 24-factor model with one global market factor, 10 industry factors and 13 style factors built from 29 descriptors. See the previous tutorial for more details:

from skfolio.descriptor import (
    AssetTurnover,
    AssetsGrowthRate,
    BookLeverage,
    BookToPrice,
    CapexToAssetsChangeInIntensity,
    CashFlowToAssets,
    CashFlowToPrice,
    DebtToAssets,
    DividendToPrice,
    EWAmihudIlliquidity,
    EWMarketBeta,
    EWMomentum,
    EWResidualVolatility,
    EWShareTurnover,
    EWVolatility,
    EarningsChangeToPrice,
    EarningsToPrice,
    EbitdaToEnterpriseValue,
    ForwardEarningsToPrice,
    GrossMargin,
    GrossProfitability,
    IssuanceGrowthRate,
    LogMarketCap,
    MarketLeverage,
    ReturnOnAssets,
    ReturnOnEquity,
    SalesGrowthRate,
    SalesToPrice,
    ShareholderYield,
)
from skfolio.factor_exposure import (
    DerivedFactor,
    FixedWeightedFactor,
    GlobalFactor,
    OneHotCategoricalFactors,
)
from skfolio.moments import EWMu, RegimeAdjustedEWCovariance
from skfolio.prior import CharacteristicsFactorModel, EmpiricalPrior

month = 21
quarter = 3 * month
half_year = 6 * month
year = 12 * month

global_factor = GlobalFactor(family="market")

industry_factors = OneHotCategoricalFactors(category="industry", family="industry")

beta_factor = FixedWeightedFactor(
    descriptors=[("market_beta", EWMarketBeta(half_life=year))],
    transform_by_group="industry",
)

momentum_factor = FixedWeightedFactor(
    descriptors=[("momentum", EWMomentum(half_life=half_year, skip=month))],
    transform_by_group="industry",
)

size_factor = FixedWeightedFactor(
    descriptors=[("log_mcap", LogMarketCap())], transform_by_group="industry"
)

non_linear_size_factor = DerivedFactor(
    source="size", func=lambda x: x**3, transform_by_group="industry"
)

value_factor = FixedWeightedFactor(
    descriptors=[
        ("book_to_price", BookToPrice()),
        ("sales_to_price", SalesToPrice()),
        ("cash_flow_to_price", CashFlowToPrice()),
    ],
    weights=[0.8, 0.1, 0.1],
    transform_by_group="industry",
)

earnings_yield_factor = FixedWeightedFactor(
    descriptors=[
        ("fwd_earnings_to_price", ForwardEarningsToPrice()),
        ("earnings_to_price", EarningsToPrice()),
        ("enterprise_multiple", EbitdaToEnterpriseValue()),
    ],
    transform_by_group="industry",
)

growth_factor = FixedWeightedFactor(
    descriptors=[
        ("earnings_change_to_price", EarningsChangeToPrice(lag=year)),
        ("sales_growth", SalesGrowthRate(lag=year)),
    ],
    transform_by_group="industry",
)

profitability_factor = FixedWeightedFactor(
    descriptors=[
        ("asset_turnover", AssetTurnover()),
        ("gross_profitability", GrossProfitability()),
        ("gross_margin", GrossMargin()),
        ("return_on_assets", ReturnOnAssets()),
        ("return_on_equity", ReturnOnEquity()),
        ("cash_flow_to_assets", CashFlowToAssets()),
    ],
    transform_by_group="industry",
)

investment_factor = FixedWeightedFactor(
    descriptors=[
        ("asset_growth", AssetsGrowthRate(lag=year)),
        ("issuance_growth", IssuanceGrowthRate(lag=year)),
        ("capex_growth", CapexToAssetsChangeInIntensity(lag=year)),
    ],
    transform_by_group="industry",
)

dividend_yield_factor = FixedWeightedFactor(
    descriptors=[
        ("dividend_to_price", DividendToPrice()),
        ("shareholder_yield", ShareholderYield()),
    ],
    weights=[0.7, 0.3],
    transform_by_group="industry",
)

leverage_factor = FixedWeightedFactor(
    descriptors=[
        ("market_leverage", MarketLeverage()),
        ("debt_to_assets", DebtToAssets()),
        ("book_leverage", BookLeverage()),
    ],
    transform_by_group="industry",
)

liquidity_factor = FixedWeightedFactor(
    descriptors=[
        ("share_turnover", EWShareTurnover(half_life=quarter)),
        ("amihud_illiquidity", EWAmihudIlliquidity()),
    ],
    transform_by_group="industry",
)

volatility_factor = FixedWeightedFactor(
    descriptors=[
        ("vol", EWVolatility(half_life=quarter)),
        (
            "residual_vol",
            EWResidualVolatility(half_life=quarter, beta_half_life=quarter),
        ),
    ],
    transform_by_group="industry",
)

model = CharacteristicsFactorModel(
    factors=[
        ("market", global_factor),
        ("industry", industry_factors),
        ("beta", beta_factor),
        ("momentum", momentum_factor),
        ("size", size_factor),
        ("non_linear_size", non_linear_size_factor),
        ("value", value_factor),
        ("earnings_yield", earnings_yield_factor),
        ("growth", growth_factor),
        ("profitability", profitability_factor),
        ("investment", investment_factor),
        ("dividend_yield", dividend_yield_factor),
        ("leverage", leverage_factor),
        ("liquidity", liquidity_factor),
        ("volatility", volatility_factor),
    ],
    neutralize_against={
        "non_linear_size": ["size"],
        "volatility": ["beta"],
    },
    constrained_families=[("industry", None)],
    exposure_lag=1,
    inv_idio_variance_weight_shrinkage=0.5,
    factor_prior_estimator=EmpiricalPrior(
        covariance_estimator=RegimeAdjustedEWCovariance(
            half_life=half_year, corr_half_life=year, regime_half_life=month
        ),
        mu_estimator=EWMu(half_life=year),
    ),
    n_jobs=-1,
)

Factor-Constrained Optimization#

The factor model is a prior estimator, so we can pass it to any skfolio optimizer through prior_estimator. The optimizer fits it internally and consumes its expected returns, covariance and scenarios, while scikit-learn metadata routing forwards the characteristics panel to the prior.

Let’s define the portfolio. We maximize the Sharpe ratio under explicit factor exposure constraints [1]. The synthetic generator gives momentum and dividend yield positive premia and investment a weak premium, so we target:

  • long momentum, with exposure at least 1.0,

  • long dividend yield, set exactly to 1.5,

  • short investment, set exactly to -1.0.

We also set beta, size, volatility and every industry exposure to exactly zero, and bound each remaining style within \(\pm 0.05\):

from sklearn import set_config

from skfolio import RiskMeasure
from skfolio.optimization import MeanRisk, ObjectiveFunction

set_config(enable_metadata_routing=True)

X = panel.to_dataframe(fields="returns")
industry_names = panel.fields["industry"].levels

bounded_styles = [
    "non_linear_size",
    "value",
    "earnings_yield",
    "growth",
    "profitability",
    "leverage",
    "liquidity",
]

mvo = MeanRisk(
    objective_function=ObjectiveFunction.MAXIMIZE_RATIO,
    risk_measure=RiskMeasure.VARIANCE,
    prior_estimator=model,
    max_weights=0.05,  # limit individual positions to 5%
    min_weights=-0.05,  # allow short positions and limit to -5%
    budget=0.0,  # dollar neutral
    max_long=1.0,  # cap long exposure at 100%
    linear_constraints=[
        # Style exposures
        "momentum >= 1.0",
        "dividend_yield == 1.5",
        "investment == -1.0",
        # Styles set to zero
        "beta == 0",
        "size == 0",
        "volatility == 0",
        # Remaining styles within +/- 0.05
        *[f"{name} <= 0.05" for name in bounded_styles],
        *[f"{name} >= -0.05" for name in bounded_styles],
        # Industries set to zero
        *[f"{name} == 0" for name in industry_names],
    ],
)

mvo.fit(X, characteristics=panel)

print(f"Long positions: {(mvo.weights_ > 1e-8).sum()}")
print(f"Short positions: {(mvo.weights_ < -1e-8).sum()}")
print(f"Gross exposure: {np.abs(mvo.weights_).sum():.2f}")
Long positions: 178
Short positions: 180
Gross exposure: 2.00

budget=0.0 makes the portfolio dollar neutral and max_long=1.0 caps the long exposure at 100%. Dollar neutrality implies an equally sized short exposure, so the gross exposure can reach 200%. Individual positions are limited to \(\pm 5\%\). We will add transaction costs and a fallback in the walk-forward backtest below, where each rebalancing starts from the previous allocation.

In linear_constraints, an expression on a factor name (e.g. "momentum >= 1.0") applies to the portfolio exposure to that factor. Industry neutrality needs one constraint per industry factor. A single "industry == 0" on the family name would only force industry exposures to offset each other. For the market factor, budget=0.0 is equivalent to a "market == 0" constraint on the global factor exposure, so the explicit constraint is unnecessary.

The factor model fitted inside the optimizer is available through prior_estimator_. We keep a reference to it for attribution below:

factor_model = mvo.prior_estimator_.factor_model_

Ex-Ante Attribution#

Ex-ante attribution reports the portfolio’s factor exposures and decomposes its forecast risk and expected return into systematic and idiosyncratic contributions [2]. We obtain it from the predicted portfolio with predicted_attribution:

portfolio = mvo.predict(X)
predicted_attrib = portfolio.predicted_attribution(factor_model=factor_model)
# Equivalent lower-level call on the factor model:
# factor_model.predicted_attribution(weights=mvo.weights_)

First, we verify that the optimizer delivered the targeted exposures:

predicted_attrib.plot_exposure(top_n=15)


The dividend-yield factor sits at its 1.5 target and the investment factor at its -1.0 target. Momentum is bounded below by its 1.0 floor and can exceed it when the Sharpe-maximizing objective concentrates in the factor with the strongest forecast premium. The market, industry and neutralized style exposures are zero, and the remaining styles stay within their \(\pm 0.05\) bands.

Next, we look at where the predicted risk comes from:

predicted_attrib.plot_vol_contrib(top_n=15)


Each factor’s volatility contribution is given by the product of its exposure, standalone volatility and correlation with the portfolio (the exposure-volatility-correlation decomposition). The idiosyncratic contribution comes from the part of the allocation that moves into orthogonal directions once market and industry exposures are forced to zero.

Let’s do the same for the expected return:

predicted_attrib.plot_return_contrib(top_n=15)


The targeted factors drive the expected return. The idiosyncratic contribution is exactly zero because no alpha estimator is attached, so the model forecasts no return in the orthogonal space.

We can also plot each factor’s expected return contribution against its volatility contribution:

predicted_attrib.plot_return_vs_vol_contrib(top_n=15)


The targeted factors drive both dimensions, while the idiosyncratic component lies on the zero-return axis, carrying risk without forecast reward.

The same information is also available as DataFrames. Let’s start with the summary: it reports volatility and return contributions for the systematic, idiosyncratic and total components:

predicted_attrib.summary_df()
Volatility Contribution % of Total Variance Expected Return Contribution
Component
Systematic 3.83% 74.74% 6.77%
Idiosyncratic 1.29% 25.26% 0.00%
Total 5.12% 100.00% 6.77%


Each volatility contribution is the corresponding variance contribution divided by total volatility, so the systematic and idiosyncratic rows sum to the total volatility forecast.

The family breakdown aggregates exposures and contributions by factor family. As imposed by the constraints, we can see that market and industries carry no risk or return:

predicted_attrib.families_df()
Exposure Volatility Contribution % of Total Variance Expected Return Contribution
Family
style 2.4488 3.83% 74.74% 6.77%
market -0.0000 -0.00% -0.00% -0.00%
industry 0.0000 0.00% 0.00% -0.00%


The factor breakdown reports per-factor exposures, standalone statistics, and contributions to portfolio risk and expected return:

predicted_attrib.factors_df()
Family Exposure Volatility Contribution % of Total Variance Expected Return Contribution Standalone Volatility Standalone Expected Return Correlation with Portfolio
Factor
momentum style 1.8594 1.94% 37.77% 4.72% 1.61% 2.54% 0.6460
dividend_yield style 1.5000 1.37% 26.75% 0.69% 1.62% 0.46% 0.5649
investment style -1.0000 0.52% 10.09% 1.03% 1.48% -1.03% -0.3488
earnings_yield style 0.0500 0.01% 0.28% 0.03% 1.77% 0.60% 0.1645
leverage style -0.0500 -0.01% -0.25% 0.02% 1.40% -0.40% 0.1826
liquidity style 0.0500 0.01% 0.12% 0.15% 3.23% 3.08% 0.0367
value style -0.0500 -0.00% -0.09% 0.01% 2.07% -0.12% 0.0451
non_linear_size style 0.0500 0.00% 0.05% 0.08% 1.67% 1.51% 0.0278
growth style -0.0106 0.00% 0.02% -0.00% 2.36% 0.19% -0.0507
profitability style 0.0500 -0.00% -0.00% 0.04% 1.54% 0.90% -0.0021
market market -0.0000 -0.00% -0.00% -0.00% 19.08% 11.46% 0.1146
beta style -0.0000 -0.00% -0.00% -0.00% 7.69% 2.95% 0.1020
size style 0.0000 -0.00% -0.00% 0.00% 6.22% 2.10% -0.1143
Capital Goods industry 0.0000 -0.00% -0.00% -0.00% 10.65% -7.32% -0.0592
Banks industry -0.0000 0.00% 0.00% 0.00% 8.62% -2.76% -0.0311
Financials industry 0.0000 0.00% 0.00% 0.00% 12.57% 5.61% 0.0158
Tech Hardware industry 0.0000 0.00% 0.00% 0.00% 10.61% 2.05% 0.0118
Software industry -0.0000 -0.00% -0.00% 0.00% 10.21% -3.83% 0.0209
Real Estate industry 0.0000 0.00% 0.00% -0.00% 13.97% -4.06% 0.0051
Pharma & Biotech industry 0.0000 0.00% 0.00% -0.00% 22.09% -10.15% 0.0005
Health Care industry -0.0000 0.00% 0.00% -0.00% 16.00% 2.18% -0.0023
Energy industry 0.0000 0.00% 0.00% 0.00% 10.79% 5.02% 0.0041
volatility style 0.0000 0.00% 0.00% -0.00% 3.40% -0.25% 0.0014
Commercial industry 0.0000 0.00% 0.00% 0.00% 14.02% 4.73% 0.0143


Finally, the asset breakdown reports per-asset weights and volatility and return contributions, split into total, systematic and idiosyncratic parts. We show only the first rows:

predicted_attrib.assets_df().head()
Volatility Contribution Systematic Vol Contribution Idiosyncratic Vol Contribution % of Total Variance Expected Return Contribution Systematic Expected Return Contribution Idiosyncratic Expected Return Contribution Standalone Volatility Standalone Expected Return Weight Correlation with Portfolio
Asset
A00060 0.48% 0.42% 0.06% 9.36% 0.95% 0.95% 0.00% 29.28% 18.96% 0.050000 0.3275
A00357 0.39% 0.36% 0.03% 7.59% 0.52% 0.52% 0.00% 37.37% 10.50% 0.050000 0.2082
A00259 0.32% 0.29% 0.02% 6.16% 0.71% 0.71% 0.00% 23.90% 14.63% 0.048243 0.2740
A00105 0.23% 0.18% 0.04% 4.44% 0.39% 0.39% 0.00% 38.56% 15.73% 0.024725 0.2386
A00382 0.19% 0.17% 0.02% 3.77% 0.67% 0.67% 0.00% 27.95% 14.92% 0.045053 0.1534


Walk-Forward Backtest#

Now let’s backtest the strategy. online_predict walks forward through the data, updates the model with partial_fit and builds one portfolio per test window, in a single pass over the data. We rebalance monthly and reserve two years plus one month of warmup for the descriptors and estimators warmups (see Warmup Periods).

We also add:

  • transaction_costs=0.001 / month: skfolio deducts transaction costs directly from expected returns, which are expressed per observation period (here daily). The 10 basis points are paid once per rebalancing while a position earns its return on every day it is held, so we amortize the cost over the one-month holding period to convert it to a daily cost (see Periodicity Convention).

  • fallback="previous_weights" keeps the latest valid allocation when a rebalancing problem is infeasible (see Failure and Fallbacks).

  • entry_rebalancing_params overrides estimator parameters only for the first portfolio, which starts from cash. Setting transaction_costs=0.0 at entry avoids charging costs on the full initial ramp-up and lets the first rebalance reach its target allocation instead of building exposure over several rebalancings.

Borrow costs and market impact can be added through the optimizer’s add_objective and add_constraints parameters, with native support planned for a future release:

from skfolio.model_selection import online_predict

warmup = 2 * year + month
mvo.set_params(transaction_costs=0.001 / month, fallback="previous_weights")

mpp = online_predict(
    estimator=mvo,
    X=X,
    warmup_size=warmup,
    test_size=month,
    params={"characteristics": panel},
    entry_rebalancing_params={"transaction_costs": 0.0, "fallback": None},
)

print(f"Fallback portfolios: {mpp.n_fallback_portfolios}")
print(mpp.summary())
Fallback portfolios: 0
Mean                                     0.024%
Annualized Mean                           6.16%
Variance                               0.000006
Annualized Variance                       0.15%
Semi-Variance                          0.000003
Annualized Semi-Variance                 0.079%
Standard Deviation                        0.25%
Annualized Standard Deviation             3.93%
Semi-Deviation                            0.18%
Annualized Semi-Deviation                 2.81%
Mean Absolute Deviation                   0.20%
CVaR at 95%                               0.51%
EVaR at 95%                               0.64%
Worst Realization                         0.95%
CDaR at 95%                               3.30%
MAX Drawdown                              4.41%
Average Drawdown                          0.91%
EDaR at 95%                               3.61%
First Lower Partial Moment               0.098%
Ulcer Index                               0.013
Gini Mean Difference                      0.28%
Value at Risk at 95%                      0.38%
Drawdown at Risk at 95%                   2.80%
Entropic Risk Measure at 95%               3.00
Fourth Central Moment                 0.000000%
Fourth Lower Partial Moment               0.00%
Skew                                     -9.12%
Kurtosis                                338.38%
Sharpe Ratio                              0.099
Annualized Sharpe Ratio                    1.57
Sortino Ratio                              0.14
Annualized Sortino Ratio                   2.19
Mean Absolute Deviation Ratio              0.13
First Lower Partial Moment Ratio           0.25
Value at Risk Ratio at 95%                0.064
CVaR Ratio at 95%                         0.048
Entropic Risk Measure Ratio at 95%     0.000082
EVaR Ratio at 95%                         0.038
Worst Realization Ratio                   0.026
Drawdown at Risk Ratio at 95%            0.0087
CDaR Ratio at 95%                        0.0074
Calmar Ratio                             0.0055
Average Drawdown Ratio                    0.027
EDaR Ratio at 95%                        0.0068
Ulcer Index Ratio                         0.019
Gini Mean Difference Ratio                0.088
Avg nb of Assets per Portfolio            500.0
Number of Portfolios                         46
Number of Failed Portfolios                   0
Number of Fallback Portfolios                 0
dtype: str

online_predict returns a MultiPeriodPortfolio with one portfolio per rebalancing. n_fallback_portfolios counts the rebalancings that fell back to the previous weights.

Let’s plot the out-of-sample performance:

mpp.plot_cumulative_returns()


Next, we check the long, short, net and gross exposures through time. Net exposure remains zero and gross exposure stays within the 200% cap:

mpp.plot_long_short_exposure()


Ex-Post Attribution#

Now that we have the backtest, let’s find out which factors drove the realized performance. realized_attribution decomposes the walk-forward portfolio, whose weights vary through time, using the realized factor returns, exposures and idiosyncratic returns. It also reports standard errors that separate genuine contributions from estimation noise:

realized_attrib = mpp.realized_attribution(factor_model=factor_model)

As in the ex-ante section, we start with the exposures. For each factor we plot the mean exposure over the backtest and its standard deviation through time:

realized_attrib.plot_exposure(top_n=15)


Dividend yield remains close to its 1.5 target, while investment stays negative but averages less short than its -1.0 rebalancing target as realized exposures drift between monthly rebalances. Momentum averages well above its 1.0 floor and has the widest variation.

Next, we look at where the realized risk came from:

realized_attrib.plot_vol_contrib(top_n=15)


Realized volatility is split between intended factor tilts and orthogonal risk. Idiosyncratic risk is the largest single contribution, while dividend yield and momentum are the largest systematic contributors.

Next, we decompose realized return into factor and idiosyncratic contributions:

fig = realized_attrib.plot_return_contrib(top_n=15)
show(fig)

The error bars show 95% confidence intervals on annualized mean return contributions. Momentum and dividend yield are the main positive factor contributors. Because no alpha estimator is attached, any realized idiosyncratic contribution is uncompensated risk.

Finally, we plot each factor’s realized return contribution against its volatility contribution. Marker sizes are proportional to the absolute portfolio exposure, and the idiosyncratic component displays as a fixed-size diamond:

realized_attrib.plot_return_vs_vol_contrib(top_n=15)


The same results are available as DataFrames. Compared with the ex-ante summary, the realized breakdown adds unattributed, the difference between observed portfolio returns and model-attributed returns. The portfolio returns are net of transaction costs while the factor decomposition explains gross returns, so the cost drag falls into this component, as would management fees, slippage and cash:

realized_attrib.summary_df()
Volatility Contribution % of Total Variance Mean Return Contribution (95% CI)
Component
Systematic 2.63% 66.81% 5.70% ± 2.04%
Idiosyncratic 1.22% 31.05% 0.78% ± 2.04%
Unattributed 0.08% 2.13% -0.36%
Total 3.93% 100.00% 6.12%


The per-factor breakdown reports each factor’s average realized exposure, standalone statistics and contributions:

realized_attrib.factors_df().head()
Family Exposure Mean Exposure Std Volatility Contribution % of Total Variance Mean Return Contribution (95% CI) Standalone Volatility Standalone Mean Return Correlation with Portfolio
Factor
dividend_yield style 1.4583 0.0614 1.14% 29.10% 1.65% ± 1.23% 1.40% 1.13% 0.5594
momentum style 1.4977 0.2902 1.11% 28.15% 4.23% ± 1.70% 1.42% 2.58% 0.5089
investment style -0.6425 0.1770 0.29% 7.42% 0.13% ± 0.68% 1.34% -0.60% -0.3202
earnings_yield style 0.1172 0.1241 0.06% 1.53% 0.06% ± 0.16% 1.59% 0.76% 0.2774
market market -0.0014 0.0336 -0.02% -0.58% -0.36% ± 0.03% 19.81% 10.49% 0.0112


Rolling Attribution#

Finally, let’s see how the exposures evolved through time. Rolling attribution repeats the realized attribution over rolling windows, by default 60 observations stepped by 21:

rolling_realized_attrib = mpp.rolling_realized_attribution(
    factor_model=factor_model,
    compute_asset_breakdowns=False,
)

rolling_realized_attrib.plot_exposure(top_n=15)


Dividend yield stays close to 1.5 throughout the backtest. Momentum varies more because 1.0 is a minimum exposure rather than an exact target, allowing the optimizer to increase it when this improves the forecast Sharpe ratio. Investment remains negative, while the other exposures stay close to zero. The shaded areas show one standard deviation of exposure within each rolling window.

Conclusion#

We optimized a dollar-neutral portfolio with explicit factor tilts, verified the targeted exposures ex ante, backtested it with monthly walk-forward rebalancing and attributed the realized risk and performance to the factors ex post.

The next tutorial attaches an alpha estimator to the factor model and builds a factor-neutral portfolio whose return comes from the orthogonal alpha component.

See also

The Portfolio Construction and Attribution sections of the Factor Models user guide cover the methodology in depth.

References#

Total running time of the script: (0 minutes 18.957 seconds)

Gallery generated by Sphinx-Gallery