Source code for fynance.signal.mappers

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

""" Signal mappers: turn a model prediction into a position.

Pure numpy and causal: the position at ``t`` depends only on the prediction at
``t`` (the engine in :mod:`fynance.backtest` applies the causal one-step shift).

The **anti-churn** mappers — :func:`ema_smooth`, :func:`deadband`,
:func:`min_hold` — are stateful (each output depends on the past output) but
strictly causal; compose them to cut turnover where transaction costs would
otherwise eat the edge (high fees / high frequency).

"""

from __future__ import annotations

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

# Local packages
from fynance.portfolio.sizing import vol_target

__all__ = ['sign', 'threshold', 'rank', 'vol_target_position',
           'ema_smooth', 'deadband', 'min_hold']


[docs] def sign(pred: NDArray) -> NDArray[np.float64]: """ Long/short by the sign of the prediction (``+1`` / ``-1`` / ``0``). Examples -------- >>> import numpy as np >>> sign(np.array([0.3, -0.1, 0.0])) array([ 1., -1., 0.]) """ return np.sign(np.asarray(pred, dtype=np.float64))
[docs] def threshold( pred: NDArray, long: float = 0.0, short: float = 0.0, ) -> NDArray[np.float64]: """ Position with a flat dead-band. ``+1`` where ``pred > long``, ``-1`` where ``pred < short``, ``0`` between. Examples -------- >>> import numpy as np >>> threshold(np.array([0.5, 0.05, -0.5]), long=0.1, short=-0.1) array([ 1., 0., -1.]) """ p = np.asarray(pred, dtype=np.float64) pos = np.zeros_like(p) pos[p > long] = 1.0 pos[p < short] = -1.0 return pos
[docs] def rank(pred: NDArray, top: int, bottom: int) -> NDArray[np.float64]: """ Cross-sectional long/short by rank. For each time step (row of a 2-D ``(T, n_assets)`` prediction), go long the ``top`` highest-ranked assets and short the ``bottom`` lowest, equally weighted within each leg (dollar-neutral). Parameters ---------- pred : array-like, shape (T, n_assets) Cross-sectional predictions. top, bottom : int Number of assets in the long and short legs. Returns ------- numpy.ndarray Weights of shape ``(T, n_assets)``. """ p = np.asarray(pred, dtype=np.float64) if p.ndim != 2: raise ValueError("rank expects a 2-D (T, n_assets) prediction") weights = np.zeros_like(p) order = np.argsort(p, axis=1) for t in range(p.shape[0]): if bottom > 0: weights[t, order[t, :bottom]] = -1.0 / bottom if top > 0: weights[t, order[t, -top:]] = 1.0 / top return weights
[docs] def vol_target_position( signal: NDArray, prices: NDArray, target_vol: float = 0.15, period: int = 252, w: int = 21, max_leverage: float = 5.0, ) -> NDArray[np.float64]: """ Scale a directional signal by causal volatility-targeting leverage. Multiplies a directional ``signal`` by the leverage from :func:`fynance.portfolio.sizing.vol_target` (inverse of *past* realized volatility). Strictly causal. """ sig = np.asarray(signal, dtype=np.float64) lev = vol_target( prices, target_vol=target_vol, period=period, w=w, max_leverage=max_leverage, ) return sig * lev
[docs] def ema_smooth(pred: NDArray, alpha: float = 0.2) -> NDArray[np.float64]: """ Causally smooth a position with an exponential moving average. ``out[t] = alpha * pred[t] + (1 - alpha) * out[t-1]`` — a low ``alpha`` reacts slowly, cutting turnover by damping minute-to-minute flips. Anti-churn. Parameters ---------- pred : array-like, shape (T,) Raw position / prediction series. alpha : float Smoothing factor in ``(0, 1]`` (``1`` = no smoothing). Examples -------- >>> import numpy as np >>> ema_smooth(np.array([0.0, 1.0, 1.0, -1.0]), alpha=0.5) array([ 0. , 0.5 , 0.75 , -0.125]) """ if not 0.0 < alpha <= 1.0: raise ValueError("alpha must be in (0, 1]") p = np.asarray(pred, dtype=np.float64).reshape(-1) out = np.empty_like(p) acc = 0.0 if p.size == 0 else p[0] for t in range(p.size): acc = alpha * p[t] + (1.0 - alpha) * acc out[t] = acc return out
[docs] def deadband(pred: NDArray, band: float = 0.1) -> NDArray[np.float64]: """ Hold the previous position unless the target moves by more than ``band``. A *sticky* dead-band: ``out[t] = pred[t]`` only when ``|pred[t] - out[t-1]| > band``, else ``out[t] = out[t-1]``. Magnitude is preserved (unlike :func:`threshold`), but small oscillations are ignored, so the position changes only on meaningful moves. Anti-churn. Parameters ---------- pred : array-like, shape (T,) Raw position / prediction series. band : float Minimum change required to update the held position (``>= 0``). Examples -------- >>> import numpy as np >>> deadband(np.array([0.0, 0.05, 0.5, 0.55, -0.4]), band=0.2) array([ 0. , 0. , 0.5, 0.5, -0.4]) """ if band < 0.0: raise ValueError("band must be >= 0") p = np.asarray(pred, dtype=np.float64).reshape(-1) out = np.empty_like(p) held = 0.0 if p.size == 0 else p[0] for t in range(p.size): if abs(p[t] - held) > band: held = p[t] out[t] = held return out
[docs] def min_hold(pos: NDArray, hold: int, tol: float = 1e-9) -> NDArray[np.float64]: """ Enforce a minimum holding period between position changes. Once the position changes, it is **frozen for ``hold`` bars** before another change can take effect — a hard cap on trade frequency. Strictly causal. Parameters ---------- pos : array-like, shape (T,) Position series (e.g. already mapped to a target). hold : int Minimum number of bars between two changes (``hold <= 1`` is a no-op). tol : float Changes smaller than ``tol`` are treated as no change. Examples -------- >>> import numpy as np >>> min_hold(np.array([1., -1., 1., -1., 1., -1.]), hold=3) array([ 1., 1., 1., -1., -1., -1.]) """ p = np.asarray(pos, dtype=np.float64).reshape(-1) if hold <= 1 or p.size == 0: return p.copy() out = np.empty_like(p) held = p[0] out[0] = held since = 0 for t in range(1, p.size): since += 1 if since >= hold and abs(p[t] - held) > tol: held = p[t] since = 0 out[t] = held return out