Skip to content

costs

PortPy assumes frictionless trading by default. turnover_from_weights/net_of_costs_returns layer a single blended cost rate back in; bid_ask_spreads, broker_commissions, fx_spread_costs, and tax_impact itemize that cost by source instead - see the Costs & taxes guide for how they compose.

costs

Optional transaction-cost modeling.

PortPy assumes frictionless trading by default. These helpers let you layer transaction costs back in from an explicit weight-history: an aggregate flat rate (net_of_costs_returns, given turnover from turnover_from_weights), or itemized by source (bid_ask_spreads, broker_commissions, fx_spread_costs, tax_impact) - the itemized functions all return a per-period return-drag Series in the same units, so they compose by plain addition: total_drag = bid_ask_spreads(...) + broker_commissions(...) + ..., then returns - total_drag.

Every function here is a flat, per-period approximation, not a trade-level execution or accounting engine - see each function's own caveats for exactly what it ignores.

turnover_from_weights

turnover_from_weights(
    weights_history: DataFrame,
) -> pd.Series

Per-period portfolio turnover: half the sum of absolute weight changes, i.e. the fraction of the book traded each period.

The first row is treated as building the initial portfolio from cash, so its turnover equals half the sum of the initial absolute weights.

Parameters:

Name Type Description Default
weights_history DataFrame

Portfolio weights per period, indexed by date with one column per asset.

required

Returns:

Type Description
Series

Per-period turnover as a Series named "turnover", aligned to

Series

weights_history.index.

Source code in src/portpy/metrics/costs.py
def turnover_from_weights(weights_history: pd.DataFrame) -> pd.Series:
    """
    Per-period portfolio turnover: half the sum of absolute weight changes,
    i.e. the fraction of the book traded each period.

    The first row is treated as building the initial portfolio from cash, so
    its turnover equals half the sum of the initial absolute weights.

    Args:
        weights_history: Portfolio weights per period, indexed by date with
            one column per asset.

    Returns:
        Per-period turnover as a Series named "turnover", aligned to
        `weights_history.index`.
    """
    ensure_min_observations(weights_history, 1, "weights_history")

    turnover = weights_history.diff().abs().sum(axis=1) / 2.0
    turnover.iloc[0] = float(weights_history.iloc[0].abs().sum()) / 2.0
    return turnover.rename("turnover")

net_of_costs_returns

net_of_costs_returns(
    returns: Series,
    turnover: Series | float,
    cost_bps: float,
) -> pd.Series

Deduct a transaction-cost drag from returns, given per-period turnover and a cost in basis points. Uses a flat, linear-in-turnover cost model.

Parameters:

Name Type Description Default
returns Series

Periodic gross returns.

required
turnover Series | float

Either a per-period turnover Series (see turnover_from_weights, aligned to returns.index) or a single constant turnover fraction applied to every period.

required
cost_bps float

Round-trip cost per unit of notional traded, in basis points (e.g. 10 = 0.10% per unit turnover).

required

Returns:

Type Description
Series

returns net of the estimated transaction-cost drag.

Source code in src/portpy/metrics/costs.py
def net_of_costs_returns(
    returns: pd.Series,
    turnover: pd.Series | float,
    cost_bps: float,
) -> pd.Series:
    """
    Deduct a transaction-cost drag from `returns`, given per-period turnover
    and a cost in basis points. Uses a flat, linear-in-turnover cost model.

    Args:
        returns: Periodic gross returns.
        turnover: Either a per-period turnover Series (see
            `turnover_from_weights`, aligned to `returns.index`) or a single
            constant turnover fraction applied to every period.
        cost_bps: Round-trip cost per unit of notional traded, in basis
            points (e.g. 10 = 0.10% per unit turnover).

    Returns:
        `returns` net of the estimated transaction-cost drag.
    """
    cost_rate = cost_bps / 10_000.0
    if isinstance(turnover, pd.Series):
        cost_drag = turnover.reindex(returns.index).fillna(0.0) * cost_rate
        return (returns - cost_drag).rename(returns.name)
    return returns - turnover * cost_rate

bid_ask_spreads

bid_ask_spreads(
    weights_history: DataFrame,
    spread_bps: float | dict[str, float] | Series,
) -> pd.Series

Per-period cost of crossing the bid-ask spread, from an explicit weight history.

A market order pays roughly half the quoted spread away from the mid price on each leg (buy or sell) - this charges that half-spread on every asset's traded notional each period, then sums across assets.

Parameters:

Name Type Description Default
weights_history DataFrame

Portfolio weights per period, indexed by date with one column per asset - same shape turnover_from_weights takes, but per-asset trades matter here (not just their net sum), since spreads can differ by asset.

required
spread_bps float | dict[str, float] | Series

Quoted round-trip bid-ask spread, in basis points, either one number applied to every asset or a {asset: bps} dict/Series for per-asset (or, via a dict comprehension over your own region/asset-class mapping, per-region) spreads.

required

Returns:

Type Description
Series

Per-period cost drag (a positive return-rate reduction) as a Series

Series

named "bid_ask_spread_cost", aligned to weights_history.index. The

Series

first row charges the full initial allocation, matching

Series

turnover_from_weights' "built from cash" convention.

Source code in src/portpy/metrics/costs.py
def bid_ask_spreads(
    weights_history: pd.DataFrame,
    spread_bps: float | dict[str, float] | pd.Series,
) -> pd.Series:
    """
    Per-period cost of crossing the bid-ask spread, from an explicit weight history.

    A market order pays roughly half the quoted spread away from the mid
    price on each leg (buy or sell) - this charges that half-spread on every
    asset's traded notional each period, then sums across assets.

    Args:
        weights_history: Portfolio weights per period, indexed by date with
            one column per asset - same shape `turnover_from_weights` takes,
            but per-asset trades matter here (not just their net sum), since
            spreads can differ by asset.
        spread_bps: Quoted round-trip bid-ask spread, in basis points, either
            one number applied to every asset or a `{asset: bps}` dict/Series
            for per-asset (or, via a dict comprehension over your own
            region/asset-class mapping, per-region) spreads.

    Returns:
        Per-period cost drag (a positive return-rate reduction) as a Series
        named "bid_ask_spread_cost", aligned to `weights_history.index`. The
        first row charges the full initial allocation, matching
        `turnover_from_weights`' "built from cash" convention.
    """
    trades = _per_asset_trade_fractions(weights_history)
    rate = _per_asset_rate(spread_bps, weights_history.columns) / 10_000.0
    cost = (trades * rate) / 2.0
    return cost.sum(axis=1).rename("bid_ask_spread_cost")

broker_commissions

broker_commissions(
    weights_history: DataFrame,
    commission_bps: float | dict[str, float] | Series,
) -> pd.Series

Per-period broker commission cost, proportional to traded notional.

Parameters:

Name Type Description Default
weights_history DataFrame

See bid_ask_spreads.

required
commission_bps float | dict[str, float] | Series

Commission rate in basis points of traded notional, either one number for every asset or a {asset: bps} dict/Series. PortPy works in weights/returns, not share counts or dollar NAV, so a fixed-currency-amount-per-trade commission schedule (e.g. "$1 per trade" or "$0.005/share") can't be expressed here directly - convert it to an equivalent bps-of-notional rate for your typical trade size first if that's your actual fee schedule.

required

Returns:

Type Description
Series

Per-period cost drag as a Series named "broker_commission_cost",

Series

aligned to weights_history.index.

Source code in src/portpy/metrics/costs.py
def broker_commissions(
    weights_history: pd.DataFrame,
    commission_bps: float | dict[str, float] | pd.Series,
) -> pd.Series:
    """
    Per-period broker commission cost, proportional to traded notional.

    Args:
        weights_history: See `bid_ask_spreads`.
        commission_bps: Commission rate in basis points of traded notional,
            either one number for every asset or a `{asset: bps}` dict/Series.
            PortPy works in weights/returns, not share counts or dollar NAV,
            so a fixed-currency-amount-per-trade commission schedule (e.g.
            "$1 per trade" or "$0.005/share") can't be expressed here directly -
            convert it to an equivalent bps-of-notional rate for your typical
            trade size first if that's your actual fee schedule.

    Returns:
        Per-period cost drag as a Series named "broker_commission_cost",
        aligned to `weights_history.index`.
    """
    trades = _per_asset_trade_fractions(weights_history)
    rate = _per_asset_rate(commission_bps, weights_history.columns) / 10_000.0
    cost = trades * rate
    return cost.sum(axis=1).rename("broker_commission_cost")

fx_spread_costs

fx_spread_costs(
    weights_history: DataFrame,
    asset_currencies: dict[str, str],
    base_currency: str,
    fx_spread_bps: float | dict[str, float] | Series,
) -> pd.Series

Per-period cost of crossing the FX bid-ask spread when trading assets denominated in a currency other than base_currency.

Every trade in a foreign-currency asset implicitly converts currency too (buying a EUR-denominated stock from a USD book means buying EUR first) - this charges the same half-spread convention as bid_ask_spreads, but only on assets whose currency differs from base_currency, using an FX spread rather than that asset's own equity/bond spread.

Parameters:

Name Type Description Default
weights_history DataFrame

See bid_ask_spreads.

required
asset_currencies dict[str, str]

Mapping of asset symbol -> currency code. Assets already in base_currency (or missing from this dict) incur no FX spread cost.

required
base_currency str

The portfolio's base currency code.

required
fx_spread_bps float | dict[str, float] | Series

Quoted FX bid-ask spread, in basis points, either one number for every non-base currency or a {currency: bps} dict/Series (not per-asset - see calculate_fx_spread to derive this from real bid/ask FX quotes, multiplying its fractional output by 10,000).

required

Returns:

Type Description
Series

Per-period cost drag as a Series named "fx_spread_cost", aligned to

Series

weights_history.index. All-zero if every asset is already in

Series

base_currency.

Source code in src/portpy/metrics/costs.py
def fx_spread_costs(
    weights_history: pd.DataFrame,
    asset_currencies: dict[str, str],
    base_currency: str,
    fx_spread_bps: float | dict[str, float] | pd.Series,
) -> pd.Series:
    """
    Per-period cost of crossing the FX bid-ask spread when trading assets
    denominated in a currency other than `base_currency`.

    Every trade in a foreign-currency asset implicitly converts currency too
    (buying a EUR-denominated stock from a USD book means buying EUR first) -
    this charges the same half-spread convention as `bid_ask_spreads`, but
    only on assets whose currency differs from `base_currency`, using an FX
    spread rather than that asset's own equity/bond spread.

    Args:
        weights_history: See `bid_ask_spreads`.
        asset_currencies: Mapping of asset symbol -> currency code. Assets
            already in `base_currency` (or missing from this dict) incur no
            FX spread cost.
        base_currency: The portfolio's base currency code.
        fx_spread_bps: Quoted FX bid-ask spread, in basis points, either one
            number for every non-base currency or a `{currency: bps}`
            dict/Series (not per-asset - see `calculate_fx_spread` to derive
            this from real bid/ask FX quotes, multiplying its fractional
            output by 10,000).

    Returns:
        Per-period cost drag as a Series named "fx_spread_cost", aligned to
        `weights_history.index`. All-zero if every asset is already in
        `base_currency`.
    """
    trades = _per_asset_trade_fractions(weights_history)
    foreign_assets = [a for a in weights_history.columns if asset_currencies.get(a, base_currency) != base_currency]
    if not foreign_assets:
        return pd.Series(0.0, index=weights_history.index, name="fx_spread_cost")

    currencies_needed = {asset_currencies[a] for a in foreign_assets}
    rate_by_currency = _per_asset_rate(fx_spread_bps, pd.Index(sorted(currencies_needed))) / 10_000.0

    cost = pd.Series(0.0, index=weights_history.index)
    for asset in foreign_assets:
        cost = cost + trades[asset] * rate_by_currency[asset_currencies[asset]] / 2.0
    return cost.rename("fx_spread_cost")

tax_impact

tax_impact(
    returns: Series,
    turnover: Series | float,
    capital_gains_tax_rate: float,
    dividend_tax_rate: float = 0.0,
    dividend_yield: float | Series = 0.0,
) -> pd.Series

Estimate a per-period tax drag on realized capital gains plus dividend income.

This is a first-order, turnover-proxy approximation, not a lot-level tax engine (see caveats) - it assumes the fraction of the book turned over each period approximates the fraction of that period's gain that gets realized (and therefore taxed) rather than deferred by staying invested, and taxes any assumed dividend yield separately (dividends are taxed whether or not you sell).

Parameters:

Name Type Description Default
returns Series

Periodic gross returns.

required
turnover Series | float

Either a per-period turnover Series (see turnover_from_weights) or a single constant turnover fraction - same convention as net_of_costs_returns.

required
capital_gains_tax_rate float

Tax rate applied to the realized-gain estimate in periods with a positive return (e.g. 0.15 for a 15% long-term US federal rate, 0.37 for short-term-as-ordinary-income, or a blended local+foreign effective rate you've computed yourself). Losses are never taxed here (no loss-harvesting credit either - see caveats).

required
dividend_tax_rate float

Tax rate applied to dividend_yield, independent of realized capital gains. Defaults to 0 (no dividend tax modeled).

0.0
dividend_yield float | Series

Assumed per-period dividend yield, as a return fraction - a single constant or a Series aligned to returns.index. Defaults to 0. This is a return you assume is already included in returns and are separately taxing, not an addition to it.

0.0

Returns:

Type Description
Series

returns net of the estimated tax drag.

This is a deliberately simplified, single-effective-rate approximation: it has no per-lot cost basis, no distinction between short-term and long-term holding periods beyond whatever single capital_gains_tax_rate you pass in, no loss carryforward or tax-loss harvesting, no wash-sale rule, and no jurisdiction-specific treatment of local vs. foreign withholding beyond a blended rate you supply yourself. Using turnover as a realization proxy is a common simplifying assumption in tax-drag literature, not an actual accounting rule - a buy-and-hold book with zero turnover pays zero estimated capital-gains tax here even though the portfolio's value has genuinely appreciated (correctly reflecting that unrealized gains aren't taxed yet, but only if you also track cost basis yourself when gains are eventually realized outside this function).

Source code in src/portpy/metrics/costs.py
def tax_impact(
    returns: pd.Series,
    turnover: pd.Series | float,
    capital_gains_tax_rate: float,
    dividend_tax_rate: float = 0.0,
    dividend_yield: float | pd.Series = 0.0,
) -> pd.Series:
    """
    Estimate a per-period tax drag on realized capital gains plus dividend income.

    This is a first-order, turnover-proxy approximation, not a lot-level tax
    engine (see caveats) - it assumes the *fraction of the book turned over*
    each period approximates the fraction of that period's gain that gets
    realized (and therefore taxed) rather than deferred by staying invested,
    and taxes any assumed dividend yield separately (dividends are taxed
    whether or not you sell).

    Args:
        returns: Periodic gross returns.
        turnover: Either a per-period turnover Series (see
            `turnover_from_weights`) or a single constant turnover fraction -
            same convention as `net_of_costs_returns`.
        capital_gains_tax_rate: Tax rate applied to the realized-gain estimate
            in periods with a positive return (e.g. 0.15 for a 15% long-term
            US federal rate, 0.37 for short-term-as-ordinary-income, or a
            blended local+foreign effective rate you've computed yourself).
            Losses are never taxed here (no loss-harvesting credit either -
            see caveats).
        dividend_tax_rate: Tax rate applied to `dividend_yield`, independent
            of realized capital gains. Defaults to 0 (no dividend tax modeled).
        dividend_yield: Assumed per-period dividend yield, as a return
            fraction - a single constant or a Series aligned to `returns.index`.
            Defaults to 0. This is a return you assume is *already included*
            in `returns` and are separately taxing, not an addition to it.

    Returns:
        `returns` net of the estimated tax drag.

    This is a deliberately simplified, single-effective-rate approximation:
    it has no per-lot cost basis, no distinction between short-term and
    long-term holding periods beyond whatever single `capital_gains_tax_rate`
    you pass in, no loss carryforward or tax-loss harvesting, no wash-sale
    rule, and no jurisdiction-specific treatment of local vs. foreign
    withholding beyond a blended rate you supply yourself. Using turnover as
    a realization proxy is a common simplifying assumption in tax-drag
    literature, not an actual accounting rule - a buy-and-hold book with zero
    turnover pays zero estimated capital-gains tax here even though the
    portfolio's value has genuinely appreciated (correctly reflecting that
    unrealized gains aren't taxed yet, but only if you also track cost basis
    yourself when gains are eventually realized outside this function).
    """
    turnover_series = turnover if isinstance(turnover, pd.Series) else pd.Series(turnover, index=returns.index)
    turnover_series = turnover_series.reindex(returns.index).fillna(0.0).clip(upper=1.0)

    realized_gain = returns.clip(lower=0.0) * turnover_series
    capital_gains_drag = realized_gain * capital_gains_tax_rate

    dividend_series = dividend_yield if isinstance(dividend_yield, pd.Series) else pd.Series(dividend_yield, index=returns.index)
    dividend_series = dividend_series.reindex(returns.index).fillna(0.0)
    dividend_drag = dividend_series * dividend_tax_rate

    return (returns - capital_gains_drag - dividend_drag).rename(returns.name)