Turnover and transaction costs¶
Author: Artur Sepp / First recorded: 2026-08-15
Implemented in OptimalPortfolios. Software citation: CITATION.cff. Executed holdings, turnover and costs use qis; see the qis citation.
Portfolio turnover measures the amount traded or proposed to trade. Transaction costs measure the resources consumed by those trades. OptimalPortfolios uses turnover limits or penalties during construction; qis simulates execution and deducts proportional costs from cash. The construction budget and realised cost are different quantities.
Overview¶
A useful audit reports the proposed target change, the executed trade and the resulting cost separately. A turnover constraint can alter a target without charging any cash. Conversely, a backtest can charge costs without imposing a turnover limit on the supplied targets.
This article uses full, two-sided turnover: purchases and sales both count, with no factor of one half. Its executed examples concern cash securities with floating-point total-return prices, an explicit implementation lag and no funding, management fees or additional carry. See the complete constraints contract for solver and group-policy details.
Inputs, notation, and assumptions¶
Convention |
This article |
|---|---|
Return basis |
No return series enters the optimisation examples; the backtest values held units at the supplied floating-point total-return prices, so NAV moves with simple price ratios |
Estimation grid |
None; both covariances are fixed synthetic inputs, not estimates |
Rebalancing grid |
Single-date solves for the optimisation examples; in the backtest, targets dated 2 and 3 January 2024 on a three-business-day price grid execute one observation later ( |
Covariance units |
Annualized fractional return-squared, as supplied; no solver rescales it, so tracking error is annual volatility. Turnover budgets, penalties and cash costs are per decision or price observation and never annualized |
Expected returns |
None; the budget example minimises variance ( |
Weight state |
|
Solver |
CVXPY with CLARABEL, the |
The notation follows the conventions page. The table below adds the symbols and inputs specific to turnover and costs.
Input or symbol |
Meaning |
|---|---|
\(w_i\), \(w_{0,i}\) |
Proposed and baseline weights as fractions of portfolio NAV. |
\(w^{\mathrm{bm}}\), \(d\) |
Benchmark weights and active weights \(d=w-w^{\mathrm{bm}}\). |
\(\tau\), |
Hard budget on the selected full L1 expression. |
\(a_i\), |
Per-asset multipliers used by the optimiser’s turnover expression. |
\(\kappa_{\mathrm{TE}}\), |
Weight of active variance in a utility objective. |
\(\kappa_{\mathrm{TO}}\), |
Weight of the full L1 turnover in a utility objective; not a cash cost. |
\(u_{i,t}\), \(P_{i,t}\) |
Executed units and their current price. |
\(c_{i,t}\), |
Backtest cost per unit of traded notional, in fractional units. |
\(V_t\) |
Portfolio NAV after trading and costs at observation \(t\). |
Provide aligned asset labels and finite, nonnegative cost inputs. Multipliers \(a_i\) and cash-cost rates \(c_{i,t}\) need not be the same. If multipliers are cost fractions, their weighted budget also has cost-fraction units; a limit calibrated for unit multipliers cannot be reused blindly.
Turnover here is measured per decision or price observation. Neither the L1 budget nor the cash charge is annualised. A rolling or resampled turnover statistic needs its own period label. Price returns and estimation conventions belong to the upstream rolling workflow, not to the cost-rate units.
Methodology¶
Target turnover in optimisation¶
The unweighted hard constraint is
A five-percentage-point sale and a five-point purchase use 0.10 of budget. Moving from
60/40 to 50/50 uses 0.20. Both legs count; net weight change would be zero.
With turnover_costs, the common CVXPY constraint uses
For a change from 60/40 to 55/45, unit multipliers give 0.10, while multipliers [2, 1]
give 0.15. This weighted amount changes constraint or objective units; it is not a cash
deduction. The constraint compiler
and total-turnover contract define the calculation.
A baseline is required. Without resolved weights_0, the common CVXPY compiler skips total
and group turnover constraints; it does not infer zero initial holdings. The first solve
can be constrained if current holdings are supplied through Constraints.weights_0
or the wrapper’s weights_0 argument. In the quadratic wrapper, a supplied argument takes
precedence over the stored constraint baseline.
Rolling solvers normally drift prior targets between decision dates. That construction baseline can differ from lagged, cost-bearing executed holdings; see decision-date drift versus executed holdings. A construction limit is therefore not a guarantee about subsequently realised turnover.
Hard limits and utility penalties¶
turnover_utility_weight controls a penalty only in an optimisation path that uses that
utility formulation. A configured penalty field alone does not switch every solver into it:
every Constraints carries turnover_utility_weight=0.40 and tre_utility_weight=1.0 by
default, and the forced-constraint solve of the budget example below reads neither. With
ConstraintEnforcementType.UTILITY_CONSTRAINTS, wrapper_maximise_alpha_over_tre
(tactical allocation) maximises
over the hard rows that remain, such as the budget and the weight bounds; with alphas=None
the first term is dropped. The utility branches of wrapper_max_return_target_vol and
wrapper_min_variance_target_return add the same turnover term to their own objectives, and
the soft-tracking-error path of wrapper_maximise_alpha_with_target_return keeps it only when
no hard turnover cap is set. Without weights_0 the penalty is skipped, like the hard budget.
Penalty strength, hard-budget size and the backtest cash-cost rate are separate inputs.
Two properties follow for a fixed \(\kappa_{\mathrm{TE}}\). First, raising \(\kappa_{\mathrm{TO}}\) cannot raise the penalised turnover of the optimum: adding the optimality inequalities of two penalty weights shows that the larger weight never has the larger turnover, and with no alpha the tracking error never falls. Second, the absolute value stops trading at a finite weight. With no alpha, unit multipliers, baseline weights strictly inside their bounds and no hard row other than the budget, the baseline is optimal exactly when
The vector \(2\kappa_{\mathrm{TE}}\Sigma d_0\) is the marginal cost of active variance at the baseline; the budget row lets a common shift centre it, and the L1 subgradient absorbs up to \(\kappa_{\mathrm{TO}}\) on each asset.
Group turnover applies group loadings inside the absolute-value expression. It does not also
apply the portfolio-level turnover_costs multipliers. In forced constraint mode, group and
total caps are additive; in the generic utility builder, a group-turnover object takes
precedence over the total-turnover penalty. Consult
group turnover and
utility group precedence before combining them.
Realised turnover and costs in the backtest¶
qis converts each implemented target to units at execution prices and holds those units between trades. For cash securities, its per-asset proportional cost is
For opening-trade costs, pre-entry units are zero, including when entry is on the first price observation. Rates are read on the actual execution date after applying the lag. The charge is deducted from cash after sizing the trade using pre-cost NAV. See the qis backtester.
With TurnoverComputationType.EXECUTED_NOTIONAL_NAV, qis reports two-sided executed turnover as
This is the default convention of a newly constructed qis.PortfolioData in qis 5.31.0, the
minimum version this package requires. The denominator is same-observation, post-cost NAV.
Entry of notional 100 with cost 0.10 therefore gives 100 / 99.90 = 1.001001, slightly more
than 100%.
For derivatives, turnover_unit_notional can represent full contract value including
multipliers and currency conversion; a return-price series alone need not supply that value.
This article’s cash-security example uses prices as unit notionals.
The qis turnover implementation
owns the reporting conventions.
The first turnover row is normally missing because units.diff() has no preceding holding.
It does not assume zero pre-entry units for that statistic. Opening costs can still be recorded
on that row. With delayed entry, a prior in-sample zero-unit row makes the later opening turnover
observable. Keep this distinction when reconciling turnover totals and charged costs.
Worked example¶
All four Python blocks of this page are excerpts of the canonical script
examples/docs/turnover_and_transaction_costs.py,
which runs them in order and asserts every number and property on this page against a
reference computed a different way:
python -m examples.docs.turnover_and_transaction_costs
A hard target budget with supplied starting holdings¶
This preserves the original constraint setup: current weights are 60/40 and the full L1
budget is 0.10 with unit multipliers.
import pandas as pd
import optimalportfolios as opt
current = pd.Series({"A": 0.60, "B": 0.40})
constraints = opt.Constraints(
is_long_only=True,
weights_0=current,
turnover_constraint=0.10,
turnover_costs=pd.Series({"A": 1.0, "B": 1.0}),
)
A fixed, synthetic annual covariance with variances 0.04 and 0.01 and zero covariance
gives a minimum-variance target. The stored starting holdings apply even though this is
the first solve.
covar = pd.DataFrame(
[[0.04, 0.0], [0.0, 0.01]], index=current.index, columns=current.index
)
optimal_weights, outcome = opt.wrapper_quadratic_optimisation(
pd_covar=covar, constraints=constraints,
portfolio_objective=opt.PortfolioObjective.MIN_VARIANCE,
)
if not (outcome.accepted and outcome.compliant and outcome.fallback_source is None):
raise RuntimeError(f"Unusable solve: {outcome.status}")
Asset |
Baseline weight |
Constrained target |
Absolute change |
|---|---|---|---|
A |
0.60 |
0.55 |
0.05 |
B |
0.40 |
0.45 |
0.05 |
Without the turnover budget, the minimum-variance allocation is 20/80. The budget restricts A to at least 55%, so the constrained solution stops at 55/45. Its full L1 change is 0.10. No cash cost is charged by this solve.
Trading turnover against tracking error with a penalty¶
A penalty puts a price on trading instead of a limit on it. A four-asset example isolates the
trade-off: equity, credit, government bonds and gold with annual volatilities of 16%, 8%, 5%
and 15%, a benchmark of 50/20/25/5, and current holdings of 40/25/20/15, a full L1 gap of 0.30.
The canonical script calls wrapper_maximise_alpha_over_tre with alphas=None and the holdings
as weights_0, under ConstraintEnforcementType.UTILITY_CONSTRAINTS with
tre_utility_weight=100.0 and turnover_utility_weight from 0 to 0.5 in steps of 0.01. It
computes each turnover and tracking error with NumPy, checks that compute_tre_turnover_stats
reports the same two numbers, and solves every weight again exactly by enumerating the
first-order conditions.
At weight 0 the solve holds the benchmark: no tracking error, for the whole 0.30 of turnover. Credit and government bonds, whose deviations cost least risk, stop trading first, at weights of about 0.014 and 0.039. The equity-to-gold trade continues: at 0.20 the solve trades 0.0855 of NAV and runs 1.02% tracking error. That trade stops at the threshold \(100\times0.003851=0.3851\), where the solve keeps the holdings and their 1.88% tracking error. Along the whole path turnover never rises and tracking error never falls.

Figure: the turnover and ex-ante tracking error of the four-asset example as the turnover
penalty weight grows, with the tracking-error weight fixed at 100. Drawn by the exhibit
function of the canonical script; the analytics gallery lists its
provenance.
Insight
An L1 turnover penalty has a finite no-trade point. With no alpha, the current
holdings are optimal once turnover_utility_weight reaches tre_utility_weight times the
spread of \(\Sigma d_0\), the marginal active risk of the current deviations: 0.3851 in the
example. Below that point the penalty keeps the deviations that cost least risk, here credit
and government bonds, and spends turnover on the equity-to-gold trade, the pair with the
largest spread.
Executed trades from the original backtest example¶
The following example is separate from both optimisations: it deliberately supplies targets
60/40 and 50/50 to qis. Their target change is 0.20 and they are not claimed to satisfy
the 0.10 constraint above. The original prices, targets, 10 bp rate and lag-one call
are preserved.
import pandas as pd
import qis
prices = pd.DataFrame(
{"A": [100.0, 102.0, 101.0], "B": [100.0, 99.0, 101.0]},
index=pd.date_range("2024-01-02", periods=3, freq="B"),
)
targets = pd.DataFrame(
{"A": [0.60, 0.50], "B": [0.40, 0.50]},
index=prices.index[:2],
)
portfolio = qis.backtest_model_portfolio(
prices=prices,
weights=targets,
rebalancing_costs=0.0010,
weight_implementation_lag=1,
ticker="Cost-aware backtest",
)
The 2 January target enters on 3 January at prices 102 and 99. It buys \(60/102\) units of A and \(40/99\) units of B, costing 0.06 and 0.04 respectively. The next target trades on 4 January after the existing units have earned the intervening price returns.
Trade date |
Traded notional |
Cash cost |
Post-cost NAV |
|---|---|---|---|
2024-01-03 |
100.000000 |
0.100000 |
99.900000 |
2024-01-04 |
18.603684 |
0.018604 |
100.101242 |
On the second trade, the pre-cost NAV is approximately 100.119846. Target units are sized
from that amount, then costs are deducted. The traded notional is not 0.20 times NAV:
price drift, implementation dates and the existing cost debit affect the actual trade.
Implementation in optimalportfolios¶
compute_tre_turnover_stats(covar, benchmark_weights, weights, weights_0, alphas=None)
summarises one target. It returns (te_vol, turnover, port_alpha, port_vol, benchmark_vol):
turnover is the full L1 change nansum(abs(weights - weights_0)), with no half factor, and
te_vol is the tracking error in the units of covar. The covariance is a NumPy array without
labels, so pass it in the order of the weight index. The weight differences align by label, and
a label missing from weights_0 becomes a NaN change that the turnover sum drops.
Cost inputs and timing¶
|
Interpretation |
|---|---|
Scalar |
One fractional rate for every instrument and trade date. |
Ticker-indexed Series |
A separate constant rate for each price column. |
Date-by-ticker DataFrame |
Time-varying rates, forward-filled onto the price grid and read at execution. |
A date-indexed Series is rejected as ambiguous. A cost DataFrame must contain every price column. In qis 5.31.0, dates before the first schedule row are costless, and missing aligned DataFrame values become zero. This is an explicit missing-cost policy, not an estimate of unavailable costs. Supply a complete schedule when zero is unintended.
Setting turnover_costs or a turnover utility weight on Constraints does not configure
rebalancing_costs. The examples invoke the two layers separately.
Explicit turnover and cost reporting¶
Use roll_period=None for observation-level values. The default reporting window is 260
observations and can give all-missing turnover on a short example.
executed_turnover = portfolio.get_turnover(
is_agg=True, roll_period=None,
turnover_computation_type=qis.TurnoverComputationType.EXECUTED_NOTIONAL_NAV,
)
target_turnover = portfolio.get_turnover(
is_agg=True, roll_period=None,
turnover_computation_type=qis.TurnoverComputationType.TARGET_WEIGHTS,
)
cash_costs = portfolio.realized_costs.sum(axis=1)
cost_fractions = portfolio.get_costs(is_agg=True, roll_period=None)
The reporting modes have different numerators or denominators:
Mode or output |
Meaning |
|---|---|
|
Absolute units traded at current unit notional, divided by same-date NAV. |
|
The same traded notional divided by current gross exposure. |
|
Absolute changes between input target rows, on decision dates; an allocation proxy. |
|
Per-asset charges in portfolio currency units. |
|
Charges divided by same-date NAV, then aggregated as requested. |
On 3 and 4 January, executed turnover is 1.001001 and 0.185849. The target proxy records 0.20 on 3 January, the second decision date, not its execution date. At a constant scalar rate, observed cost fractions equal that rate times observed executed NAV turnover wherever the latter is defined. This does not supply an opening turnover where the first row is missing.
For get_turnover, the deprecated boolean is_unit_based_traded_volume=True selects gross-exposure
turnover, not NAV turnover. Use the enum explicitly. This is separate from the similarly named
get_costs option, whose default normalises costs by NAV; False returns currency charges.
If freq is supplied, reporting first sums by that frequency, then applies roll_period.
The rolling count therefore refers to the resampled observations. Summing cost fractions is
not a compounded return penalty or necessarily the difference between independently simulated
gross and net NAV paths. See the
PortfolioData implementation.
Reproduction and verification context¶
The canonical script runs all four blocks. It checks the displayed allocation against a one-dimensional optimality argument, the penalty path against an exact solution of its first-order conditions and the closed-form no-trade threshold, and the trade table and reported turnover against an exact rational currency ledger. It also checks the baseline, cost-schedule, missing-rate, reporting-window and deprecated-selector statements of this page. The test suite runs it, and so does the offline examples lane of CI.
Interpretation and limitations¶
A target constraint does not certify an executed turnover limit. Check decision-date baselines, trade dates, costs and eligible instruments together.
Solver support, filtering, constraint rescaling and fallback policies vary by objective. Review the resolved constraint and
OptimizationOutcomeinstead of relying only on an apparent weight sum. See constraint filtering.Unpriced instruments cannot be traded normally, and missing prices inside a held asset’s history can distort NAV. Read incomplete histories; do not infer missing-data behavior from the complete-price examples.
NAV and gross-exposure normalisations differ with cash, leverage, shorts and cost debits. Zero denominators make the corresponding turnover undefined rather than zero.
A missing first turnover row is not evidence of a free opening trade. Reconcile actual currency charges before comparing aggregate measures.
This proportional model does not estimate market impact, order-size capacity, bid/ask dynamics or tax effects. The short synthetic examples demonstrate conventions, not expected strategy performance.
Pitfall
The default penalty weights do not suit an annualized covariance. Every
Constraints carries tre_utility_weight=1.0 and turnover_utility_weight=0.40. With those
defaults the four-asset example’s no-trade threshold is 0.003851, which 0.40 exceeds more than
100 times, so the utility solve returns the current holdings unchanged. Set both weights
explicitly, in the units of the objective.
See also¶
Rolling backtests for holdings drift and implementation timing
Incomplete histories for missing and frozen positions
Constraints for full L1, group policies and backend support
API reference for
Constraintsandapply_drift_to_weights_0Rendered article for Markdown viewers without mathematical rendering
References¶
OptimalPortfolios. Constraint backend compiler and constraints methodology: target budgets and utility penalties.
QuantInvestStrats. Portfolio backtester: executed units, proportional costs and execution-date schedules.
QuantInvestStrats. Turnover computations and PortfolioData reporting: explicit conventions, first-row handling and aggregation.