Source code for fynance.strategy.strategy

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

""" Optional orchestrator composing the fynance pipeline.

:class:`Strategy` wires the protocol-based maillons — features -> model ->
signal -> cost -> backtest -> metrics — into one runnable object. Every slot is
optional and accepts any object conforming to its protocol, so the pieces stay
usable standalone (this is composition, not a forced pipeline).

Decision D4: a fluent dataclass-style constructor (slots as keyword arguments)
with a ``.run`` method, rather than a declarative config tree.

"""

from __future__ import annotations

# Built-in packages
from typing import Any, Callable

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

# Local packages
from fynance.backtest import backtest
from fynance.backtest.result import BacktestResult
from fynance.data import walk_forward
from fynance.signal import sign

__all__ = ['Strategy']


def _as_array(data: Any) -> NDArray:
    """ Coerce a PriceSeries / array-like to a float64 numpy array. """
    values = getattr(data, "values", None)
    arr = values if values is not None else data

    return np.asarray(arr, dtype=np.float64)


[docs] class Strategy: """ Compose features, a model, a signal mapper and costs into a backtest. Parameters ---------- model : SignalModel, optional Object with ``fit(X, y)`` / ``predict(X)``. If omitted, the (featured) input is fed straight to the signal mapper (rule-based strategy). signal : callable, optional Maps predictions/features to positions. Defaults to :func:`fynance.signal.sign`. features : callable, optional Maps the price series to a feature array used as model/​signal input. Defaults to the identity (the price series itself). cost : CostModel, optional Per-step transaction cost model. period : int Annualization factor passed to the summary. """ def __init__( self, model: Any = None, signal: Callable[..., NDArray] = sign, features: Callable[[NDArray], NDArray] | None = None, cost: Any = None, period: int = 252, ): """ Store the pipeline slots. """ self.model = model self.signal = signal self.features = features self.cost = cost self.period = period def _featurize(self, prices: NDArray) -> NDArray: """ Build the feature array from a price series. """ if self.features is None: return prices return np.asarray(self.features(prices), dtype=np.float64) def _positions(self, prices: NDArray, y: NDArray | None) -> NDArray: """ Compute the (unshifted) position series from a price series. """ feats = self._featurize(prices) if self.model is not None: if y is not None: self.model.fit(feats, y) preds = np.asarray(self.model.predict(feats), dtype=np.float64) else: preds = feats return np.asarray(self.signal(preds), dtype=np.float64).reshape(-1)
[docs] def run(self, data: Any, y: NDArray | None = None) -> BacktestResult: """ Run the strategy on a price series, returning a backtest result. Parameters ---------- data : PriceSeries or array-like Price series. y : array-like, optional Supervised target for the model (fit on the whole series; for leak-free training use :meth:`run_walk_forward`). Returns ------- BacktestResult """ prices = _as_array(data) returns = prices[1:] / prices[:-1] - 1.0 positions = self._positions(prices, y) # Align positions with the returns series (drop the first observation). if positions.shape[0] == prices.shape[0]: positions = positions[1:] return backtest(returns, positions, cost=self.cost, shift=True)
[docs] def run_walk_forward( self, data: Any, y: NDArray, train: int, test: int, step: int | None = None, purge: int = 0, ) -> BacktestResult: """ Walk-forward run: refit per window on train only, predict on test. The model and features are fit on each train slice only; out-of-sample positions are stitched together and backtested. Strictly no-lookahead. Parameters ---------- data : PriceSeries or array-like Price series. y : array-like Supervised target aligned with ``data``. train, test : int Train and test window lengths. step : int, optional Roll step (defaults to ``test``). purge : int Embargo removed at the train/test boundary. Returns ------- BacktestResult Backtest of the concatenated out-of-sample positions. """ prices = _as_array(data) y_arr = np.asarray(y, dtype=np.float64) n = prices.shape[0] oos_pos: list[float] = [] oos_ret: list[float] = [] for tr, te in walk_forward(n, train=train, test=test, step=step, purge=purge): feats_tr = self._featurize(prices[tr]) feats_te = self._featurize(prices[te]) if self.model is not None: self.model.fit(feats_tr, y_arr[tr]) preds = np.asarray(self.model.predict(feats_te), dtype=np.float64) else: preds = feats_te pos = np.asarray(self.signal(preds), dtype=np.float64).reshape(-1) # Return realized over each test step (causal: position at t earns r_t # within the OOS block; the engine applies the one-step shift). block = prices[te] ret = np.empty(block.shape[0]) ret[0] = np.nan ret[1:] = block[1:] / block[:-1] - 1.0 oos_pos.extend(pos.tolist()) oos_ret.extend(ret.tolist()) positions = np.asarray(oos_pos) returns = np.nan_to_num(np.asarray(oos_ret), nan=0.0) return backtest(returns, positions, cost=self.cost, shift=True)