Source code for fynance.portfolio.rebalance

#!/usr/bin/env python3
# coding: utf-8

r""" Rebalancing policies, lot discretization and execution delay.

Composable, strictly causal ``(T, N)`` transforms that sit between an
allocator/signal (the *target* book) and
:func:`fynance.backtest.engine.backtest` (the *effective* book actually
held). A real portfolio does not snap to its target every bar: between
trades the held weights **drift** with asset returns, and trading is
throttled — on a calendar, inside a no-trade band, or under a turnover
budget. These overlays turn a frictionless target series into the book a
desk would actually carry, so a backtest charges turnover on the trades
that really happen rather than on the allocator's ideal (and far busier)
target.

All policies share one drift law: a position held from ``t`` to ``t + 1``
has its weight rescaled by its own gross return relative to the book's,

.. math::
    w_{t+1,i} = \frac{w_{t,i}\,(1 + r_{t+1,i})}
                     {1 + \sum_j w_{t,j}\, r_{t+1,j}},
    \quad r_{t+1,i} = X_{t+1,i} / X_{t,i} - 1,

i.e. the mark-to-market evolution of each position's share of the book
value. If the book return is ``<= -1`` (the book is wiped out) the drifted
weights are held at ``0`` from that bar on. Every transform is causal (row
``t`` uses only ``X`` and ``W`` up to row ``t``), promotes a 1-D input to a
single column, validates float64, and supports long-short books.

Main entry points
-----------------
- :func:`rebalance_calendar` — trade to target on a fixed bar schedule,
  drift between.
- :func:`rebalance_band` — trade only when the drift leaves a no-trade band
  around the target (to the target, or just to the band edge).
- :func:`rebalance_turnover_cap` — move toward the target each bar under a
  per-bar turnover budget.
- :func:`discretize` — round a target book to whole lots given a capital
  base, suppressing trades below a minimum notional.
- :func:`delay` — shift the whole book by a fixed number of bars
  (generalizes the engine's one-bar execution shift).

"""

from __future__ import annotations

# Third-party packages
import numpy as np
from numba import njit
from numpy.typing import NDArray

__all__ = [
    'delay',
    'discretize',
    'rebalance_band',
    'rebalance_calendar',
    'rebalance_turnover_cap',
]


# =========================================================================== #
#                                validation                                   #
# =========================================================================== #


def _as_TN(a: NDArray, name: str) -> NDArray[np.float64]:
    """ Coerce `a` to a float64 ``(T, N)`` array (1-D promoted to ``(T, 1)``). """
    arr = np.asarray(a, dtype=np.float64)
    if arr.ndim == 1:
        arr = arr.reshape(-1, 1)
    elif arr.ndim != 2:
        raise ValueError(f"{name} must be 1-D or 2-D, got ndim={arr.ndim}.")

    if not np.all(np.isfinite(arr)):
        raise ValueError(f"{name} contains non-finite values (NaN or inf).")

    return arr


def _validate_WX(W: NDArray, X: NDArray) -> tuple[NDArray[np.float64], NDArray[np.float64], bool]:
    """ Validate a ``(W, X)`` pair and report whether the input was 1-D.

    Both are coerced to float64 ``(T, N)`` (a 1-D input promoted to
    ``(T, 1)``) and must share the same shape. The returned boolean is
    ``True`` when the original `W` was 1-D, so callers can squeeze the
    output back to ``(T,)``.

    """
    squeeze = np.asarray(W).ndim == 1
    W2 = _as_TN(W, "W")
    X2 = _as_TN(X, "X")
    if W2.shape != X2.shape:
        raise ValueError(
            f"W and X must share the same shape (T, N) once 1-D inputs are "
            f"reshaped to (T, 1); got W.shape={W2.shape} and X.shape={X2.shape}."
        )

    return W2, X2, squeeze


def _returns(X: NDArray[np.float64]) -> NDArray[np.float64]:
    """ Simple per-asset returns ``r_t = X_t / X_{t-1} - 1`` with ``r_0 = 0``. """
    R = np.zeros_like(X)
    R[1:] = X[1:] / X[:-1] - 1.0

    return R


# =========================================================================== #
#                            drift kernel (shared)                           #
# =========================================================================== #


@njit(cache=True)
def _drift_step(w: NDArray[np.float64], r: NDArray[np.float64]) -> NDArray[np.float64]:
    r""" Mark-to-market drift of a held weight vector over one bar.

    Holding weights `w` and earning per-asset returns `r` over the bar, the
    new weights are each position's updated value as a share of the updated
    book value:

    .. math::
        w^+_i = w_i (1 + r_i) / (1 + \sum_j w_j r_j).

    If the book return :math:`\sum_j w_j r_j` is ``<= -1`` (book wiped out)
    the guard returns an all-zero vector, which stays zero under any further
    drift.
    """
    n = w.shape[0]
    book_ret = 0.0
    for i in range(n):
        book_ret += w[i] * r[i]

    out = np.zeros(n)
    denom = 1.0 + book_ret
    if denom <= 0.0:

        return out

    for i in range(n):
        out[i] = w[i] * (1.0 + r[i]) / denom

    return out


# =========================================================================== #
#                              calendar schedule                             #
# =========================================================================== #


@njit(cache=True)
def _calendar_kernel(
    W: NDArray[np.float64], R: NDArray[np.float64], every: int,
) -> NDArray[np.float64]:
    """ Scan: reset to ``W[t]`` when ``t % every == 0``, drift otherwise. """
    T, N = W.shape
    E = np.empty((T, N))
    for i in range(N):
        E[0, i] = W[0, i]

    for t in range(1, T):
        drift = _drift_step(E[t - 1], R[t])
        if t % every == 0:
            for i in range(N):
                E[t, i] = W[t, i]

        else:
            for i in range(N):
                E[t, i] = drift[i]

    return E


[docs] def rebalance_calendar(W: NDArray, X: NDArray, every: int = 21) -> NDArray[np.float64]: r""" Rebalance to target weights on a fixed calendar, drift between. The effective book is reset to the target ``W[t]`` at every bar ``t`` with ``t % every == 0`` (bar 0 is always a rebalance bar) and left to drift with asset returns on all other bars. ``every = 1`` reproduces the raw target series (rebalance every bar); larger periods trade less and let the book wander further from target between trades. Parameters ---------- W : array_like Target weights, shape ``(T, N)`` (a 1-D input is promoted to ``(T, 1)`` and the output squeezed back to ``(T,)``). Long-short books are supported. X : array_like Price/level panel aligned with `W`, same shape. Used only to drift held weights between rebalances (see :func:`_drift_step`). every : int, optional Rebalancing period in bars; must be ``>= 1``. Default 21. Returns ------- np.ndarray Effective weights actually held, same shape as `W`. Raises ------ ValueError If `W` and `X` do not share the same shape, contain non-finite values, or ``every < 1``. See Also -------- rebalance_band rebalance_turnover_cap Examples -------- >>> import numpy as np >>> W = np.array([[0.5, 0.5], [0.5, 0.5], [0.5, 0.5]]) >>> X = np.array([[100.0, 100.0], [110.0, 90.0], [121.0, 81.0]]) >>> np.round(rebalance_calendar(W, X, every=2), 3) array([[0.5 , 0.5 ], [0.55, 0.45], [0.5 , 0.5 ]]) """ if every < 1: raise ValueError(f"every must be >= 1, got {every}.") W2, X2, squeeze = _validate_WX(W, X) E = _calendar_kernel(W2, _returns(X2), int(every)) return E.ravel() if squeeze else E
# =========================================================================== # # no-trade band # # =========================================================================== # @njit(cache=True) def _band_kernel( W: NDArray[np.float64], R: NDArray[np.float64], band: float, edge: bool, ) -> NDArray[np.float64]: """ Scan: trade only when the drift's max deviation from target exceeds `band`. """ T, N = W.shape E = np.empty((T, N)) for i in range(N): E[0, i] = W[0, i] for t in range(1, T): drift = _drift_step(E[t - 1], R[t]) max_dev = 0.0 for i in range(N): dev = abs(drift[i] - W[t, i]) if dev > max_dev: max_dev = dev if max_dev > band: if edge: for i in range(N): lo = W[t, i] - band hi = W[t, i] + band v = drift[i] if v < lo: v = lo elif v > hi: v = hi E[t, i] = v else: for i in range(N): E[t, i] = W[t, i] else: for i in range(N): E[t, i] = drift[i] return E
[docs] def rebalance_band( W: NDArray, X: NDArray, band: float = 0.05, mode: str = 'full', ) -> NDArray[np.float64]: r""" Rebalance only when the drift leaves a no-trade band around the target. Each bar the held book is drifted with asset returns and compared to the current target; a trade is triggered only when the largest per-asset deviation exceeds `band`, :math:`\max_i |w^{\text{drift}}_{t,i} - W_{t,i}| > band`. When it does: - ``mode='full'`` trades all the way back to the target ``W[t]``; - ``mode='edge'`` trades each asset only to the near band edge, i.e. clips the drifted weight to ``[W[t] - band, W[t] + band]`` — the breaching asset lands exactly on the boundary and the book stays inside the band while trading as little as possible. Bar 0 always establishes the full target book; the band governs bars ``>= 1``. Parameters ---------- W : array_like Target weights, shape ``(T, N)`` (1-D promoted to ``(T, 1)``, output squeezed back). Long-short books are supported. X : array_like Price/level panel aligned with `W`, same shape. band : float, optional No-trade half-width around each target weight; must be ``>= 0``. Default 0.05. mode : {'full', 'edge'}, optional Whether a triggered trade goes to the target (``'full'``, default) or only to the band edge (``'edge'``). Returns ------- np.ndarray Effective weights actually held, same shape as `W`. Raises ------ ValueError If `W` and `X` do not share the same shape, contain non-finite values, ``band < 0``, or `mode` is not ``'full'`` / ``'edge'``. See Also -------- rebalance_calendar rebalance_turnover_cap Examples -------- A 20% one-day divergence breaks a 5% band; ``'full'`` snaps back to target while ``'edge'`` stops on the band boundary: >>> import numpy as np >>> W = np.array([[0.5, 0.5], [0.5, 0.5]]) >>> X = np.array([[100.0, 100.0], [120.0, 80.0]]) >>> rebalance_band(W, X, band=0.05, mode='full') array([[0.5, 0.5], [0.5, 0.5]]) >>> rebalance_band(W, X, band=0.05, mode='edge') array([[0.5 , 0.5 ], [0.55, 0.45]]) """ if mode not in ('full', 'edge'): raise ValueError(f"Unknown mode {mode!r}; expected 'full' or 'edge'.") if band < 0: raise ValueError(f"band must be >= 0, got {band}.") W2, X2, squeeze = _validate_WX(W, X) E = _band_kernel(W2, _returns(X2), float(band), mode == 'edge') return E.ravel() if squeeze else E
# =========================================================================== # # turnover budget # # =========================================================================== # @njit(cache=True) def _turnover_cap_kernel( W: NDArray[np.float64], R: NDArray[np.float64], budget: float, ) -> NDArray[np.float64]: """ Scan: move toward the target each bar under ``sum_i |dw_i| <= budget``. """ T, N = W.shape E = np.empty((T, N)) prev = np.zeros(N) for t in range(T): if t == 0: drift = np.zeros(N) else: drift = _drift_step(prev, R[t]) desired = 0.0 for i in range(N): desired += abs(W[t, i] - drift[i]) if desired <= budget or desired == 0.0: for i in range(N): E[t, i] = W[t, i] else: scale = budget / desired for i in range(N): E[t, i] = drift[i] + scale * (W[t, i] - drift[i]) for i in range(N): prev[i] = E[t, i] return E
[docs] def rebalance_turnover_cap( W: NDArray, X: NDArray, budget: float = 0.10, ) -> NDArray[np.float64]: r""" Move toward the target each bar under a per-bar turnover budget. Each bar the held book is drifted with asset returns, then traded toward the current target ``W[t]`` — but the trade :math:`\Delta w = E_t - w^{\text{drift}}_t` is capped so its one-way turnover :math:`\sum_i |\Delta w_i|` never exceeds `budget`. When the desired move is larger than the budget the whole trade vector is scaled down by ``budget / desired`` (its direction preserved, only its size shrunk), so the book eases toward a persistent target over several bars instead of snapping in one. The book starts flat, so the initial entry is throttled by the same budget. Parameters ---------- W : array_like Target weights, shape ``(T, N)`` (1-D promoted to ``(T, 1)``, output squeezed back). Long-short books are supported. X : array_like Price/level panel aligned with `W`, same shape. budget : float, optional Maximum one-way turnover ``sum_i |dw_i|`` allowed per bar; must be ``>= 0``. Default 0.10. Returns ------- np.ndarray Effective weights actually held, same shape as `W`. Raises ------ ValueError If `W` and `X` do not share the same shape, contain non-finite values, or ``budget < 0``. See Also -------- rebalance_calendar rebalance_band Examples -------- A 50% budget lets an all-in-asset-0 to all-in-asset-1 target ease over several bars; the first bar can move at most 0.5 of turnover from flat: >>> import numpy as np >>> W = np.array([[1.0, 0.0], [0.0, 1.0]]) >>> X = np.array([[100.0, 100.0], [100.0, 100.0]]) >>> E = rebalance_turnover_cap(W, X, budget=0.5) >>> np.round(E[0], 3) array([0.5, 0. ]) """ if budget < 0: raise ValueError(f"budget must be >= 0, got {budget}.") W2, X2, squeeze = _validate_WX(W, X) E = _turnover_cap_kernel(W2, _returns(X2), float(budget)) return E.ravel() if squeeze else E
# =========================================================================== # # lot discretization # # =========================================================================== # @njit(cache=True) def _discretize_kernel( W: NDArray[np.float64], P: NDArray[np.float64], capital: float, lot: float, min_notional: float, ) -> NDArray[np.float64]: """ Scan: round each target notional to whole lots, suppress tiny trades. """ T, N = W.shape E = np.empty((T, N)) prev = np.zeros(N) for t in range(T): for i in range(N): price = P[t, i] target_shares = W[t, i] * capital / price rounded = np.round(target_shares / lot) * lot trade_notional = abs(rounded - prev[i]) * price if trade_notional < min_notional: shares = prev[i] else: shares = rounded E[t, i] = shares * price / capital prev[i] = shares return E
[docs] def discretize( W: NDArray, prices: NDArray, capital: float = 1e6, lot: float = 1.0, min_notional: float = 0.0, ) -> NDArray[np.float64]: r""" Round a target weight book to whole lots on a fixed capital base. Turns continuous target weights into the book an integer-lot execution would actually hold. At each bar the target notional ``W[t, i] * capital`` is converted to shares at ``prices[t, i]``, rounded to the nearest multiple of `lot`, and converted back to a weight ``shares * price / capital``. A rebalancing trade whose notional ``|shares_new - shares_prev| * price`` falls below `min_notional` is suppressed (the previous share count is kept), which removes the churn of tiny odd-lot adjustments. The scan carries the held share count across bars, so the output depends on trade *history*, not only on the current target. Parameters ---------- W : array_like Target weights, shape ``(T, N)`` (1-D promoted to ``(T, 1)``, output squeezed back). Long-short books are supported (negative weights round to negative share counts). prices : array_like Strictly positive price levels aligned with `W`, same shape. capital : float, optional Reference capital defining the notional base; must be ``> 0``. Default ``1e6``. lot : float, optional Tradeable lot size in shares (e.g. ``100`` for round lots); must be ``> 0``. Default 1.0. min_notional : float, optional Trades whose notional value is strictly below this threshold are skipped, keeping the previous position; must be ``>= 0``. Default 0.0 (never skip). Returns ------- np.ndarray Effective weights implied by the rounded share book, same shape as `W`. Raises ------ ValueError If `W` and `prices` do not share the same shape, contain non-finite values, or if ``capital <= 0``, ``lot <= 0`` or ``min_notional < 0``. Examples -------- With ``capital=1000`` and unit lots, a 50% target on a $300 asset buys ``round(500 / 300) = 2`` shares, i.e. an effective 60% weight; the $50 asset lands exactly on 50%: >>> import numpy as np >>> W = np.array([[0.5, 0.5]]) >>> prices = np.array([[300.0, 50.0]]) >>> discretize(W, prices, capital=1000.0, lot=1.0) array([[0.6, 0.5]]) """ if capital <= 0: raise ValueError(f"capital must be > 0, got {capital}.") if lot <= 0: raise ValueError(f"lot must be > 0, got {lot}.") if min_notional < 0: raise ValueError(f"min_notional must be >= 0, got {min_notional}.") W2, P2, squeeze = _validate_WX(W, prices) E = _discretize_kernel(W2, P2, float(capital), float(lot), float(min_notional)) return E.ravel() if squeeze else E
# =========================================================================== # # execution delay # # =========================================================================== #
[docs] def delay(W: NDArray, steps: int = 1) -> NDArray[np.float64]: r""" Shift a weight book forward by a fixed number of bars. Delays execution by `steps` bars: row ``t`` of the output is the target of row ``t - steps`` (the first `steps` rows are zero, i.e. flat). This generalizes the one-bar causal shift the backtest engine applies by default (``shift=True``) — use it to model a longer decision-to-fill lag, or to pre-shift a book fed to ``backtest(..., shift=False)``. Parameters ---------- W : array_like Weight book, shape ``(T, N)`` (1-D promoted to ``(T, 1)``, output squeezed back). Long-short books are supported. steps : int, optional Number of bars to delay; must be ``>= 0``. ``steps=0`` returns a copy unchanged; ``steps >= T`` returns an all-zero book. Default 1. Returns ------- np.ndarray The shifted book, same shape as `W`. Raises ------ ValueError If `W` is not 1-D or 2-D, contains non-finite values, or ``steps < 0``. Examples -------- >>> import numpy as np >>> W = np.array([[0.5, 0.5], [1.0, 0.0], [0.0, 1.0]]) >>> delay(W, steps=1) array([[0. , 0. ], [0.5, 0.5], [1. , 0. ]]) """ if steps < 0: raise ValueError(f"steps must be >= 0, got {steps}.") squeeze = np.asarray(W).ndim == 1 W2 = _as_TN(W, "W") E = np.zeros_like(W2) if steps < W2.shape[0]: E[steps:] = W2[:W2.shape[0] - steps] return E.ravel() if squeeze else E