diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7979533..4fafa4c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,7 +11,34 @@ concurrency: cancel-in-progress: true jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: uv sync --extra dev + + - name: Check formatting + run: uv run ruff format --check src/ + + - name: Lint + run: uv run ruff check src/ + + - name: Run tests + run: uv run pytest tests/ -v + run-notebooks: + needs: [lint] runs-on: arc-runners-org-nvidia-ai-bp-1-gpu env: PYTHON_VERSION: 3.12 diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 0000000..f67e7e4 --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,91 @@ +name: PR Checks + +on: + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lockfile-check: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "0.11.7" + + - name: Verify lockfile resolves across all extras and Python range + run: uv lock --locked + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "0.11.7" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: uv sync --extra dev + + - name: Check formatting + run: uv run ruff format --check src/ + + - name: Lint + run: uv run ruff check src/ + + install-and-test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "0.11.7" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package + run: uv sync --extra dev --python ${{ matrix.python-version }} + + - name: Verify import + run: uv run python -c "import cufolio" + + - name: Run tests + run: uv run pytest tests/ -v + + pr-builder: + if: always() + needs: [lockfile-check, lint, install-and-test] + runs-on: ubuntu-latest + steps: + - name: Check job results + run: | + if [[ "${{ needs.lockfile-check.result }}" != "success" || "${{ needs.lint.result }}" != "success" || "${{ needs.install-and-test.result }}" != "success" ]]; then + echo "One or more required jobs failed." + exit 1 + fi diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..44b8954 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,12 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.9 + hooks: + - id: ruff-check + args: [--fix, --config, pyproject.toml] + - id: ruff-format diff --git a/notebooks/cvar_basic.ipynb b/notebooks/cvar_basic.ipynb index 452c16d..25aa32d 100644 --- a/notebooks/cvar_basic.ipynb +++ b/notebooks/cvar_basic.ipynb @@ -1860,7 +1860,7 @@ ")\n", "\n", "# Solver settings for cuOpt Python API\n", - "cuopt_settings = {\"log_to_console\":True, \"presolve\": False, \"method\": 1}\n", + "cuopt_settings = {\"log_to_console\":True, \"presolve\": 0, \"method\": 1}\n", "\n", "# Solve using cuOpt Python API\n", "cuopt_gpu_results, cuopt_gpu_portfolio = cvar_problem.solve_optimization_problem(solver_settings=cuopt_settings)\n" diff --git a/notebooks/launchable.ipynb b/notebooks/launchable.ipynb index 063efd0..15513ce 100644 --- a/notebooks/launchable.ipynb +++ b/notebooks/launchable.ipynb @@ -2225,7 +2225,7 @@ ")\n", "\n", "# Solver settings for cuOpt Python API\n", - "cuopt_settings = {\"log_to_console\":True, \"presolve\": False, \"method\": 1}\n", + "cuopt_settings = {\"log_to_console\":True, \"presolve\": 0, \"method\": 1}\n", "\n", "# Solve using cuOpt Python API\n", "cuopt_gpu_results, cuopt_gpu_portfolio = cvar_problem.solve_optimization_problem(solver_settings=cuopt_settings)\n" diff --git a/pyproject.toml b/pyproject.toml index 3fa649a..fc78cf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,9 +16,8 @@ dependencies = [ [project.optional-dependencies] dev = [ - "black==25.9.0", - "isort", - "flake8", + "ruff==0.15.9", + "pytest>=9.0", "pre-commit==4.3.0", ] cuda12 = [ @@ -30,13 +29,19 @@ cuda13 = [ "cuopt-cu13==26.04.*", ] -[tool.black] +[tool.ruff] line-length = 88 -target-version = ['py311'] +target-version = "py311" -[tool.isort] -profile = "black" -line_length = 88 +[tool.ruff.lint] +select = ["E", "F", "W", "I"] +ignore = ["E501"] + +[tool.ruff.lint.per-file-ignores] +"**/__init__.py" = ["E402"] + +[tool.ruff.lint.isort] +combine-as-imports = true [tool.setuptools] packages = ["cufolio"] diff --git a/src/base_optimizer.py b/src/base_optimizer.py index 4fbcbce..5a7af60 100644 --- a/src/base_optimizer.py +++ b/src/base_optimizer.py @@ -405,9 +405,7 @@ def solve_optimization_problem( ) if print_results: - self._print_results( - result_row, portfolio, time_results, min_percentage=1 - ) + self._print_results(result_row, portfolio, time_results, min_percentage=1) return result_row, portfolio diff --git a/src/cvar_optimizer.py b/src/cvar_optimizer.py index af9bf96..4d33fc4 100644 --- a/src/cvar_optimizer.py +++ b/src/cvar_optimizer.py @@ -20,8 +20,7 @@ import numpy as np import pandas as pd -from . import base_optimizer -from . import cvar_utils +from . import base_optimizer, cvar_utils from .cvar_parameters import CvarParameters from .portfolio import Portfolio from .settings import ApiSettings @@ -402,10 +401,10 @@ def _setup_cuopt_problem(self): INTEGER, MAXIMIZE, MINIMIZE, - Problem, LinearExpression, + Problem, ) - + num_assets = self.n_assets num_scen = len(self.data.p) @@ -471,11 +470,11 @@ def _setup_cuopt_problem(self): for j in range(num_scen): # Build constraint using LinearExpression scenario_vars = [variables["t"], variables["u"][j]] + variables["w"] - scenario_coeffs = [1.0, 1.0] + [float(self.data.R[i, j]) for i in range(num_assets)] + scenario_coeffs = [1.0, 1.0] + [ + float(self.data.R[i, j]) for i in range(num_assets) + ] scenario_expr = LinearExpression(scenario_vars, scenario_coeffs, 0.0) - problem.addConstraint( - scenario_expr >= 0.0, name=f"cvar_scenario_{j}" - ) + problem.addConstraint(scenario_expr >= 0.0, name=f"cvar_scenario_{j}") timing_dict["cvar_constraints"] = time.time() - start_time # Add leverage constraint: sum(|w[i]|) <= L_tar @@ -494,10 +493,16 @@ def _setup_cuopt_problem(self): # Then, add decomposition constraints: w[i] = w_pos[i] - w_neg[i] for i in range(num_assets): - decomp_vars = [variables["w"][i], variables["w_pos"][i], variables["w_neg"][i]] + decomp_vars = [ + variables["w"][i], + variables["w_pos"][i], + variables["w_neg"][i], + ] decomp_coeffs = [1.0, -1.0, 1.0] # w - w_pos + w_neg = 0 decomp_expr = LinearExpression(decomp_vars, decomp_coeffs, 0.0) - problem.addConstraint(decomp_expr == 0.0, name=f"weight_decomposition_{i}") + problem.addConstraint( + decomp_expr == 0.0, name=f"weight_decomposition_{i}" + ) # Leverage constraint: sum(w_pos + w_neg) <= L_tar leverage_vars = variables["w_pos"] + variables["w_neg"] @@ -535,7 +540,7 @@ def _setup_cuopt_problem(self): lower_coeffs = [1.0, -float(self.params.w_min[i])] lower_expr = LinearExpression(lower_vars, lower_coeffs, 0.0) problem.addConstraint(lower_expr >= 0.0, name=f"cardinality_lower_{i}") - + # Upper bound: w[i] - w_max[i] * y[i] <= 0 upper_vars = [variables["w"][i], variables["y"][i]] upper_coeffs = [1.0, -float(self.params.w_max[i])] @@ -570,7 +575,11 @@ def _setup_cuopt_problem(self): # Decomposition constraints: w[i] - w_prev[i] = to_pos[i] - to_neg[i] # Rewritten: w[i] - to_pos[i] + to_neg[i] = w_prev[i] for i in range(num_assets): - decomp_vars = [variables["w"][i], variables["turnover_pos"][i], variables["turnover_neg"][i]] + decomp_vars = [ + variables["w"][i], + variables["turnover_pos"][i], + variables["turnover_neg"][i], + ] decomp_coeffs = [1.0, -1.0, 1.0] decomp_expr = LinearExpression(decomp_vars, decomp_coeffs, 0.0) problem.addConstraint( @@ -620,15 +629,20 @@ def _setup_cuopt_problem(self): # Set up objective function start_time = time.time() - + # Build expected return expression using LinearExpression expected_return_coeffs = [float(self.data.mean[i]) for i in range(num_assets)] - expected_return_expr = LinearExpression(variables["w"], expected_return_coeffs, 0.0) + expected_return_expr = LinearExpression( + variables["w"], expected_return_coeffs, 0.0 + ) # Build CVaR expression using LinearExpression # CVaR = t + sum(p[j] / (1 - alpha)) * u[j] cvar_vars = [variables["t"]] + variables["u"] - cvar_coeffs = [1.0] + [float(self.data.p[j] / (1 - self.params.confidence)) for j in range(num_scen)] + cvar_coeffs = [1.0] + [ + float(self.data.p[j] / (1 - self.params.confidence)) + for j in range(num_scen) + ] cvar_expr = LinearExpression(cvar_vars, cvar_coeffs, 0.0) if self.params.cvar_limit is None: @@ -637,8 +651,14 @@ def _setup_cuopt_problem(self): obj_vars = [variables["t"]] + variables["u"] + variables["w"] obj_coeffs = ( [float(self.params.risk_aversion)] # t coefficient - + [float(self.params.risk_aversion) * float(self.data.p[j] / (1 - self.params.confidence)) for j in range(num_scen)] # u coefficients - + [-float(self.data.mean[i]) for i in range(num_assets)] # w coefficients (negative for return) + + [ + float(self.params.risk_aversion) + * float(self.data.p[j] / (1 - self.params.confidence)) + for j in range(num_scen) + ] # u coefficients + + [ + -float(self.data.mean[i]) for i in range(num_assets) + ] # w coefficients (negative for return) ) objective_expr = LinearExpression(obj_vars, obj_coeffs, 0.0) problem.setObjective(objective_expr, sense=MINIMIZE) @@ -699,6 +719,7 @@ def _solve_cuopt_problem(self, solver_settings: dict = None): """ # Lazy import from cuopt.linear_programming.solver_settings import SolverSettings + # Configure solver settings settings = SolverSettings() if solver_settings: diff --git a/src/cvar_utils.py b/src/cvar_utils.py index 038e383..ab84193 100644 --- a/src/cvar_utils.py +++ b/src/cvar_utils.py @@ -81,15 +81,15 @@ def generate_samples_kde( if kde_device == "CPU": start_time = time.time() - #fit kde and sample from it + # fit kde and sample from it kde = sklearn.neighbors.KernelDensity(kernel=kernel, bandwidth=bandwidth).fit( returns_data ) new_samples = kde.sample(num_scen) - + end_time = time.time() kde_time = end_time - start_time - + if verbose: print(f"KDE fit on CPU in {kde_time} seconds.") @@ -99,9 +99,9 @@ def generate_samples_kde( start_time = time.time() with cuml.using_output_type("numpy"): - kde = cuml.neighbors.KernelDensity( - kernel=kernel, bandwidth=bandwidth - ).fit(returns_data) + kde = cuml.neighbors.KernelDensity(kernel=kernel, bandwidth=bandwidth).fit( + returns_data + ) new_samples = kde.sample(num_scen) end_time = time.time() @@ -293,9 +293,9 @@ def get_solver_name(settings): # Set up optimization problem cvar_problem = cvar_optimizer.CVaR( - returns_dict=returns_dict, cvar_params=cvar_params + returns_dict=returns_dict, cvar_params=cvar_params ) - + # Solve optimization problem try: result, portfolio = cvar_problem.solve_optimization_problem( @@ -778,15 +778,19 @@ def create_efficient_frontier( # Color schemes color_schemes = { - "modern": { - "frontier": "#7cd7fe", - "benchmark": ["#ef9100", "#ff8181", "#0d8473"], #NVIDIA orange, red, dark teal - "assets": "#c359ef", - "custom": "#fc79ca", - "background": "#FFFFFF", - "grid": "#E0E0E0", - } + "modern": { + "frontier": "#7cd7fe", + "benchmark": [ + "#ef9100", + "#ff8181", + "#0d8473", + ], # NVIDIA orange, red, dark teal + "assets": "#c359ef", + "custom": "#fc79ca", + "background": "#FFFFFF", + "grid": "#E0E0E0", } + } colors = color_schemes[color_scheme] # Set style diff --git a/src/mean_variance_optimizer.py b/src/mean_variance_optimizer.py index cd4ee03..4ac93ec 100644 --- a/src/mean_variance_optimizer.py +++ b/src/mean_variance_optimizer.py @@ -119,8 +119,11 @@ def __init__( An existing portfolio to measure the turnover from. """ super().__init__( - returns_dict, mean_variance_params, api_settings, - existing_portfolio, "variance" + returns_dict, + mean_variance_params, + api_settings, + existing_portfolio, + "variance", ) self.mean = returns_dict["mean"] @@ -220,7 +223,9 @@ def _setup_cvxpy_problem(self): # Set up common constraints if self.params.cardinality is not None: - raise NotImplementedError("MIQP (cardinality constraint) is not implemented yet.") + raise NotImplementedError( + "MIQP (cardinality constraint) is not implemented yet." + ) else: constraints.extend( [ @@ -249,8 +254,7 @@ def _setup_cvxpy_problem(self): if self.params.group_constraints is not None: for group_constraint in self.params.group_constraints: tickers_index = [ - self.tickers.index(ticker) - for ticker in group_constraint["tickers"] + self.tickers.index(ticker) for ticker in group_constraint["tickers"] ] constraints.append( cp.sum(self.w[tickers_index]) @@ -285,8 +289,8 @@ def _setup_cuopt_problem(self): from cuopt.linear_programming.problem import ( CONTINUOUS, MINIMIZE, - Problem, LinearExpression, + Problem, QuadraticExpression, ) @@ -405,8 +409,7 @@ def _setup_cuopt_problem(self): t0 = time.time() for group_idx, group_constraint in enumerate(self.params.group_constraints): tickers_index = [ - self.tickers.index(ticker) - for ticker in group_constraint["tickers"] + self.tickers.index(ticker) for ticker in group_constraint["tickers"] ] if len(tickers_index) > 0: group_vars = [w_vars[i] for i in tickers_index] @@ -433,9 +436,7 @@ def _setup_cuopt_problem(self): t0 = time.time() total_vars = problem.NumVariables q_matrix = np.zeros((total_vars, total_vars)) - q_matrix[:num_assets, :num_assets] = ( - self.params.risk_aversion * self.covariance - ) + q_matrix[:num_assets, :num_assets] = self.params.risk_aversion * self.covariance quad_expr = QuadraticExpression(q_matrix, problem.getVariables()) timing["build_quad_matrix"] = time.time() - t0 @@ -455,7 +456,9 @@ def _setup_cuopt_problem(self): print(f"{'=' * 50}") print("cuOpt MEAN-VARIANCE (QP) PROBLEM SETUP COMPLETED") print(f"{'=' * 50}") - print(f"Variables: {num_assets} weights + 1 cash + {2 * num_assets} leverage aux") + print( + f"Variables: {num_assets} weights + 1 cash + {2 * num_assets} leverage aux" + ) print(f"Covariance matrix: {num_assets}x{num_assets}") print(f"Linear terms: {num_assets}") print("Problem Type: QP (Quadratic Programming)") @@ -559,7 +562,9 @@ def _print_results( print("\nPERFORMANCE METRICS") print(f"{'-' * 30}") - print(f"Expected Return: {expected_return:.6f} ({expected_return * 100:.4f}%)") + print( + f"Expected Return: {expected_return:.6f} ({expected_return * 100:.4f}%)" + ) print(f"Variance: {variance_value:.6f}") print(f"Std Deviation: {np.sqrt(variance_value):.6f}") print(f"Objective Value: {objective_value:.6f}") @@ -586,4 +591,3 @@ def _print_results( def _get_cone_data_filename(self): regime_name = getattr(self, "regime_name", "unknown") return f"mean_variance_{regime_name}.pkl" - diff --git a/src/portfolio.py b/src/portfolio.py index b37853e..4fba06d 100644 --- a/src/portfolio.py +++ b/src/portfolio.py @@ -265,9 +265,9 @@ def calculate_portfolio_expected_return(self, mean): float Portfolio expected return """ - assert ( - mean.shape[0] == self._n_assets - ), f"Incorrect mean vector size! Expecting: {self._n_assets}." + assert mean.shape[0] == self._n_assets, ( + f"Incorrect mean vector size! Expecting: {self._n_assets}." + ) return mean @ self.weights @@ -362,7 +362,7 @@ def plot_portfolio( colors = color_schemes.get(style, color_schemes["modern"]) plt.style.use("seaborn-v0_8-whitegrid") - + # Create figure if needed if ax is None: fig, ax = plt.subplots( diff --git a/src/rebalance.py b/src/rebalance.py index dae0d26..971c44b 100644 --- a/src/rebalance.py +++ b/src/rebalance.py @@ -128,9 +128,9 @@ def _get_price_data(self): self.dataset_directory, index_col=0, parse_dates=True ) price_data_start = self.trading_start - pd.Timedelta(days=self.look_back_window) - assert ( - price_data_start >= self.price_data.index[0] - ), "Invalid start date - choose a later date!" + assert price_data_start >= self.price_data.index[0], ( + "Invalid start date - choose a later date!" + ) def re_optimize( self, @@ -248,13 +248,10 @@ def re_optimize( } optimize_returns_dict = utils.calculate_returns( - self.price_data, - optimize_regime, - self.returns_compute_settings + self.price_data, optimize_regime, self.returns_compute_settings ) optimize_returns_dict = cvar_utils.generate_cvar_data( - optimize_returns_dict, - self.scenario_generation_settings + optimize_returns_dict, self.scenario_generation_settings ) re_optimize_problem = cvar_optimizer.CVaR( @@ -366,10 +363,12 @@ def re_optimize( current_portfolio, tail_returns, benchmark_portfolios=None ) tail_result = tail_bt.backtest_single_portfolio(current_portfolio) - cumulative_portfolio_value_array = np.concatenate(( - cumulative_portfolio_value_array, - tail_result["cumulative returns"].values[0] * portfolio_value, - )) + cumulative_portfolio_value_array = np.concatenate( + ( + cumulative_portfolio_value_array, + tail_result["cumulative returns"].values[0] * portfolio_value, + ) + ) cumulative_portfolio_value_dates.extend(tail_bt._dates) # Convert to pandas Series with dates as index, ensuring proper datetime format @@ -603,7 +602,11 @@ def plot_results( color_schemes = { "modern": { "frontier": "#7cd7fe", - "benchmark": ["#ef9100", "#ff8181", "#0d8473"], #NVIDIA orange, red, dark teal + "benchmark": [ + "#ef9100", + "#ff8181", + "#0d8473", + ], # NVIDIA orange, red, dark teal "assets": "#c359ef", "custom": "#fc79ca", "background": "#FFFFFF", @@ -917,9 +920,9 @@ def plot_weights_vs_prices( AssertionError If ticker not found in price data. """ - assert ( - ticker in self.price_data.columns - ), "The selected ticker is not in the asset universe!" + assert ticker in self.price_data.columns, ( + "The selected ticker is not in the asset universe!" + ) ticker_idx = list(self.price_data.columns).index(ticker) ticker_weights_history = [ @@ -931,7 +934,9 @@ def plot_weights_vs_prices( plot_end_date = re_optimize_results.index[-1] price_data = self.price_data.loc[plot_start_date:plot_end_date, ticker] ax1.plot(price_data, color="red", label=f"{ticker} prices") - ax1.set_title(plot_title if plot_title is not None else f"{ticker} weights vs. prices") + ax1.set_title( + plot_title if plot_title is not None else f"{ticker} weights vs. prices" + ) ax2 = ax1.twinx() ax2.bar( diff --git a/src/settings.py b/src/settings.py index 75deb94..ae296d8 100644 --- a/src/settings.py +++ b/src/settings.py @@ -265,4 +265,3 @@ class ApiSettings(BaseModel): default=None, description="Path to save CVXPY problem pickle (CVXPY only)", ) - diff --git a/src/utils.py b/src/utils.py index e1aff59..36987eb 100644 --- a/src/utils.py +++ b/src/utils.py @@ -16,8 +16,8 @@ """Utility functions for portfolio optimization and data processing.""" import os - from typing import Union + import matplotlib.pyplot as plt import numpy as np import pandas as pd @@ -87,11 +87,14 @@ def calculate_returns( if regime_dict is None or regime_dict.get("range") is None: input_data = input_data - regime_dict = {"name": "Default", "range": (input_data.index[0], input_data.index[-1])} + regime_dict = { + "name": "Default", + "range": (input_data.index[0], input_data.index[-1]), + } else: start, end = regime_dict["range"] input_data = input_data.loc[start:end] - + input_data = input_data.dropna(axis=1) if return_type == "LOG": @@ -132,7 +135,7 @@ def calculate_log_returns(price_data, freq=1): def compute_linear_returns(price_data, freq=1): """ - compute the simple returns using freq. For example, + compute the simple returns using freq. For example, freq = 1 means (today - yesterday) / yesterday. """ returns_dataframe = price_data.pct_change(freq) @@ -144,7 +147,7 @@ def compute_linear_returns(price_data, freq=1): def compute_absolute_returns(price_data, freq=1): """ - compute the absolute returns using freq. + compute the absolute returns using freq. For example, freq = 1 means today - yesterday. """ returns_dataframe = price_data.diff(freq) @@ -461,8 +464,12 @@ def compare_results(*results_list): # Find common numeric keys, sorted: solve time, obj, then rest common = set.intersection(*[set(r.keys()) for r in results]) keys = sorted( - [k for k in common if k != "solver" and isinstance(results[0].get(k), (int, float))], - key=lambda x: (0 if x == "solve time" else 1 if x == "obj" else 2, x) + [ + k + for k in common + if k != "solver" and isinstance(results[0].get(k), (int, float)) + ], + key=lambda x: (0 if x == "solve time" else 1 if x == "obj" else 2, x), ) # Print table @@ -472,66 +479,556 @@ def compare_results(*results_list): print(f"{'Solver':<15}" + "".join(f" {k:<12}" for k in keys)) print("-" * 70) for r in results: - print(f"{r.get('solver', 'Unknown'):<15}" + "".join(f" {(r.get(k) or 0):<12.6f}" for k in keys)) + print( + f"{r.get('solver', 'Unknown'):<15}" + + "".join(f" {(r.get(k) or 0):<12.6f}" for k in keys) + ) # Objective differences if len(results) > 1 and "obj" in keys: print("\nObjective Differences:") for i, r1 in enumerate(results): - for r2 in results[i + 1:]: - print(f" {r1.get('solver')} vs {r2.get('solver')}: {abs(r1.get('obj', 0) - r2.get('obj', 0)):.8f}") + for r2 in results[i + 1 :]: + print( + f" {r1.get('solver')} vs {r2.get('solver')}: {abs(r1.get('obj', 0) - r2.get('obj', 0)):.8f}" + ) print() # Add blank line for better readability SP500_TICKERS = [ - 'A', 'AAPL', 'ABT', 'ACGL', 'ACN', 'ADBE', 'ADI', 'ADM', 'ADP', 'ADSK', 'AEE', 'AEP', 'AES', 'AFL', 'AIG', 'AIZ', 'AJG', 'AKAM', 'ALB', 'ALGN', - 'ALL', 'AMAT', 'AMD', 'AME', 'AMGN', 'AMT', 'AMZN', 'AON', 'AOS', 'APA', 'APD', 'APH', 'ARE', 'ATO', 'AVB', 'AVY', 'AXON', 'AXP', 'AZO', - 'BA', 'BAC', 'BALL', 'BAX', 'BBWI', 'BBY', 'BDX', 'BEN', 'BG', 'BIIB', 'BIO', 'BK', 'BKNG', 'BKR', 'BLK', 'BMY', 'BRO', 'BSX', 'BWA', 'BXP', - 'C', 'CAG', 'CAH', 'CAT', 'CB', 'CBRE', 'CCI', 'CCL', 'CDNS', 'CHD', 'CHRW', 'CI', 'CINF', 'CL', 'CLX', 'CMA', 'CMCSA', 'CME', 'CMI', 'CMS', - 'CNC', 'CNP', 'COF', 'COO', 'COP', 'COR', 'COST', 'CPB', 'CPRT', 'CPT', 'CRL', 'CRM', 'CSCO', 'CSGP', 'CSX', 'CTAS', 'CTRA', 'CTSH', 'CVS', 'CVX', - 'D', 'DD', 'DE', 'DECK', 'DGX', 'DHI', 'DHR', 'DIS', 'DLR', 'DLTR', 'DOC', 'DOV', 'DPZ', 'DRI', 'DTE', 'DUK', 'DVA', 'DVN', - 'EA', 'EBAY', 'ECL', 'ED', 'EFX', 'EG', 'EIX', 'EL', 'ELV', 'EMN', 'EMR', 'EOG', 'EQIX', 'EQR', 'EQT', 'ES', 'ESS', 'ETN', 'ETR', 'EVRG', - 'EW', 'EXC', 'EXPD', 'EXR', 'F', 'FAST', 'FCX', 'FDS', 'FDX', 'FE', 'FFIV', 'FICO', 'FIS', 'FITB', 'FMC', 'FRT', - 'GD', 'GE', 'GEN', 'GILD', 'GIS', 'GL', 'GLW', 'GOOG', 'GOOGL', 'GPC', 'GPN', 'GRMN', 'GS', 'GWW', - 'HAL', 'HAS', 'HBAN', 'HD', 'HIG', 'HOLX', 'HON', 'HPQ', 'HRL', 'HSIC', 'HST', 'HSY', 'HUBB', 'HUM', - 'IBM', 'IDXX', 'IEX', 'IFF', 'ILMN', 'INCY', 'INTC', 'INTU', 'IP', 'IRM', 'ISRG', 'IT', 'ITW', 'IVZ', - 'J', 'JBHT', 'JBL', 'JCI', 'JKHY', 'JNJ', 'JPM', 'KEY', 'KIM', 'KLAC', 'KMB', 'KMX', 'KO', 'KR', - 'L', 'LEN', 'LH', 'LHX', 'LIN', 'LKQ', 'LLY', 'LMT', 'LNT', 'LOW', 'LRCX', 'LUV', 'LVS', - 'MAA', 'MAR', 'MAS', 'MCD', 'MCHP', 'MCK', 'MCO', 'MDLZ', 'MDT', 'MET', 'MGM', 'MHK', 'MKC', 'MKTX', 'MLM', 'MMC', 'MMM', 'MNST', 'MO', 'MOH', - 'MOS', 'MPWR', 'MRK', 'MS', 'MSFT', 'MSI', 'MTB', 'MTCH', 'MTD', 'MU', - 'NDAQ', 'NDSN', 'NEE', 'NEM', 'NFLX', 'NI', 'NKE', 'NOC', 'NRG', 'NSC', 'NTAP', 'NTRS', 'NUE', 'NVDA', 'NVR', - 'O', 'ODFL', 'OKE', 'OMC', 'ON', 'ORCL', 'ORLY', 'OXY', - 'PAYX', 'PCAR', 'PCG', 'PEG', 'PEP', 'PFE', 'PFG', 'PG', 'PGR', 'PH', 'PHM', 'PKG', 'PLD', 'PNC', 'PNR', 'PNW', 'POOL', 'PPG', 'PPL', 'PRU', - 'PSA', 'PTC', 'PWR', 'QCOM', - 'RCL', 'REG', 'REGN', 'RF', 'RHI', 'RJF', 'RL', 'RMD', 'ROK', 'ROL', 'ROP', 'ROST', 'RSG', 'RTX', 'RVTY', - 'SBAC', 'SBUX', 'SCHW', 'SHW', 'SJM', 'SLB', 'SNA', 'SNPS', 'SO', 'SPG', 'SPGI', 'SRE', 'STE', 'STLD', 'STT', 'STX', 'STZ', 'SWK', 'SWKS', 'SYK', - 'SYY', 'T', 'TAP', 'TDY', 'TECH', 'TER', 'TFC', 'TFX', 'TGT', 'TJX', 'TMO', 'TPR', 'TRMB', 'TROW', 'TRV', 'TSCO', 'TSN', 'TT', 'TTWO', 'TXN', - 'TXT', 'TYL', 'UDR', 'UHS', 'UNH', 'UNP', 'UPS', 'URI', 'USB', - 'VLO', 'VMC', 'VRSN', 'VRTX', 'VTR', 'VTRS', 'VZ', - 'WAB', 'WAT', 'WDC', 'WEC', 'WELL', 'WFC', 'WM', 'WMB', 'WMT', 'WRB', 'WST', 'WTW', 'WY', 'WYNN', - 'XEL', 'XOM', 'YUM', 'ZBH', 'ZBRA', + "A", + "AAPL", + "ABT", + "ACGL", + "ACN", + "ADBE", + "ADI", + "ADM", + "ADP", + "ADSK", + "AEE", + "AEP", + "AES", + "AFL", + "AIG", + "AIZ", + "AJG", + "AKAM", + "ALB", + "ALGN", + "ALL", + "AMAT", + "AMD", + "AME", + "AMGN", + "AMT", + "AMZN", + "AON", + "AOS", + "APA", + "APD", + "APH", + "ARE", + "ATO", + "AVB", + "AVY", + "AXON", + "AXP", + "AZO", + "BA", + "BAC", + "BALL", + "BAX", + "BBWI", + "BBY", + "BDX", + "BEN", + "BG", + "BIIB", + "BIO", + "BK", + "BKNG", + "BKR", + "BLK", + "BMY", + "BRO", + "BSX", + "BWA", + "BXP", + "C", + "CAG", + "CAH", + "CAT", + "CB", + "CBRE", + "CCI", + "CCL", + "CDNS", + "CHD", + "CHRW", + "CI", + "CINF", + "CL", + "CLX", + "CMA", + "CMCSA", + "CME", + "CMI", + "CMS", + "CNC", + "CNP", + "COF", + "COO", + "COP", + "COR", + "COST", + "CPB", + "CPRT", + "CPT", + "CRL", + "CRM", + "CSCO", + "CSGP", + "CSX", + "CTAS", + "CTRA", + "CTSH", + "CVS", + "CVX", + "D", + "DD", + "DE", + "DECK", + "DGX", + "DHI", + "DHR", + "DIS", + "DLR", + "DLTR", + "DOC", + "DOV", + "DPZ", + "DRI", + "DTE", + "DUK", + "DVA", + "DVN", + "EA", + "EBAY", + "ECL", + "ED", + "EFX", + "EG", + "EIX", + "EL", + "ELV", + "EMN", + "EMR", + "EOG", + "EQIX", + "EQR", + "EQT", + "ES", + "ESS", + "ETN", + "ETR", + "EVRG", + "EW", + "EXC", + "EXPD", + "EXR", + "F", + "FAST", + "FCX", + "FDS", + "FDX", + "FE", + "FFIV", + "FICO", + "FIS", + "FITB", + "FMC", + "FRT", + "GD", + "GE", + "GEN", + "GILD", + "GIS", + "GL", + "GLW", + "GOOG", + "GOOGL", + "GPC", + "GPN", + "GRMN", + "GS", + "GWW", + "HAL", + "HAS", + "HBAN", + "HD", + "HIG", + "HOLX", + "HON", + "HPQ", + "HRL", + "HSIC", + "HST", + "HSY", + "HUBB", + "HUM", + "IBM", + "IDXX", + "IEX", + "IFF", + "ILMN", + "INCY", + "INTC", + "INTU", + "IP", + "IRM", + "ISRG", + "IT", + "ITW", + "IVZ", + "J", + "JBHT", + "JBL", + "JCI", + "JKHY", + "JNJ", + "JPM", + "KEY", + "KIM", + "KLAC", + "KMB", + "KMX", + "KO", + "KR", + "L", + "LEN", + "LH", + "LHX", + "LIN", + "LKQ", + "LLY", + "LMT", + "LNT", + "LOW", + "LRCX", + "LUV", + "LVS", + "MAA", + "MAR", + "MAS", + "MCD", + "MCHP", + "MCK", + "MCO", + "MDLZ", + "MDT", + "MET", + "MGM", + "MHK", + "MKC", + "MKTX", + "MLM", + "MMC", + "MMM", + "MNST", + "MO", + "MOH", + "MOS", + "MPWR", + "MRK", + "MS", + "MSFT", + "MSI", + "MTB", + "MTCH", + "MTD", + "MU", + "NDAQ", + "NDSN", + "NEE", + "NEM", + "NFLX", + "NI", + "NKE", + "NOC", + "NRG", + "NSC", + "NTAP", + "NTRS", + "NUE", + "NVDA", + "NVR", + "O", + "ODFL", + "OKE", + "OMC", + "ON", + "ORCL", + "ORLY", + "OXY", + "PAYX", + "PCAR", + "PCG", + "PEG", + "PEP", + "PFE", + "PFG", + "PG", + "PGR", + "PH", + "PHM", + "PKG", + "PLD", + "PNC", + "PNR", + "PNW", + "POOL", + "PPG", + "PPL", + "PRU", + "PSA", + "PTC", + "PWR", + "QCOM", + "RCL", + "REG", + "REGN", + "RF", + "RHI", + "RJF", + "RL", + "RMD", + "ROK", + "ROL", + "ROP", + "ROST", + "RSG", + "RTX", + "RVTY", + "SBAC", + "SBUX", + "SCHW", + "SHW", + "SJM", + "SLB", + "SNA", + "SNPS", + "SO", + "SPG", + "SPGI", + "SRE", + "STE", + "STLD", + "STT", + "STX", + "STZ", + "SWK", + "SWKS", + "SYK", + "SYY", + "T", + "TAP", + "TDY", + "TECH", + "TER", + "TFC", + "TFX", + "TGT", + "TJX", + "TMO", + "TPR", + "TRMB", + "TROW", + "TRV", + "TSCO", + "TSN", + "TT", + "TTWO", + "TXN", + "TXT", + "TYL", + "UDR", + "UHS", + "UNH", + "UNP", + "UPS", + "URI", + "USB", + "VLO", + "VMC", + "VRSN", + "VRTX", + "VTR", + "VTRS", + "VZ", + "WAB", + "WAT", + "WDC", + "WEC", + "WELL", + "WFC", + "WM", + "WMB", + "WMT", + "WRB", + "WST", + "WTW", + "WY", + "WYNN", + "XEL", + "XOM", + "YUM", + "ZBH", + "ZBRA", ] SP100_TICKERS = [ - 'AAPL', 'ABBV', 'ABT', 'ACN', 'ADBE', 'AIG', 'AMD', 'AMGN', 'AMT', 'AMZN', - 'AVGO', 'AXP', 'BA', 'BAC', 'BK', 'BKNG', 'BLK', 'BMY', 'BRK-B', 'C', - 'CAT', 'CHTR', 'CI', 'CL', 'CMCSA', 'COF', 'COP', 'COST', 'CRM', 'CSCO', - 'CVS', 'CVX', 'DE', 'DHR', 'DIS', 'DUK', 'EMR', 'EXC', 'F', 'FDX', - 'GD', 'GE', 'GILD', 'GM', 'GOOG', 'GOOGL', 'GS', 'HD', 'HON', 'IBM', - 'INTC', 'INTU', 'ISRG', 'JNJ', 'JPM', 'KHC', 'KO', 'LIN', 'LLY', 'LMT', - 'LOW', 'MA', 'MCD', 'MDLZ', 'MDT', 'MET', 'META', 'MMM', 'MO', 'MRK', - 'MS', 'MSFT', 'NEE', 'NFLX', 'NKE', 'NOC', 'NVDA', 'ORCL', 'PEP', 'PFE', - 'PG', 'PM', 'PYPL', 'QCOM', 'RTX', 'SBUX', 'SCHW', 'SLB', 'SO', 'SPG', - 'T', 'TGT', 'TMO', 'TMUS', 'TSLA', 'TXN', 'UNH', 'UNP', 'UPS', 'USB', - 'V', 'VZ', 'WFC', 'WMT', 'XOM', + "AAPL", + "ABBV", + "ABT", + "ACN", + "ADBE", + "AIG", + "AMD", + "AMGN", + "AMT", + "AMZN", + "AVGO", + "AXP", + "BA", + "BAC", + "BK", + "BKNG", + "BLK", + "BMY", + "BRK-B", + "C", + "CAT", + "CHTR", + "CI", + "CL", + "CMCSA", + "COF", + "COP", + "COST", + "CRM", + "CSCO", + "CVS", + "CVX", + "DE", + "DHR", + "DIS", + "DUK", + "EMR", + "EXC", + "F", + "FDX", + "GD", + "GE", + "GILD", + "GM", + "GOOG", + "GOOGL", + "GS", + "HD", + "HON", + "IBM", + "INTC", + "INTU", + "ISRG", + "JNJ", + "JPM", + "KHC", + "KO", + "LIN", + "LLY", + "LMT", + "LOW", + "MA", + "MCD", + "MDLZ", + "MDT", + "MET", + "META", + "MMM", + "MO", + "MRK", + "MS", + "MSFT", + "NEE", + "NFLX", + "NKE", + "NOC", + "NVDA", + "ORCL", + "PEP", + "PFE", + "PG", + "PM", + "PYPL", + "QCOM", + "RTX", + "SBUX", + "SCHW", + "SLB", + "SO", + "SPG", + "T", + "TGT", + "TMO", + "TMUS", + "TSLA", + "TXN", + "UNH", + "UNP", + "UPS", + "USB", + "V", + "VZ", + "WFC", + "WMT", + "XOM", ] DOW30_TICKERS = [ - 'AAPL', 'AMGN', 'AMZN', 'AXP', 'BA', 'CAT', 'CRM', 'CSCO', 'CVX', 'DIS', - 'DOW', 'GS', 'HD', 'HON', 'IBM', 'INTC', 'JNJ', 'JPM', 'KO', 'MCD', - 'MMM', 'MRK', 'MSFT', 'NKE', 'NVDA', 'PG', 'SHW', 'TRV', 'UNH', 'V', - 'VZ', 'WMT', + "AAPL", + "AMGN", + "AMZN", + "AXP", + "BA", + "CAT", + "CRM", + "CSCO", + "CVX", + "DIS", + "DOW", + "GS", + "HD", + "HON", + "IBM", + "INTC", + "JNJ", + "JPM", + "KO", + "MCD", + "MMM", + "MRK", + "MSFT", + "NKE", + "NVDA", + "PG", + "SHW", + "TRV", + "UNH", + "V", + "VZ", + "WMT", ] DATASET_TICKERS = { @@ -541,14 +1038,15 @@ def compare_results(*results_list): } -def _download_tickers(tickers, output_path, start_date="2005-01-01", - end_date="2025-01-01", batch_size=50): +def _download_tickers( + tickers, output_path, start_date="2005-01-01", end_date="2025-01-01", batch_size=50 +): """Download closing prices for a list of tickers and save to CSV.""" frames = [] for i in range(0, len(tickers), batch_size): - batch = tickers[i:i + batch_size] + batch = tickers[i : i + batch_size] batch_data = yf.download(batch, start=start_date, end=end_date, timeout=30) - frames.append(batch_data['Close']) + frames.append(batch_data["Close"]) data = pd.concat(frames, axis=1) # Drop tickers with >10% missing data, forward-fill the rest @@ -579,7 +1077,9 @@ def download_data(dataset_dir, batch_size=50, datasets=None): _download_tickers(SP500_TICKERS, dataset_dir, batch_size=batch_size) return - target_dir = os.path.dirname(dataset_dir) if os.path.isfile(dataset_dir) else dataset_dir + target_dir = ( + os.path.dirname(dataset_dir) if os.path.isfile(dataset_dir) else dataset_dir + ) os.makedirs(target_dir, exist_ok=True) if datasets is None: @@ -588,7 +1088,9 @@ def download_data(dataset_dir, batch_size=50, datasets=None): for name in datasets: tickers = DATASET_TICKERS.get(name) if tickers is None: - print(f"Unknown dataset '{name}', skipping. Available: {list(DATASET_TICKERS.keys())}") + print( + f"Unknown dataset '{name}', skipping. Available: {list(DATASET_TICKERS.keys())}" + ) continue output_path = os.path.join(target_dir, f"{name}.csv") print(f"Downloading {name} ({len(tickers)} tickers)...") @@ -704,8 +1206,10 @@ def optimize_market_regimes( "scenario_generation_settings is required when using Mean-CVaR optimization" ) risk_measure = "CVaR" - from . import cvar_optimizer # Lazy import - from . import cvar_utils # For generate_cvar_data + from . import ( + cvar_optimizer, # Lazy import + cvar_utils, # For generate_cvar_data + ) elif isinstance(params, MeanVarianceParameters): risk_measure = "variance" from . import mean_variance_optimizer # Lazy import @@ -725,7 +1229,9 @@ def get_solver_name(settings): solver_obj = settings["solver"] return str(solver_obj).replace("cp.", "").replace("solvers.", "") else: - raise ValueError(f"Please provide a solver name in the format 'solver': ") + raise ValueError( + "Please provide a solver name in the format 'solver': " + ) # Build column names dynamically based on solvers columns = ["regime"] @@ -833,4 +1339,4 @@ def get_solver_name(settings): result_dataframe.to_csv(results_csv_file_name, index=False) print(f"Results saved to: {results_csv_file_name}") - return result_dataframe \ No newline at end of file + return result_dataframe diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..b9d8999 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,478 @@ +import cvxpy as cp +import matplotlib +import numpy as np +import pandas as pd +import pytest +from cufolio.backtest import portfolio_backtester +from cufolio.cvar_data import CvarData +from cufolio.cvar_optimizer import CVaR +from cufolio.cvar_parameters import CvarParameters +from cufolio.cvar_utils import ( + compute_CVaR, + create_efficient_frontier, + generate_cvar_data, + normalize_portfolio_weights_to_one, +) +from cufolio.portfolio import Portfolio +from cufolio.settings import ReturnsComputeSettings, ScenarioGenerationSettings +from cufolio.utils import ( + calculate_log_returns, + calculate_returns, + compute_absolute_returns, +) +from pydantic import ValidationError + +matplotlib.use("Agg") + +# --------------------------------------------------------------------------- +# Fixtures: small synthetic data shared across tests +# --------------------------------------------------------------------------- + +TICKERS = ["AAPL", "GOOGL", "MSFT"] + + +@pytest.fixture() +def price_data(): + np.random.seed(42) + dates = pd.date_range("2023-01-01", periods=60, freq="B") + base = np.array([150.0, 100.0, 250.0]) + noise = np.random.randn(60, 3) * 0.5 + prices = base + np.cumsum(noise, axis=0) + return pd.DataFrame(prices, index=dates, columns=TICKERS) + + +@pytest.fixture() +def returns_dict(price_data): + settings = ReturnsComputeSettings(return_type="LOG", freq=1) + return calculate_returns( + price_data, regime_dict=None, returns_compute_settings=settings + ) + + +@pytest.fixture() +def cvar_data(returns_dict): + np.random.seed(0) + settings = ScenarioGenerationSettings(num_scen=200, fit_type="gaussian") + rd = generate_cvar_data(returns_dict, settings) + return rd["cvar_data"] + + +@pytest.fixture() +def cvar_params(): + return CvarParameters( + w_min=0.0, + w_max=0.5, + c_min=0.0, + c_max=1.0, + risk_aversion=1.0, + confidence=0.95, + L_tar=1.6, + ) + + +# --------------------------------------------------------------------------- +# Returns +# --------------------------------------------------------------------------- + + +class TestReturns: + def test_log_returns_shape(self, price_data): + ret = calculate_log_returns(price_data, freq=1) + assert ret.shape == (59, 3) + assert list(ret.columns) == TICKERS + + def test_log_returns_values(self, price_data): + ret = calculate_log_returns(price_data, freq=1) + expected_first = np.log(price_data.iloc[1]) - np.log(price_data.iloc[0]) + np.testing.assert_allclose( + ret.iloc[0].values, expected_first.values, atol=1e-12 + ) + + def test_abs_returns_shape(self, price_data): + ret = compute_absolute_returns(price_data, freq=1) + assert ret.shape == (59, 3) + + def test_abs_returns_values(self, price_data): + ret = compute_absolute_returns(price_data, freq=1) + expected_first = price_data.iloc[1] - price_data.iloc[0] + np.testing.assert_allclose( + ret.iloc[0].values, expected_first.values, atol=1e-12 + ) + + def test_calculate_returns_dict_keys(self, returns_dict): + expected_keys = { + "return_type", + "returns", + "regime", + "dates", + "mean", + "covariance", + "tickers", + } + assert expected_keys == set(returns_dict.keys()) + + def test_calculate_returns_mean_shape(self, returns_dict): + assert returns_dict["mean"].shape == (3,) + + def test_calculate_returns_covariance_shape(self, returns_dict): + assert returns_dict["covariance"].shape == (3, 3) + + def test_covariance_is_symmetric(self, returns_dict): + cov = returns_dict["covariance"] + np.testing.assert_allclose(cov, cov.T, atol=1e-12) + + +# --------------------------------------------------------------------------- +# CvarData / CvarParameters +# --------------------------------------------------------------------------- + + +class TestCvarData: + def test_shape(self, cvar_data): + assert cvar_data.R.shape == (3, 200) + assert cvar_data.mean.shape == (3,) + assert cvar_data.p.shape == (200,) + + def test_probabilities_sum_to_one(self, cvar_data): + np.testing.assert_allclose(cvar_data.p.sum(), 1.0, atol=1e-12) + + def test_uniform_probabilities(self, cvar_data): + np.testing.assert_allclose(cvar_data.p, 1.0 / 200, atol=1e-12) + + +class TestCvarParameters: + def test_defaults(self): + params = CvarParameters() + assert params.confidence == 0.95 + assert params.cardinality is None + assert params.T_tar is None + + def test_update_confidence_valid(self): + params = CvarParameters() + params.update_confidence(0.99) + assert params.confidence == 0.99 + + def test_update_confidence_invalid(self): + params = CvarParameters() + with pytest.raises(ValueError): + params.update_confidence(0.0) + with pytest.raises(ValueError): + params.update_confidence(1.5) + + def test_update_risk_aversion_invalid(self): + params = CvarParameters() + with pytest.raises(ValueError): + params.update_risk_aversion(-1.0) + + def test_update_cardinality_invalid(self): + params = CvarParameters() + with pytest.raises(ValueError): + params.update_cardinality(-3) + + def test_update_c_min_invalid(self): + params = CvarParameters() + with pytest.raises(ValueError): + params.update_c_min(-0.1) + + +# --------------------------------------------------------------------------- +# Portfolio math +# --------------------------------------------------------------------------- + + +class TestPortfolio: + def test_expected_return(self): + mean = np.array([0.05, 0.10, 0.15]) + p = Portfolio(tickers=TICKERS, weights=np.array([0.3, 0.3, 0.4])) + ret = p.calculate_portfolio_expected_return(mean) + expected = 0.3 * 0.05 + 0.3 * 0.10 + 0.4 * 0.15 + np.testing.assert_allclose(ret, expected, atol=1e-12) + + def test_portfolio_variance(self): + cov = np.array([[0.04, 0.01, 0.02], [0.01, 0.09, 0.03], [0.02, 0.03, 0.16]]) + w = np.array([0.5, 0.3, 0.2]) + p = Portfolio(tickers=TICKERS, weights=w) + var = p.calculate_portfolio_variance(cov) + expected = w @ cov @ w + np.testing.assert_allclose(var, expected, atol=1e-12) + + def test_portfolio_from_dict(self): + p = Portfolio(tickers=TICKERS) + p.portfolio_from_dict("test", {"AAPL": 0.5, "GOOGL": 0.3}, 0.2) + assert p.name == "test" + np.testing.assert_allclose(p.weights, [0.5, 0.3, 0.0], atol=1e-12) + assert p.cash == pytest.approx(0.2) + + def test_self_financing(self): + p = Portfolio(tickers=TICKERS, weights=np.array([0.3, 0.3, 0.2]), cash=0.2) + p._check_self_financing() # should not raise + + def test_equality(self): + p1 = Portfolio(tickers=TICKERS, weights=np.array([0.5, 0.3, 0.2])) + p2 = Portfolio(tickers=TICKERS, weights=np.array([0.5, 0.3, 0.2])) + assert p1 == p2 + + def test_inequality(self): + p1 = Portfolio(tickers=TICKERS, weights=np.array([0.5, 0.3, 0.2])) + p2 = Portfolio(tickers=TICKERS, weights=np.array([0.1, 0.1, 0.8])) + assert not (p1 == p2) + + +# --------------------------------------------------------------------------- +# CVaR computation +# --------------------------------------------------------------------------- + + +class TestComputeCVaR: + def test_known_cvar(self): + np.random.seed(99) + n_scen = 10000 + R = np.random.randn(1, n_scen) * 0.02 + p = np.ones(n_scen) / n_scen + data = CvarData(mean=np.array([0.0]), R=R, p=p) + weights = np.array([1.0]) + + cvar_95 = compute_CVaR(data, weights, confidence_level=0.95) + assert cvar_95 > 0, "CVaR should be positive for a risky asset" + + cvar_99 = compute_CVaR(data, weights, confidence_level=0.99) + assert cvar_99 > cvar_95, "99% CVaR should exceed 95% CVaR" + + def test_zero_weight_zero_cvar(self): + R = np.array([[0.01, -0.02, 0.03], [0.02, -0.01, 0.01]]) + p = np.ones(3) / 3 + data = CvarData(mean=np.zeros(2), R=R, p=p) + weights = np.array([0.0, 0.0]) + + cvar = compute_CVaR(data, weights, confidence_level=0.95) + assert cvar == pytest.approx(0.0, abs=1e-12) + + +# --------------------------------------------------------------------------- +# Normalize weights +# --------------------------------------------------------------------------- + + +class TestNormalizeWeights: + def test_sums_to_one(self): + weights_dict = {"AAPL": 0.3, "GOOGL": 0.4, "MSFT": 0.2} + cash = 0.2 + nw, nc = normalize_portfolio_weights_to_one(weights_dict, cash) + total = sum(nw.values()) + nc + assert total == pytest.approx(1.0, abs=1e-10) + + def test_preserves_ratios(self): + weights_dict = {"A": 2.0, "B": 1.0} + nw, _ = normalize_portfolio_weights_to_one(weights_dict, 0.0) + assert nw["A"] / nw["B"] == pytest.approx(2.0, abs=1e-10) + + def test_already_normalized(self): + weights_dict = {"A": 0.6, "B": 0.3} + nw, nc = normalize_portfolio_weights_to_one(weights_dict, 0.1) + np.testing.assert_allclose(nw["A"], 0.6, atol=1e-10) + np.testing.assert_allclose(nc, 0.1, atol=1e-10) + + +# --------------------------------------------------------------------------- +# Scenario generation (generate_cvar_data) +# --------------------------------------------------------------------------- + + +class TestGenerateCvarData: + def test_gaussian_fit(self, returns_dict): + np.random.seed(7) + rd = generate_cvar_data( + returns_dict, + ScenarioGenerationSettings(num_scen=100, fit_type="gaussian"), + ) + cd = rd["cvar_data"] + assert cd.R.shape == (3, 100) + np.testing.assert_allclose(cd.p.sum(), 1.0, atol=1e-12) + + @pytest.mark.skip( + reason="no_fit path in generate_cvar_data passes a DataFrame to CvarData.R, " + "which pydantic-validates as ndarray. Pre-existing bug in src/cvar_utils.py." + ) + def test_no_fit(self, returns_dict): + rd = generate_cvar_data( + returns_dict, + ScenarioGenerationSettings(fit_type="no_fit"), + ) + cd = rd["cvar_data"] + n_obs = returns_dict["returns"].shape[0] + assert cd.R.shape == (3, n_obs) + + def test_invalid_fit_type(self): + with pytest.raises(ValidationError): + ScenarioGenerationSettings(num_scen=50, fit_type="magic") + + +# --------------------------------------------------------------------------- +# Small CVaR optimization (CVXPY + CLARABEL, CPU only) +# --------------------------------------------------------------------------- + + +class TestCVaROptimization: + def test_basic_optimization_feasible(self, returns_dict, cvar_data, cvar_params): + returns_dict["cvar_data"] = cvar_data + optimizer = CVaR(returns_dict=returns_dict, cvar_params=cvar_params) + result, portfolio = optimizer.solve_optimization_problem( + {"solver": cp.CLARABEL, "verbose": False}, print_results=False + ) + + assert portfolio is not None + w = portfolio.weights + c = portfolio.cash + + np.testing.assert_allclose(np.sum(w) + c, 1.0, atol=1e-4) + + assert np.all(w >= -1e-4), "weights should respect lower bound" + assert np.all(w <= 0.5 + 1e-4), "weights should respect upper bound" + + def test_optimization_returns_expected_keys( + self, returns_dict, cvar_data, cvar_params + ): + returns_dict["cvar_data"] = cvar_data + optimizer = CVaR(returns_dict=returns_dict, cvar_params=cvar_params) + result, _ = optimizer.solve_optimization_problem( + {"solver": cp.CLARABEL, "verbose": False}, print_results=False + ) + + for key in ["regime", "solver", "solve time", "return", "CVaR", "obj"]: + assert key in result.index, f"Missing key: {key}" + + def test_cvar_consistent_with_compute(self, returns_dict, cvar_data, cvar_params): + returns_dict["cvar_data"] = cvar_data + optimizer = CVaR(returns_dict=returns_dict, cvar_params=cvar_params) + result, portfolio = optimizer.solve_optimization_problem( + {"solver": cp.CLARABEL, "verbose": False}, print_results=False + ) + + reported_cvar = result["CVaR"] + computed_cvar = compute_CVaR( + cvar_data, portfolio.weights, cvar_params.confidence + ) + + np.testing.assert_allclose(reported_cvar, computed_cvar, atol=0.02) + + +# --------------------------------------------------------------------------- +# Efficient frontier end-to-end +# --------------------------------------------------------------------------- + + +class TestEfficientFrontier: + def test_frontier_monotonicity(self, returns_dict, cvar_data, cvar_params): + returns_dict["cvar_data"] = cvar_data + solver_settings = {"solver": cp.CLARABEL, "verbose": False} + + results_df, fig, ax = create_efficient_frontier( + returns_dict, + cvar_params, + solver_settings, + ra_num=3, + min_risk_aversion=-1, + max_risk_aversion=1, + show_plot=False, + show_discretized_portfolios=False, + benchmark_portfolios=False, + print_portfolio_results=False, + ) + + assert len(results_df) == 3 + assert "return" in results_df.columns + assert "CVaR" in results_df.columns + assert "variance" in results_df.columns + + sorted_by_risk = results_df.sort_values("CVaR") + returns_sorted = sorted_by_risk["return"].values + assert returns_sorted[-1] >= returns_sorted[0] - 1e-6, ( + "higher risk should generally yield higher return on the frontier" + ) + + for _, row in results_df.iterrows(): + assert row["variance"] >= 0, "variance must be non-negative" + assert np.isfinite(row["sharpe"]), "sharpe should be finite" + + +# --------------------------------------------------------------------------- +# Backtester with canned data +# --------------------------------------------------------------------------- + + +class TestBacktester: + @pytest.fixture() + def backtest_returns_dict(self): + np.random.seed(123) + dates = pd.date_range("2023-01-01", periods=252, freq="B") + daily_returns = np.random.randn(252, 3) * 0.01 + 0.0003 + returns_df = pd.DataFrame(daily_returns, index=dates, columns=TICKERS) + return { + "return_type": "LOG", + "returns": returns_df, + "dates": returns_df.index, + "mean": np.mean(daily_returns, axis=0), + "covariance": np.cov(daily_returns.T), + "tickers": TICKERS, + } + + def test_sharpe_ratio_positive_for_positive_drift(self, backtest_returns_dict): + portfolio = Portfolio( + name="test", + tickers=TICKERS, + weights=np.array([0.4, 0.3, 0.3]), + cash=0.0, + ) + bt = portfolio_backtester( + test_portfolio=portfolio, + returns_dict=backtest_returns_dict, + risk_free_rate=0.0, + test_method="historical", + ) + results, _ = bt.backtest_against_benchmarks(plot_returns=False) + sharpe = results.loc["test", "sharpe"] + assert sharpe > 0, "positive drift data should produce positive Sharpe" + + def test_sortino_exceeds_sharpe(self, backtest_returns_dict): + portfolio = Portfolio( + name="test", + tickers=TICKERS, + weights=np.array([0.4, 0.3, 0.3]), + cash=0.0, + ) + bt = portfolio_backtester( + test_portfolio=portfolio, + returns_dict=backtest_returns_dict, + risk_free_rate=0.0, + test_method="historical", + ) + results, _ = bt.backtest_against_benchmarks(plot_returns=False) + sharpe = results.loc["test", "sharpe"] + sortino = results.loc["test", "sortino"] + assert sortino > sharpe, ( + "Sortino should exceed Sharpe when downside vol < total vol" + ) + + def test_max_drawdown_bounded(self, backtest_returns_dict): + portfolio = Portfolio( + name="test", + tickers=TICKERS, + weights=np.array([0.4, 0.3, 0.3]), + cash=0.0, + ) + bt = portfolio_backtester( + test_portfolio=portfolio, + returns_dict=backtest_returns_dict, + risk_free_rate=0.0, + test_method="historical", + ) + results, _ = bt.backtest_against_benchmarks(plot_returns=False) + mdd = results.loc["test", "max drawdown"] + assert 0 <= mdd <= 1, "max drawdown should be between 0 and 1" + + def test_drawdown_known_series(self): + values = np.array([1.0, 1.1, 1.05, 0.9, 0.95, 1.0]) + bt = portfolio_backtester.__new__(portfolio_backtester) + mdd = bt.max_drawdown(values) + expected = (1.1 - 0.9) / 1.1 + np.testing.assert_allclose(mdd, expected, atol=1e-10) diff --git a/uv.lock b/uv.lock index ae5a859..4235b52 100644 --- a/uv.lock +++ b/uv.lock @@ -50,35 +50,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, ] -[[package]] -name = "black" -version = "25.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, - { name = "pytokens" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4b/43/20b5c90612d7bdb2bdbcceeb53d588acca3bb8f0e4c5d5c751a2c8fdd55a/black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619", size = 648393, upload-time = "2025-09-19T00:27:37.758Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/f4/7531d4a336d2d4ac6cc101662184c8e7d068b548d35d874415ed9f4116ef/black-25.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa", size = 1698727, upload-time = "2025-09-19T00:31:14.264Z" }, - { url = "https://files.pythonhosted.org/packages/28/f9/66f26bfbbf84b949cc77a41a43e138d83b109502cd9c52dfc94070ca51f2/black-25.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d", size = 1555679, upload-time = "2025-09-19T00:31:29.265Z" }, - { url = "https://files.pythonhosted.org/packages/bf/59/61475115906052f415f518a648a9ac679d7afbc8da1c16f8fdf68a8cebed/black-25.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608", size = 1617453, upload-time = "2025-09-19T00:30:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/7f/5b/20fd5c884d14550c911e4fb1b0dae00d4abb60a4f3876b449c4d3a9141d5/black-25.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f", size = 1333655, upload-time = "2025-09-19T00:30:56.715Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8e/319cfe6c82f7e2d5bfb4d3353c6cc85b523d677ff59edc61fdb9ee275234/black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0", size = 1742012, upload-time = "2025-09-19T00:33:08.678Z" }, - { url = "https://files.pythonhosted.org/packages/94/cc/f562fe5d0a40cd2a4e6ae3f685e4c36e365b1f7e494af99c26ff7f28117f/black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4", size = 1581421, upload-time = "2025-09-19T00:35:25.937Z" }, - { url = "https://files.pythonhosted.org/packages/84/67/6db6dff1ebc8965fd7661498aea0da5d7301074b85bba8606a28f47ede4d/black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e", size = 1655619, upload-time = "2025-09-19T00:30:49.241Z" }, - { url = "https://files.pythonhosted.org/packages/10/10/3faef9aa2a730306cf469d76f7f155a8cc1f66e74781298df0ba31f8b4c8/black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a", size = 1342481, upload-time = "2025-09-19T00:31:29.625Z" }, - { url = "https://files.pythonhosted.org/packages/48/99/3acfea65f5e79f45472c45f87ec13037b506522719cd9d4ac86484ff51ac/black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175", size = 1742165, upload-time = "2025-09-19T00:34:10.402Z" }, - { url = "https://files.pythonhosted.org/packages/3a/18/799285282c8236a79f25d590f0222dbd6850e14b060dfaa3e720241fd772/black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f", size = 1581259, upload-time = "2025-09-19T00:32:49.685Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ce/883ec4b6303acdeca93ee06b7622f1fa383c6b3765294824165d49b1a86b/black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831", size = 1655583, upload-time = "2025-09-19T00:30:44.505Z" }, - { url = "https://files.pythonhosted.org/packages/21/17/5c253aa80a0639ccc427a5c7144534b661505ae2b5a10b77ebe13fa25334/black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357", size = 1343428, upload-time = "2025-09-19T00:32:13.839Z" }, - { url = "https://files.pythonhosted.org/packages/1b/46/863c90dcd3f9d41b109b7f19032ae0db021f0b2a81482ba0a1e28c84de86/black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae", size = 203363, upload-time = "2025-09-19T00:27:35.724Z" }, -] - [[package]] name = "cachetools" version = "6.2.2" @@ -267,18 +238,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/e6/4eee3062088c221e5a18b054e51c69f616e0bb0dc1b0a1a5e0fe90dfa18e/clarabel-0.11.1-cp39-abi3-win_amd64.whl", hash = "sha256:557d5148a4377ae1980b65d00605ae870a8f34f95f0f6a41e04aa6d3edf67148", size = 887310, upload-time = "2025-06-11T16:49:04.277Z" }, ] -[[package]] -name = "click" -version = "8.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-7-cufolio-cuda12' and extra == 'extra-7-cufolio-cuda13')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -677,10 +636,9 @@ cuda13 = [ { name = "cuopt-cu13" }, ] dev = [ - { name = "black" }, - { name = "flake8" }, - { name = "isort" }, { name = "pre-commit" }, + { name = "pytest" }, + { name = "ruff" }, ] [package.dev-dependencies] @@ -690,18 +648,17 @@ dev = [ [package.metadata] requires-dist = [ - { name = "black", marker = "extra == 'dev'", specifier = "==25.9.0" }, { name = "cuml-cu12", marker = "extra == 'cuda12'", specifier = "==26.4.*", index = "https://pypi.nvidia.com/" }, { name = "cuml-cu13", marker = "extra == 'cuda13'", specifier = "==26.4.*", index = "https://pypi.nvidia.com/" }, { name = "cuopt-cu12", marker = "extra == 'cuda12'", specifier = "==26.4.*", index = "https://pypi.nvidia.com/" }, { name = "cuopt-cu13", marker = "extra == 'cuda13'", specifier = "==26.4.*", index = "https://pypi.nvidia.com/" }, { name = "cvxpy", specifier = ">=1.7.0" }, - { name = "flake8", marker = "extra == 'dev'" }, - { name = "isort", marker = "extra == 'dev'" }, { name = "numpy", specifier = ">=2.0.0" }, { name = "pandas", specifier = ">=2.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.3.0" }, { name = "pydantic", specifier = ">=2.12.5" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.9" }, { name = "scikit-learn", specifier = ">=1.5" }, { name = "seaborn", specifier = ">=0.13" }, { name = "yfinance", specifier = ">=0.2.0" }, @@ -1037,20 +994,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, ] -[[package]] -name = "flake8" -version = "7.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mccabe" }, - { name = "pycodestyle" }, - { name = "pyflakes" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, -] - [[package]] name = "fonttools" version = "4.60.1" @@ -1136,6 +1079,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "ipykernel" version = "7.1.0" @@ -1194,15 +1146,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] -[[package]] -name = "isort" -version = "7.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, -] - [[package]] name = "jedi" version = "0.19.2" @@ -1708,15 +1651,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] -[[package]] -name = "mccabe" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, -] - [[package]] name = "mdurl" version = "0.1.2" @@ -1732,15 +1666,6 @@ version = "0.0.12" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/17/0d/74f0293dfd7dcc3837746d0138cbedd60b31701ecc75caec7d3f281feba0/multitasking-0.0.12.tar.gz", hash = "sha256:2fba2fa8ed8c4b85e227c5dd7dc41c7d658de3b6f247927316175a57349b84d1", size = 19984, upload-time = "2025-07-20T21:27:51.636Z" } -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - [[package]] name = "nest-asyncio" version = "1.6.0" @@ -2296,15 +2221,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, ] -[[package]] -name = "pathspec" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, -] - [[package]] name = "peewee" version = "3.18.3" @@ -2419,6 +2335,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pre-commit" version = "4.3.0" @@ -2556,15 +2481,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/03/f335d6c52b4a4761bcc83499789a1e2e16d9d201a58c327a9b5cc9a41bd9/pyarrow-22.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0c34fe18094686194f204a3b1787a27456897d8a2d62caf84b61e8dfbc0252ae", size = 29185594, upload-time = "2025-10-24T10:09:53.111Z" }, ] -[[package]] -name = "pycodestyle" -version = "2.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, -] - [[package]] name = "pycparser" version = "2.23" @@ -2691,15 +2607,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" }, ] -[[package]] -name = "pyflakes" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -2779,24 +2686,31 @@ wheels = [ ] [[package]] -name = "python-dateutil" -version = "2.9.0.post0" +name = "pytest" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'extra-7-cufolio-cuda12' and extra == 'extra-7-cufolio-cuda13')" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] -name = "pytokens" -version = "0.3.0" +name = "python-dateutil" +version = "2.9.0.post0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] @@ -2986,6 +2900,31 @@ wheels = [ { url = "https://pypi.nvidia.com/rmm-cu13/rmm_cu13-26.4.0-cp311-abi3-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a0fbb6c41c5bd1d914fd8f743ac44de3b146f41ce49ed83341006bcbc914046" }, ] +[[package]] +name = "ruff" +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, +] + [[package]] name = "scikit-learn" version = "1.7.1"