Source code for fynance.core.price_series

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

""" Central financial time-series value object.

Defines :class:`PriceSeries`, a thin, numpy-backed container for a 1-D
financial series with its temporal index and metadata. It is the spine of
the :mod:`fynance` 2.x pipeline (data -> features -> signal -> portfolio ->
backtest -> metrics).

Design invariants
-----------------
- **Composition, never subclassing** of :class:`numpy.ndarray`: the values
  are stored as a contiguous, read-only ``float64`` array; transformations
  return *new* :class:`PriceSeries` objects.
- **numpy is the lingua franca**: pytorch is never stored, only produced on
  demand through :meth:`PriceSeries.to_torch`.
- **Thin object**: only the fundamental price/return identities live here;
  the rich transform library stays as free functions reachable through
  :meth:`PriceSeries.pipe`.

"""

from __future__ import annotations

# Built-in packages
from typing import Any, Callable

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

__all__ = ['PriceSeries']


[docs] class PriceSeries: """ Thin, numpy-backed financial time-series. Wraps a 1-D array of values together with a temporal ``index`` and light metadata (``name``, ``freq``). It composes a numpy array rather than subclassing it, which keeps numpy interop (``np.asarray(ps)`` works) without the fragility of ndarray subclassing. Parameters ---------- values : array-like 1-D sequence of values (coerced to ``float64``). index : array-like, optional Temporal index of the same length. Defaults to ``0..n-1`` (int64). name : str, optional Series name. freq : str, optional Sampling frequency tag (e.g. ``"1d"``), purely informational. Attributes ---------- values : numpy.ndarray Read-only ``float64`` 1-D array of values. index : numpy.ndarray 1-D array aligned with ``values``. name : str or None Series name. freq : str or None Frequency tag. Examples -------- >>> ps = PriceSeries([100., 101., 99.], name="px") >>> len(ps) 3 >>> ps[1] 101.0 """ def __init__( self, values: ArrayLike, index: ArrayLike | None = None, name: str | None = None, freq: str | None = None, ): """ Initialize the series. """ v = np.asarray(values, dtype=np.float64).reshape(-1).copy() v.flags.writeable = False self.values: NDArray[np.float64] = v if index is None: idx = np.arange(v.size, dtype=np.int64) else: idx = np.asarray(index).reshape(-1) if idx.size != v.size: raise ValueError( f"index length {idx.size} != values length {v.size}" ) idx.flags.writeable = False self.index: NDArray[Any] = idx self.name = name self.freq = freq # -- Constructors -----------------------------------------------------
[docs] @classmethod def from_numpy( cls, values: NDArray, index: NDArray | None = None, name: str | None = None, freq: str | None = None, ) -> PriceSeries: """ Build a :class:`PriceSeries` from numpy arrays. """ return cls(values, index=index, name=name, freq=freq)
[docs] @classmethod def from_polars( cls, data: Any, value_col: str | None = None, index_col: str | None = None, freq: str | None = None, ) -> PriceSeries: """ Build a :class:`PriceSeries` from a polars Series or DataFrame. A :class:`polars.Series` maps directly to the values. For a :class:`polars.DataFrame`, ``value_col`` selects the value column (defaults to the single non-index numeric column) and ``index_col`` (or the first temporal column) becomes the index. """ import polars as pl if isinstance(data, pl.Series): return cls( data.to_numpy(), name=value_col or data.name, freq=freq, ) if not isinstance(data, pl.DataFrame): raise TypeError(f"unsupported polars input: {type(data)!r}") # Resolve the index column: explicit, else first temporal column. if index_col is None: temporal = [ c for c, dt in zip(data.columns, data.dtypes) if dt in (pl.Date, pl.Datetime) ] index_col = temporal[0] if temporal else None index = data[index_col].to_numpy() if index_col is not None else None if value_col is None: candidates = [c for c in data.columns if c != index_col] if len(candidates) != 1: raise ValueError( "value_col is ambiguous; pass it explicitly " f"(candidates={candidates})" ) value_col = candidates[0] return cls( data[value_col].to_numpy(), index=index, name=value_col, freq=freq, )
# -- Dunders ---------------------------------------------------------- def __len__(self) -> int: """ Number of observations. """ return int(self.values.size) def __getitem__(self, key: int | slice) -> float | PriceSeries: """ Index by position (int -> scalar, slice -> sub-series). """ if isinstance(key, slice): return PriceSeries( self.values[key], index=self.index[key], name=self.name, freq=self.freq, ) return float(self.values[key]) def __array__(self, dtype: Any = None) -> NDArray: """ numpy interop: ``np.asarray(ps)`` returns the values. """ if dtype is None: return self.values return self.values.astype(dtype) def __eq__(self, other: object) -> bool: """ Equality by values and index. """ if not isinstance(other, PriceSeries): return NotImplemented return ( np.array_equal(self.values, other.values, equal_nan=True) and np.array_equal(self.index, other.index) ) def __repr__(self) -> str: """ Short representation with head/tail. """ n = len(self) name = f" name={self.name!r}" if self.name else "" freq = f" freq={self.freq!r}" if self.freq else "" if n <= 6: body = ", ".join(f"{v:g}" for v in self.values) else: head = ", ".join(f"{v:g}" for v in self.values[:3]) tail = ", ".join(f"{v:g}" for v in self.values[-3:]) body = f"{head}, ..., {tail}" return f"PriceSeries([{body}], len={n}{name}{freq})" __hash__ = None # type: ignore[assignment] # -- Helpers ---------------------------------------------------------- def _new(self, values: NDArray, index: NDArray | None = None) -> PriceSeries: """ Build a derived series carrying name/freq. """ return PriceSeries( values, index=self.index if index is None else index, name=self.name, freq=self.freq, )
[docs] def copy(self) -> PriceSeries: """ Return a copy of this series. """ return self._new(self.values.copy())
# -- Finance core methods (price <-> return identities) ----------------
[docs] def to_returns( self, kind: str = "pct", dropna: bool = True, ) -> PriceSeries: """ Convert a price path to a return series. Parameters ---------- kind : {"pct", "log", "raw"} ``pct`` -> ``p_t / p_{t-1} - 1``; ``log`` -> ``ln(p_t / p_{t-1})``; ``raw`` -> ``p_t - p_{t-1}``. dropna : bool Drop the (undefined) first observation. If ``False``, a leading ``NaN`` is kept so the length is preserved. Returns ------- PriceSeries The return series (strictly causal: ``r_t`` uses only ``p_t`` and ``p_{t-1}``). Examples -------- >>> PriceSeries([100., 110., 99.]).to_returns("pct").values array([ 0.1, -0.1]) """ p = self.values if kind == "pct": r = p[1:] / p[:-1] - 1.0 elif kind == "log": r = np.log(p[1:] / p[:-1]) elif kind == "raw": r = p[1:] - p[:-1] else: raise ValueError(f"unknown kind: {kind!r}") if dropna: return self._new(r, index=self.index[1:]) full = np.empty(p.size, dtype=np.float64) full[0] = np.nan full[1:] = r return self._new(full)
[docs] def to_prices(self, base: float = 1.0, kind: str = "pct") -> PriceSeries: """ Reconstruct a price path from this return series (inverse of :meth:`to_returns`). Parameters ---------- base : float Initial price prepended to the path. kind : {"pct", "log", "raw"} Must match the convention the returns were computed with. Returns ------- PriceSeries Price path of length ``len(self) + 1``. """ r = self.values if kind == "pct": path = base * np.cumprod(1.0 + r) elif kind == "log": path = base * np.exp(np.cumsum(r)) elif kind == "raw": path = base + np.cumsum(r) else: raise ValueError(f"unknown kind: {kind!r}") prices = np.empty(r.size + 1, dtype=np.float64) prices[0] = base prices[1:] = path return PriceSeries(prices, name=self.name, freq=self.freq)
[docs] def cumulative(self) -> PriceSeries: """ Equity curve of this return series: ``cumprod(1 + r)``. """ return self._new(np.cumprod(1.0 + self.values))
[docs] def pnl(self, positions: ArrayLike) -> PriceSeries: """ Strategy returns of a position book on this return series. Causal: the position is shifted forward one step, so the position decided at ``t-1`` earns the return at ``t`` (``pnl_t = position_{t-1} * r_t``). The first observation is ``NaN`` (no prior position). Parameters ---------- positions : array-like Position series aligned with this return series. Returns ------- PriceSeries Strategy return series. """ pos = np.asarray(positions, dtype=np.float64).reshape(-1) if pos.size != self.values.size: raise ValueError( f"positions length {pos.size} != returns length " f"{self.values.size}" ) shifted = np.empty_like(pos) shifted[0] = np.nan shifted[1:] = pos[:-1] return self._new(shifted * self.values)
[docs] def drop_na(self) -> PriceSeries: """ Drop ``NaN`` observations (and their index entries). """ mask = ~np.isnan(self.values) return self._new(self.values[mask], index=self.index[mask])
[docs] def fillna(self, value: float = 0.0) -> PriceSeries: """ Replace ``NaN`` observations by a constant value. """ return self._new(np.nan_to_num(self.values, nan=value))
# -- Bridges & composition --------------------------------------------
[docs] def to_numpy(self, copy: bool = True) -> NDArray[np.float64]: """ Return the values as a numpy array (copied by default). """ return self.values.copy() if copy else self.values
[docs] def to_torch(self, dtype: Any = None, device: Any = None) -> Any: """ Return the values as a ``torch.Tensor`` (lazy torch import). """ import torch if dtype is None: dtype = torch.float32 # Copy: the stored array is read-only, which torch warns about. return torch.as_tensor(self.values.copy(), dtype=dtype, device=device)
[docs] def pipe( self, func: Callable[..., Any], *args: Any, **kwargs: Any, ) -> Any: """ Apply a free function to the values, re-wrapping when possible. Delegates to the rich transform library without bloating this class: ``ps.pipe(rsi, window=14)`` calls ``rsi(ps.values, window=14)``. If the result is a 1-D array of the same length it is wrapped back into a :class:`PriceSeries` (same index); otherwise the raw result is returned. """ out = func(self.values, *args, **kwargs) arr = np.asarray(out) if arr.ndim == 1 and arr.size == self.values.size: return self._new(arr) return out
[docs] def apply(self, func: Callable[[float], float]) -> PriceSeries: """ Apply an element-wise function to the values. """ return self._new(np.vectorize(func)(self.values))