Source code for orion_finance_sdk_py.stats.portfolio

"""Thin skfolio MeanRisk helpers for research notebooks.

Not a sklearn Pipeline. Weights are labeled Series. Annualization defaults to
365. Zero-variance assets are dropped before fitting, as in the universe
notebook.
"""

from __future__ import annotations

from dataclasses import dataclass

import pandas as pd
from skfolio import RiskMeasure
from skfolio.optimization import MeanRisk, ObjectiveFunction

from orion_finance_sdk_py.stats.constants import DEFAULT_PERIODS_PER_YEAR, ZERO_VARIANCE
from orion_finance_sdk_py.stats.rfr import daily_rfr
from orion_finance_sdk_py.stats.series import ReturnSeries


[docs] @dataclass class FittedPortfolio: """Labeled MeanRisk weights plus the fitted skfolio estimator.""" weights: pd.Series model: MeanRisk dropped: tuple[str, ...]
[docs] def predict(self, returns: pd.DataFrame) -> object: """Predict an out-of-sample skfolio Portfolio on the kept columns.""" kept = [name for name in self.weights.index if name in returns.columns] return self.model.predict(returns.loc[:, kept])
def _as_frame(returns: ReturnSeries | pd.DataFrame) -> pd.DataFrame: """Contiguous daily returns as a DataFrame.""" return returns.returns if isinstance(returns, ReturnSeries) else returns
[docs] def chronological_split( returns: ReturnSeries | pd.DataFrame, test_size: float = 0.33, ) -> tuple[pd.DataFrame, pd.DataFrame]: """Time-ordered train/test split (no shuffle) on overlapping rows.""" if not 0.0 < test_size < 1.0: raise ValueError("test_size must be in (0, 1)") overlap = _as_frame(returns).dropna(how="any") n = len(overlap) n_test = int(round(n * test_size)) n_train = n - n_test if n_train < 1 or n_test < 1: raise ValueError("split would leave an empty train or test set") return overlap.iloc[:n_train], overlap.iloc[n_train:]
[docs] def drop_zero_variance( returns: pd.DataFrame, *, threshold: float = ZERO_VARIANCE, ) -> tuple[pd.DataFrame, tuple[str, ...]]: """Drop columns whose population std is at or below ``threshold``.""" vol = returns.std(ddof=0) dropped = tuple(str(name) for name in vol.index[vol <= threshold]) if dropped: returns = returns.drop(columns=list(dropped)) return returns, dropped
def _fit_mean_risk( returns: pd.DataFrame, model: MeanRisk, dropped: tuple[str, ...], ) -> FittedPortfolio: """Fit ``model`` and wrap labeled weights.""" if returns.shape[1] < 2 or returns.shape[0] < 2: raise ValueError("MeanRisk needs at least 2 assets and 2 observations") model.fit(returns) weights = pd.Series(model.weights_, index=returns.columns, dtype=float) return FittedPortfolio(weights=weights, model=model, dropped=dropped) def _prepare( returns: ReturnSeries | pd.DataFrame, ) -> tuple[pd.DataFrame, tuple[str, ...]]: """Overlap rows and drop flat assets.""" overlap = _as_frame(returns).dropna(how="any") return drop_zero_variance(overlap)
[docs] def min_variance( returns: ReturnSeries | pd.DataFrame, *, rfr: float = 0.0, periods_per_year: int = DEFAULT_PERIODS_PER_YEAR, ) -> FittedPortfolio: """Minimum-variance long-only portfolio (skfolio ``MeanRisk`` default).""" frame, dropped = _prepare(returns) rf_period = daily_rfr(rfr, periods_per_year=periods_per_year) model = MeanRisk( risk_free_rate=rf_period, portfolio_params={"annualized_factor": float(periods_per_year)}, ) return _fit_mean_risk(frame, model, dropped)
[docs] def max_sortino( returns: ReturnSeries | pd.DataFrame, *, rfr: float = 0.0, periods_per_year: int = DEFAULT_PERIODS_PER_YEAR, ) -> FittedPortfolio: """Maximize Sortino ratio (mean / semi-deviation).""" frame, dropped = _prepare(returns) rf_period = daily_rfr(rfr, periods_per_year=periods_per_year) model = MeanRisk( objective_function=ObjectiveFunction.MAXIMIZE_RATIO, risk_measure=RiskMeasure.SEMI_VARIANCE, risk_free_rate=rf_period, portfolio_params={"annualized_factor": float(periods_per_year)}, ) return _fit_mean_risk(frame, model, dropped)
[docs] def max_sharpe( returns: ReturnSeries | pd.DataFrame, *, rfr: float = 0.0, periods_per_year: int = DEFAULT_PERIODS_PER_YEAR, ) -> FittedPortfolio: """Maximize Sharpe ratio (mean excess return / standard deviation).""" frame, dropped = _prepare(returns) rf_period = daily_rfr(rfr, periods_per_year=periods_per_year) model = MeanRisk( objective_function=ObjectiveFunction.MAXIMIZE_RATIO, risk_measure=RiskMeasure.VARIANCE, risk_free_rate=rf_period, portfolio_params={"annualized_factor": float(periods_per_year)}, ) return _fit_mean_risk(frame, model, dropped)