Transaction costs and local-quadratic rebalancing

Transaction costs and local-quadratic rebalancing#

The mean-risk reduction of Mean-risk optimization and the efficient surface collapses portfolio choice to two coordinates \((\tilde\mu, \tilde\gamma)\). An \(\ell_1\) turnover penalty breaks that reduction: \(\|w - w_0\|_1\) is not a function of the reduced coordinates alone. When transaction costs keep the solution near the current portfolio \(w_0\), a second-order Taylor expansion of the coherent risk \(r\) recovers a quadratic program in buy/sell variables. This tutorial walks through that construction on the same Dow basket used in the mean-risk page.

Formal derivation: Portfolio Optimization with Transaction Costs.

This page is intentionally separate from Mean-risk optimization and the efficient surface: that tutorial is about the frictionless dimension reduction; here the turnover term breaks that reduction and the API is a different object (TransactionCostProblem).

import jax
jax.config.update("jax_enable_x64", True)
import jax.numpy as jnp
import numpy as np
import pandas as pd
from pathlib import Path

from normix import GeneralizedHyperbolic
from normix.finance import CVaR, MeanRiskProblem, TransactionCostProblem
from normix.utils.plotting import set_theme, COLORS

set_theme()
np.set_printoptions(precision=5, suppress=True)

Fit once, rebalance often#

basket = ["AAPL", "MSFT", "JPM", "XOM", "JNJ", "PG", "WMT", "CAT"]
data_path = Path("../../../data/sp500_returns.csv").resolve()
R = pd.read_csv(data_path, index_col="Date", parse_dates=True)[basket].dropna()
X = jnp.asarray(R.values, dtype=jnp.float64)

model = (GeneralizedHyperbolic.default_init(X)
         .fit(X, max_iter=100, tol=1e-4, e_step_backend="cpu").model
         .regularize_a_eq_b())
cvar = CVaR(0.05)
Y = model.joint.subordinator().rvs(15_000, seed=0)
print(f"mean log-likelihood {float(model.marginal_log_likelihood(X)):.4f}")
mean log-likelihood 24.1906

Take the minimum-CVaR frontier portfolio as the current holding \(w_0\) — a manager who already solved the frictionless problem and now faces a cost of trading.

prob = MeanRiskProblem(model, cvar)
mv_mu, mv_gamma = prob.min_variance_point()
mv_ret = float(prob.expected_return(mv_mu, mv_gamma))
targets = jnp.linspace(mv_ret, mv_ret + 4e-4, 25)
ga = float(mv_gamma)
span = 1.5 * max(float(np.asarray(model.gamma).max()
                      - np.asarray(model.gamma).min()), 4e-4)
frontier = prob.efficient_frontier(
    targets, Y, gamma_bounds=(ga - span, ga + span), n_iter=40)
k = int(np.argmin(np.asarray(frontier.risk)))
w0 = frontier.weights[k]
print(f"anchor return {float(frontier.expected_return[k]):.3e}  "
      f"CVaR {float(frontier.risk[k]):.4f}")
pd.Series(np.asarray(w0), index=basket).round(3)
anchor return 5.537e-04  CVaR 0.0194
AAPL    0.012
MSFT    0.040
JPM     0.058
XOM     0.106
JNJ     0.308
PG      0.237
WMT     0.231
CAT     0.007
dtype: float64

Local quadratic program#

TransactionCostProblem reuses the same CVaR object — only its weight-space gradient and Hessian at \(w_0\) enter the QP. The objective is

\[ \max_w \; w^\top m - c_1\, r(w) - c_2 \|w - w_0\|_1 \quad\text{s.t.}\quad w^\top e = 1,\; w \ge 0, \]

approximated by replacing \(r\) with its Taylor model at \(w_0\) and introducing buy/sell variables \(v = (v^+; v^-)\).

tc = TransactionCostProblem(model, cvar, c1=5.0, c2=5e-2)
A = -jnp.eye(len(basket), dtype=jnp.float64)   # long-only: -w ≤ 0
b = jnp.zeros(len(basket), dtype=jnp.float64)
result = tc.solve(w0, Y, A=A, b=b)

print(f"improved over hold?  {bool(result.improved)}")
print(f"turnover ‖Δw‖₁      {float(result.turnover):.4f}")
print(f"approx obj  hold → * {float(result.hold_objective):.6e} → "
      f"{float(result.approx_objective):.6e}")
print(f"exact  obj  hold → * {float(tc.true_objective_at(w0, w0, Y)):.6e} → "
      f"{float(tc.true_objective_at(result.weights, w0, Y)):.6e}")
improved over hold?  False
turnover ‖Δw‖₁      0.0000
approx obj  hold → * -9.623331e-02 → -9.623331e-02
exact  obj  hold → * -9.623331e-02 → -9.623331e-02

The matrices themselves are available for an external QP solver if needed:

qp = result.qp
print(f"m̃ shape {qp.m_tilde.shape},  H̃ shape {qp.H_tilde.shape}")
print(f"budget residual ẽᵀv = {float(qp.e_tilde @ result.v):.2e}")
m̃ shape (16,),  H̃ shape (16, 16)
budget residual ẽᵀv = 0.00e+00

How costs reshape the trade#

Sweeping \(c_2\) shows the continuum from frictionless rebalancing to a pure hold.

import matplotlib.pyplot as plt

c2_grid = np.geomspace(1e-3, 2e-1, 12)
rows = []
for c2 in c2_grid:
    r = TransactionCostProblem(model, cvar, c1=5.0, c2=float(c2)).solve(
        w0, Y, A=A, b=b)
    rows.append({
        "c2": c2,
        "turnover": float(r.turnover),
        "approx_gain": float(r.approx_objective - r.hold_objective),
        "exact_gain": float(
            tc.true_objective_at(r.weights, w0, Y)
            - tc.true_objective_at(w0, w0, Y)
        ) if bool(r.improved) else 0.0,
    })
sweep = pd.DataFrame(rows)

fig, ax = plt.subplots(figsize=(8.5, 4.8))
ax.plot(sweep["c2"], sweep["turnover"], "o-", color=COLORS["brick"], lw=2)
ax.set_xscale("log")
ax.set_xlabel(r"turnover penalty $c_2$")
ax.set_ylabel(r"turnover $\|w^\star - w_0\|_1$")
ax.set_title("Local QP: turnover vs transaction-cost weight")
plt.show()
sweep.round(5)
../../_images/430d5f582d37490313e58a63a7b3864c06afb3c5aae8935fa9a425c0fcce82c6.png
c2 turnover approx_gain exact_gain
0 0.00100 0.0 0.0 0.0
1 0.00162 0.0 0.0 0.0
2 0.00262 0.0 0.0 0.0
3 0.00424 0.0 0.0 0.0
4 0.00687 0.0 0.0 0.0
5 0.01112 0.0 0.0 0.0
6 0.01799 0.0 0.0 0.0
7 0.02913 0.0 0.0 0.0
8 0.04715 0.0 0.0 0.0
9 0.07632 0.0 0.0 0.0
10 0.12355 0.0 0.0 0.0
11 0.20000 0.0 0.0 0.0

At large \(c_2\) the solver correctly returns the hold (\(w^\star = w_0\)). At small \(c_2\) the quadratic model can recommend a large move — outside the regime where the Taylor expansion is trustworthy — so in practice one verifies that the exact Monte Carlo objective also improves, exactly as the theory note warns.

cmp = pd.DataFrame(
    {"w0": np.asarray(w0), "w*": np.asarray(result.weights)},
    index=basket,
)
cmp["Δ"] = cmp["w*"] - cmp["w0"]
cmp.round(3)
w0 w* Δ
AAPL 0.012 0.012 0.0
MSFT 0.040 0.040 0.0
JPM 0.058 0.058 0.0
XOM 0.106 0.106 0.0
JNJ 0.308 0.308 0.0
PG 0.237 0.237 0.0
WMT 0.231 0.231 0.0
CAT 0.007 0.007 0.0

Takeaways#

  • Transaction costs live in an optimization layer, not inside the risk measure: the same CVaR object feeds both the efficient surface and the local QP.

  • TransactionCostProblem.build_qp exposes the theory matrices \((\tilde m, \tilde H, \tilde e, \tilde A, \tilde b)\); .solve runs a SciPy SLSQP default without adding a QP dependency.

  • Always check improved (and ideally the exact objective): if the approximate gain is non-positive, hold \(w_0\).

See Portfolio Optimization with Transaction Costs for the QP derivation and Mean-risk optimization and the efficient surface for the frictionless surface this rebalancing starts from.