<a id="sphx-glr-auto-examples-factor-models-plot-factor-constrained-portfolio-py"></a>

<a id="factor-constrained-portfolio-and-attribution"></a>

# 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`](https://skfolio.org/generated/skfolio.prior.CharacteristicsFactorModel.html.md#skfolio.prior.CharacteristicsFactorModel) and the optimizer
[`MeanRisk`](https://skfolio.org/generated/skfolio.optimization.MeanRisk.html.md#skfolio.optimization.MeanRisk).
The methodology is covered in the [Portfolio Construction](https://skfolio.org/user_guide/factor_models.html.md#factor-model-portfolio-construction) and [Attribution](https://skfolio.org/user_guide/factor_models.html.md#factor-model-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

<a id="data"></a>

## Data

We reuse the synthetic characteristics panel from the [previous tutorial](https://skfolio.org/auto_examples/factor_models/plot_characteristics_factor_model.html.md#sphx-glr-auto-examples-factor-models-plot-characteristics-factor-model-py).
It covers 500 assets over 1,500 trading days and includes late listings,
delistings, holidays and missing characteristics:

```Python
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
)
```

<a id="model-definition"></a>

## 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](https://skfolio.org/auto_examples/factor_models/plot_characteristics_factor_model.html.md#sphx-glr-auto-examples-factor-models-plot-characteristics-factor-model-py)
for more details:

```Python
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,
)
```

<a id="factor-constrained-optimization"></a>

## 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 <sup>[1](#id5)</sup>. 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$:

```Python
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}")
```

```none
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:

```Python
factor_model = mvo.prior_estimator_.factor_model_
```

<a id="ex-ante-attribution"></a>

## 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 <sup>[2](#id6)</sup>. We obtain it from the predicted portfolio with
`predicted_attribution`:

```Python
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:

```Python
predicted_attrib.plot_exposure(top_n=15)
```

<style>html[data-theme="dark"] div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}@media (prefers-color-scheme: dark){html:not([data-theme="light"]) div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}}</style><script>if (!window.plotlySphinxGalleryResize) {window.plotlySphinxGalleryResize = true;window.addEventListener("load", function () {document.querySelectorAll(".plotly-graph-div").forEach(function (gd) { Plotly.Plots.resize(gd); });});}</script>[plotly figure stripped from llms output]
<br />
<br />

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:

```Python
predicted_attrib.plot_vol_contrib(top_n=15)
```

<style>html[data-theme="dark"] div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}@media (prefers-color-scheme: dark){html:not([data-theme="light"]) div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}}</style><script>if (!window.plotlySphinxGalleryResize) {window.plotlySphinxGalleryResize = true;window.addEventListener("load", function () {document.querySelectorAll(".plotly-graph-div").forEach(function (gd) { Plotly.Plots.resize(gd); });});}</script>[plotly figure stripped from llms output]
<br />
<br />

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:

```Python
predicted_attrib.plot_return_contrib(top_n=15)
```

<style>html[data-theme="dark"] div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}@media (prefers-color-scheme: dark){html:not([data-theme="light"]) div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}}</style><script>if (!window.plotlySphinxGalleryResize) {window.plotlySphinxGalleryResize = true;window.addEventListener("load", function () {document.querySelectorAll(".plotly-graph-div").forEach(function (gd) { Plotly.Plots.resize(gd); });});}</script>[plotly figure stripped from llms output]
<br />
<br />

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:

```Python
predicted_attrib.plot_return_vs_vol_contrib(top_n=15)
```

<style>html[data-theme="dark"] div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}@media (prefers-color-scheme: dark){html:not([data-theme="light"]) div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}}</style><script>if (!window.plotlySphinxGalleryResize) {window.plotlySphinxGalleryResize = true;window.addEventListener("load", function () {document.querySelectorAll(".plotly-graph-div").forEach(function (gd) { Plotly.Plots.resize(gd); });});}</script>[plotly figure stripped from llms output]
<br />
<br />

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:

```Python
predicted_attrib.summary_df()
```

[plotly figure stripped from llms output]
<br />
<br />

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

```Python
mpp.plot_long_short_exposure()
```

<style>html[data-theme="dark"] div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}@media (prefers-color-scheme: dark){html:not([data-theme="light"]) div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}}</style><script>if (!window.plotlySphinxGalleryResize) {window.plotlySphinxGalleryResize = true;window.addEventListener("load", function () {document.querySelectorAll(".plotly-graph-div").forEach(function (gd) { Plotly.Plots.resize(gd); });});}</script>[plotly figure stripped from llms output]
<br />
<br />

<a id="ex-post-attribution"></a>

## 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:

```Python
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:

```Python
realized_attrib.plot_exposure(top_n=15)
```

<style>html[data-theme="dark"] div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}@media (prefers-color-scheme: dark){html:not([data-theme="light"]) div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}}</style><script>if (!window.plotlySphinxGalleryResize) {window.plotlySphinxGalleryResize = true;window.addEventListener("load", function () {document.querySelectorAll(".plotly-graph-div").forEach(function (gd) { Plotly.Plots.resize(gd); });});}</script>[plotly figure stripped from llms output]
<br />
<br />

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:

```Python
realized_attrib.plot_vol_contrib(top_n=15)
```

<style>html[data-theme="dark"] div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}@media (prefers-color-scheme: dark){html:not([data-theme="light"]) div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}}</style><script>if (!window.plotlySphinxGalleryResize) {window.plotlySphinxGalleryResize = true;window.addEventListener("load", function () {document.querySelectorAll(".plotly-graph-div").forEach(function (gd) { Plotly.Plots.resize(gd); });});}</script>[plotly figure stripped from llms output]
<br />
<br />

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:

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

[plotly figure stripped from llms output]<style>html[data-theme="dark"] div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}@media (prefers-color-scheme: dark){html:not([data-theme="light"]) div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}}</style><script>if (!window.plotlySphinxGalleryResize) {window.plotlySphinxGalleryResize = true;window.addEventListener("load", function () {document.querySelectorAll(".plotly-graph-div").forEach(function (gd) { Plotly.Plots.resize(gd); });});}</script>

<br/>

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:

```Python
realized_attrib.plot_return_vs_vol_contrib(top_n=15)
```

<style>html[data-theme="dark"] div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}@media (prefers-color-scheme: dark){html:not([data-theme="light"]) div.output_subarea:has(.plotly-graph-div){background:#fff;border-radius:0.25rem;padding:0.5rem}}</style><script>if (!window.plotlySphinxGalleryResize) {window.plotlySphinxGalleryResize = true;window.addEventListener("load", function () {document.querySelectorAll(".plotly-graph-div").forEach(function (gd) { Plotly.Plots.resize(gd); });});}</script>[plotly figure stripped from llms output]
<br />
<br />

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:

```Python
realized_attrib.summary_df()
```

[plotly figure stripped from llms output]
<br />
<br />

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.

<a id="conclusion"></a>

## 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](https://skfolio.org/user_guide/factor_models.html.md#factor-model-portfolio-construction) and [Attribution](https://skfolio.org/user_guide/factor_models.html.md#factor-model-attribution) sections of the [Factor Models](https://skfolio.org/user_guide/factor_models.html.md#factor-models) user guide cover the methodology in depth.

<a id="references"></a>

## References

* <a id='id5'>**[1]**</a> R. C. Grinold and R. N. Kahn, *Active Portfolio Management: A Quantitative Approach for Producing Superior Returns and Controlling Risk*, McGraw-Hill (1999).
* <a id='id6'>**[2]**</a> G. A. Paleologo, *The Elements of Quantitative Investing*, Wiley Finance (2025).
* <a id='id7'>**[3]**</a> D. P. Palomar, *Portfolio Optimization: Theory and Application*, Chapters 3 and 14, Cambridge University Press (2025). [doi:10.1017/9781009428095](https://doi.org/10.1017/9781009428095).
* <a id='id8'>**[4]**</a> D. Goldfarb and G. Iyengar, “Robust Portfolio Selection Problems”, *Mathematics of Operations Research*, vol. 28, no. 1, pp. 1-38 (2003). [doi:10.1287/moor.28.1.1.14260](https://doi.org/10.1287/moor.28.1.1.14260).

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

<a id="sphx-glr-download-auto-examples-factor-models-plot-factor-constrained-portfolio-py"></a>
