The source files can be found in examples/.

Turnover and tracking

The constraints so far shape what the portfolio holds. Turnover and tracking constrain how it moves: how far it may drift from your current book when you rebalance, and how far it may stray from a benchmark. Both come in two flavours in PortfolioOptimisers.jl — as a constraint on a JuMPOptimiser, or as a risk measure you minimise directly — and the choice between them is the difference between "respect this limit" and "make this the goal".

When to reach for this

Reach for turnover when trading is costly and you rebalance often — you want the new book close to the old one. Reach for tracking when you are benchmarked: index replication (minimise tracking error) or enhanced indexing (seek return subject to a tracking-error budget). Use the constraint form when the limit is a hard mandate, the risk-measure form when staying put / hugging the benchmark is itself the objective.

using PortfolioOptimisers, CSV, TimeSeries, DataFrames, PrettyTables, Clarabel, StatsPlots,      GraphRecipesresfmt = (v, i, j) -> begin    return if j == 1        v    else        isa(v, AbstractFloat) ? "$(round(v*100, digits=3)) %" : v    endend;

1. Data and a benchmark

We use the S&P 500 slice, and two benchmarks: an equal-weight book (a weight vector) and the S&P 500 index itself (a return series).

X = TimeArray(CSV.File(joinpath(@__DIR__, "..", "SP500.csv.gz")); timestamp = :Date)[(end - 252):end]rd = prices_to_returns(X)pr = prior(EmpiricalPrior(), rd)N = length(rd.nx)idx = TimeArray(CSV.File(joinpath(@__DIR__, "..", "SP500_idx.csv.gz")); timestamp = :Date)index_returns = vec(values(percentchange(idx)))[(end - 251):end]slv = Solver(; name = :clarabel, solver = Clarabel.Optimizer,             settings = Dict("verbose" => false),             check_sol = (; allow_local = true, allow_almost = true))rf = 4.2 / 100 / 252equal_weight = fill(1 / N, N)
20-element Vector{Float64}:
 0.05
 0.05
 0.05
 0.05
 0.05
 0.05
 0.05
 0.05
 0.05
 0.05
 0.05
 0.05
 0.05
 0.05
 0.05
 0.05
 0.05
 0.05
 0.05
 0.05

2. Turnover as a constraint

Turnover (tn) limits how far the weights may move from a reference book w — your current holdings — so a rebalance stays cheap. We seek the maximum-ratio portfolio but anchor it at the equal-weight book and tighten the per-name turnover budget val. A smaller budget keeps the result closer to where we started.

turnover_vals = [0.005, 0.02, 0.1, 0.5]turnover_res = [optimise(MeanRisk(; obj = MaximumRatio(; rf = rf),                                  opt = JuMPOptimiser(; pe = pr, slv = slv,                                                      tn = Turnover(; w = equal_weight,                                                                    val = v))))                for v in turnover_vals]drift(w) = sum(abs, w .- equal_weight)pretty_table(DataFrame("Turnover budget" => turnover_vals,                       "Drift from start" => drift.(getproperty.(turnover_res, :w)),                       "Max weight" => maximum.(getproperty.(turnover_res, :w)));             formatters = [resfmt],             title = "Tighter turnover budget keeps the book near the reference")
Tighter turnover budget keeps the book near the reference
┌─────────────────┬──────────────────┬────────────┐
│ Turnover budget  Drift from start  Max weight │
│         Float64           Float64     Float64 │
├─────────────────┼──────────────────┼────────────┤
│           0.005 │           10.0 % │      5.5 % │
│            0.02 │           40.0 % │      7.0 % │
│             0.1 │        125.917 % │     15.0 % │
│             0.5 │          170.0 % │     55.0 % │
└─────────────────┴──────────────────┴────────────┘

3. Turnover as a risk measure

TurnoverRiskMeasure makes minimising turnover the objective rather than a side constraint. Minimising turnover from the current book with no other pull simply returns the current book — useful as one term in a multi-objective problem, or to measure the trading cost of a target.

res_min_turnover = optimise(MeanRisk(; r = TurnoverRiskMeasure(; w = equal_weight),                                     obj = MinimumRisk(),                                     opt = JuMPOptimiser(; pe = pr, slv = slv)))
MeanRiskResult
  jr ┼ JuMPOptimisationResult
     │        pa ┼ ProcessedJuMPOptimiserAttributes
     │           │        pr ┼ LowOrderPrior
     │           │           │       X ┼ 252×20 Matrix{Float64}
     │           │           │     o_X ┼ nothing
     │           │           │      mu ┼ 20-element Vector{Float64}
     │           │           │   sigma ┼ 20×20 Matrix{Float64}
     │           │           │    chol ┼ nothing
     │           │           │       w ┼ nothing
     │           │           │     ens ┼ nothing
     │           │           │     kld ┼ nothing
     │           │           │      ow ┼ nothing
     │           │           │      rr ┼ nothing
     │           │           │     fpr ┼ nothing
     │           │           │       Z ┴ nothing
     │           │        wb ┼ WeightBounds
     │           │           │   lb ┼ 20-element StepRangeLen{Float64, Base.TwicePrecision{Float64}, Base.TwicePrecision{Float64}, Int64}
     │           │           │   ub ┴ 20-element StepRangeLen{Float64, Base.TwicePrecision{Float64}, Base.TwicePrecision{Float64}, Int64}
     │           │        lt ┼ nothing
     │           │        st ┼ nothing
     │           │      lcsr ┼ nothing
     │           │       ctr ┼ nothing
     │           │    gcardr ┼ nothing
     │           │   sgcardr ┼ nothing
     │           │      smtx ┼ nothing
     │           │     sgmtx ┼ nothing
     │           │       slt ┼ nothing
     │           │       sst ┼ nothing
     │           │      sglt ┼ nothing
     │           │      sgst ┼ nothing
     │           │        tn ┼ nothing
     │           │      fees ┼ nothing
     │           │       plr ┼ nothing
     │           │       ret ┼ ArithmeticReturn
     │           │           │   settings ┼ JuMPReturnsSettings
     │           │           │            │   scale ┼ Float64: 1.0
     │           │           │            │      lb ┼ nothing
     │           │           │            │     rte ┼ Bool: true
     │           │           │            │     fee ┼ Bool: true
     │           │           │            │     mic ┴ Bool: true
     │           │           │        ucs ┼ nothing
     │           │           │         mu ┴ 20-element Vector{Float64}
     │           │       sca ┴ SumScalariser()
     │   retcode ┼ OptimisationSuccess
     │           │   res ┴ Dict{Any, Any}: Dict{Any, Any}()
     │       sol ┼ JuMPOptimisationSolution
     │           │   w ┴ 20-element Vector{Float64}
     │     model ┼ A JuMP Model
     │           │ ├ solver: Clarabel
     │           │ ├ objective_sense: MIN_SENSE
     │           │ │ └ objective_function_type: AffExpr
     │           │ ├ num_variables: 21
     │           │ ├ num_constraints: 4
     │           │ │ ├ AffExpr in MOI.EqualTo{Float64}: 1
     │           │ │ ├ Vector{AffExpr} in MOI.Nonnegatives: 1
     │           │ │ ├ Vector{AffExpr} in MOI.Nonpositives: 1
     │           │ │ └ Vector{AffExpr} in MOI.NormOneCone: 1
     │           │ └ Names registered in the model
     │           │   └ :bgt, :cturnover_r_noc_1, :k, :lw, :obj_expr, :ret, :ret_1, :ret_vec, :risk, :risk_vec, :sc, :so, :turnover_r_1, :turnover_risk_1, :w, :w_lb, :w_ub
   r ┼ TurnoverRiskMeasure
     │   settings ┼ RiskMeasureSettings
     │            │   scale ┼ Float64: 1.0
     │            │      ub ┼ nothing
     │            │     rke ┴ Bool: true
     │          w ┼ 20-element Vector{Float64}
     │      fixed ┴ Bool: false
  fb ┴ nothing

4. Tracking a benchmark

TrackingRiskMeasure minimises the tracking error to a benchmark. With a WeightsTracking benchmark it reproduces that book exactly; with a ReturnsTracking benchmark — here the S&P 500 index return series — it builds the replicating portfolio from our 20 assets that best tracks the index.

res_replicate_ew = optimise(MeanRisk(;                                     r = TrackingRiskMeasure(;                                                             tr = WeightsTracking(;                                                                                  w = equal_weight)),                                     obj = MinimumRisk(),                                     opt = JuMPOptimiser(; pe = pr, slv = slv)))res_replicate_idx = optimise(MeanRisk(;                                      r = TrackingRiskMeasure(;                                                              tr = ReturnsTracking(;                                                                                   w = index_returns)),                                      obj = MinimumRisk(),                                      opt = JuMPOptimiser(; pe = pr, slv = slv)))pretty_table(DataFrame("Asset" => rd.nx, "Replicate EW" => res_replicate_ew.w,                       "Replicate index" => res_replicate_idx.w); formatters = [resfmt],             title = "Pure tracking: equal-weight book vs index replication")
Pure tracking: equal-weight book vs index replication
┌────────┬──────────────┬─────────────────┐
│  Asset  Replicate EW  Replicate index │
│ String       Float64          Float64 │
├────────┼──────────────┼─────────────────┤
│   AAPL │        5.0 % │        13.692 % │
│    AMD │        5.0 % │         6.689 % │
│    BAC │        5.0 % │         3.613 % │
│    BBY │        5.0 % │         1.767 % │
│    CVX │        5.0 % │         3.767 % │
│     GE │        5.0 % │         7.456 % │
│     HD │        5.0 % │         8.681 % │
│    JNJ │        5.0 % │         0.001 % │
│    JPM │        5.0 % │         7.861 % │
│     KO │        5.0 % │          3.52 % │
│    LLY │        5.0 % │         2.906 % │
│    MRK │        5.0 % │          3.55 % │
│   MSFT │        5.0 % │        18.432 % │
│    PEP │        5.0 % │         5.272 % │
│    PFE │        5.0 % │         2.654 % │
│     PG │        5.0 % │          0.75 % │
│    RRC │        5.0 % │         1.049 % │
│    UNH │        5.0 % │         4.056 % │
│    WMT │        5.0 % │         3.014 % │
│    XOM │        5.0 % │         1.271 % │
└────────┴──────────────┴─────────────────┘

5. Enhanced indexing: tracking as a constraint

The more interesting case is enhanced indexing — seek return, but stay within a tracking-error budget of the benchmark. TrackingError (tr) bounds the tracking error to err. We maximise the ratio while tightening err against the equal-weight benchmark: a small err hugs the benchmark, a large one frees the optimiser to chase return.

err_vals = [0.0005, 0.001, 0.005, 0.02]track_res = [optimise(MeanRisk(; obj = MaximumRatio(; rf = rf),                               opt = JuMPOptimiser(; pe = pr, slv = slv,                                                   tr = TrackingError(;                                                                      tr = WeightsTracking(;                                                                                           w = equal_weight),                                                                      err = e))))             for e in err_vals]pretty_table(DataFrame("Tracking-error budget" => err_vals,                       "Drift from benchmark" => drift.(getproperty.(track_res, :w)),                       "Max weight" => maximum.(getproperty.(track_res, :w)));             formatters = [resfmt],             title = "Tighter tracking-error budget hugs the benchmark")
      Tighter tracking-error budget hugs the benchmark
┌───────────────────────┬──────────────────────┬────────────┐
│ Tracking-error budget  Drift from benchmark  Max weight │
│               Float64               Float64     Float64 │
├───────────────────────┼──────────────────────┼────────────┤
│                0.0005 │              15.77 % │     7.81 % │
│                 0.001 │             31.247 % │   10.606 % │
│                 0.005 │            106.974 % │   26.905 % │
│                  0.02 │              180.0 % │    66.03 % │
└───────────────────────┴──────────────────────┴────────────┘

The tracking error itself can be measured with different norms — L1Norm (absolute, sparse), LpNorm (general p-norm), LInfNorm (worst single deviation) — passed as the alg, so you can choose whether to penalise the total drift or the largest single bet away from the benchmark.

6. Comparing the approaches

results = [res_replicate_ew, track_res[1], track_res[3], turnover_res[2]]labels = ["Replicate EW", "Track err 5e-4", "Track err 5e-3", "Turnover 0.02"]plot_stacked_bar_composition(results, rd; xticks = (1:length(labels), labels))
Example block output

This page was generated using Literate.jl.