From 818058b56f6613b245e332d42e22d0ccd5b80c82 Mon Sep 17 00:00:00 2001 From: Satvik-Singh192 Date: Sat, 15 Nov 2025 19:43:44 +0530 Subject: [PATCH] feat: numba --- .github/workflows/benchmark.yml | 42 +++ .../backtest/cython_opt.pyx | 55 ++++ .../backtest/numba_opt.py | 156 +++++++++++ .../backtest/profile_backtest.py | 55 ++++ .../backtest/setup_cython.py | 18 ++ .../benchmarks/bench_opt.py | 251 ++++++++++++++++++ 6 files changed, 577 insertions(+) create mode 100644 .github/workflows/benchmark.yml create mode 100644 src/quant_research_starter/backtest/cython_opt.pyx create mode 100644 src/quant_research_starter/backtest/numba_opt.py create mode 100644 src/quant_research_starter/backtest/profile_backtest.py create mode 100644 src/quant_research_starter/backtest/setup_cython.py create mode 100644 src/quant_research_starter/benchmarks/bench_opt.py diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000..144a139 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,42 @@ +name: Performance Benchmarks + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - 'src/quant_research_starter/backtest/**' + - 'src/quant_research_starter/benchmarks/**' + - '.github/workflows/benchmark.yml' + +jobs: + benchmark: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install numpy pandas numba + pip install -e . + + - name: Run benchmarks + run: | + cd src/quant_research_starter/benchmarks + python bench_opt.py > benchmark_results.txt 2>&1 || true + + - name: Upload benchmark results + uses: actions/upload-artifact@v3 + if: always() + with: + name: benchmark-results + path: src/quant_research_starter/benchmarks/benchmark_results.txt + retention-days: 30 + diff --git a/src/quant_research_starter/backtest/cython_opt.pyx b/src/quant_research_starter/backtest/cython_opt.pyx new file mode 100644 index 0000000..57a28ad --- /dev/null +++ b/src/quant_research_starter/backtest/cython_opt.pyx @@ -0,0 +1,55 @@ +"""Cython-optimized backtest operations (skeleton).""" + +cimport cython +import numpy as np +cimport numpy as np + +DTYPE = np.float64 +ctypedef np.float64_t DTYPE_t + + +@cython.boundscheck(False) +@cython.wraparound(False) +def compute_strategy_returns_cython( + np.ndarray[DTYPE_t, ndim=2] weights_prev, + np.ndarray[DTYPE_t, ndim=2] returns, + np.ndarray[DTYPE_t, ndim=1] turnover, + DTYPE_t transaction_cost +): + """Compute strategy returns with transaction costs (Cython version).""" + cdef int n_days = weights_prev.shape[0] + cdef int n_assets = weights_prev.shape[1] + cdef np.ndarray[DTYPE_t, ndim=1] strat_ret = np.zeros(n_days, dtype=DTYPE) + cdef int i, j + cdef DTYPE_t ret_sum + + for i in range(n_days): + ret_sum = 0.0 + for j in range(n_assets): + ret_sum += weights_prev[i, j] * returns[i, j] + strat_ret[i] = ret_sum - (turnover[i] * transaction_cost) + + return strat_ret + + +@cython.boundscheck(False) +@cython.wraparound(False) +def compute_turnover_cython( + np.ndarray[DTYPE_t, ndim=2] weights, + np.ndarray[DTYPE_t, ndim=2] weights_prev +): + """Compute turnover (L1 change / 2) (Cython version).""" + cdef int n_days = weights.shape[0] + cdef int n_assets = weights.shape[1] + cdef np.ndarray[DTYPE_t, ndim=1] turnover = np.zeros(n_days, dtype=DTYPE) + cdef int i, j + cdef DTYPE_t total_change + + for i in range(n_days): + total_change = 0.0 + for j in range(n_assets): + total_change += abs(weights[i, j] - weights_prev[i, j]) + turnover[i] = total_change * 0.5 + + return turnover + diff --git a/src/quant_research_starter/backtest/numba_opt.py b/src/quant_research_starter/backtest/numba_opt.py new file mode 100644 index 0000000..d0c8603 --- /dev/null +++ b/src/quant_research_starter/backtest/numba_opt.py @@ -0,0 +1,156 @@ +"""Numba-accelerated backtest operations.""" + +import numpy as np + +try: + from numba import jit, prange + + NUMBA_AVAILABLE = True +except ImportError: + NUMBA_AVAILABLE = False + + def jit(*args, **kwargs): + def decorator(func): + return func + + return decorator + + prange = range + + +@jit(nopython=True, cache=True) +def compute_strategy_returns( + weights_prev: np.ndarray, + returns: np.ndarray, + turnover: np.ndarray, + transaction_cost: float, +) -> np.ndarray: + """Compute strategy returns with transaction costs.""" + n_days, n_assets = returns.shape + strat_ret = np.zeros(n_days) + + for i in prange(n_days): + ret_sum = 0.0 + for j in prange(n_assets): + ret_sum += weights_prev[i, j] * returns[i, j] + strat_ret[i] = ret_sum - (turnover[i] * transaction_cost) + + return strat_ret + + +@jit(nopython=True, cache=True) +def compute_turnover(weights: np.ndarray, weights_prev: np.ndarray) -> np.ndarray: + """Compute turnover (L1 change / 2).""" + n_days, n_assets = weights.shape + turnover = np.zeros(n_days) + + for i in prange(n_days): + total_change = 0.0 + for j in prange(n_assets): + total_change += abs(weights[i, j] - weights_prev[i, j]) + turnover[i] = total_change * 0.5 + + return turnover + + +@jit(nopython=True, cache=True) +def compute_portfolio_value( + strategy_returns: np.ndarray, initial_capital: float +) -> np.ndarray: + """Compute cumulative portfolio value.""" + n_days = len(strategy_returns) + portfolio_value = np.zeros(n_days + 1) + portfolio_value[0] = initial_capital + + for i in prange(n_days): + portfolio_value[i + 1] = portfolio_value[i] * (1.0 + strategy_returns[i]) + + return portfolio_value[1:] + + +@jit(nopython=True, cache=True) +def compute_returns_from_prices(prices: np.ndarray) -> np.ndarray: + """Compute percentage returns from prices.""" + n_days, n_assets = prices.shape + returns = np.zeros((n_days - 1, n_assets)) + + for i in prange(n_days - 1): + for j in prange(n_assets): + if prices[i, j] > 0: + returns[i, j] = (prices[i + 1, j] - prices[i, j]) / prices[i, j] + + return returns + + +@jit(nopython=True, cache=True) +def rank_based_weights( + signals: np.ndarray, max_leverage: float, long_pct: float, short_pct: float +) -> np.ndarray: + """Compute rank-based portfolio weights.""" + n_assets = len(signals) + weights = np.zeros(n_assets) + + valid_mask = np.zeros(n_assets, dtype=np.bool_) + n_valid = 0 + for i in range(n_assets): + if not np.isnan(signals[i]): + valid_mask[i] = True + n_valid += 1 + + if n_valid == 0: + return weights + + valid_values = np.zeros(n_valid) + valid_indices = np.zeros(n_valid, dtype=np.int64) + idx = 0 + for i in range(n_assets): + if valid_mask[i]: + valid_values[idx] = signals[i] + valid_indices[idx] = i + idx += 1 + + sorted_idx = np.argsort(valid_values) + ranks = np.zeros(n_valid) + for i in range(n_valid): + ranks[sorted_idx[i]] = i + 1.0 + + sorted_ranks = np.sort(ranks) + long_idx = int(n_valid * long_pct) + short_idx = int(n_valid * short_pct) + long_threshold = sorted_ranks[long_idx] if long_idx < n_valid else sorted_ranks[-1] + short_threshold = sorted_ranks[short_idx] if short_idx >= 0 else sorted_ranks[0] + + long_count = 0 + short_count = 0 + + for idx in range(n_valid): + i = valid_indices[idx] + rank_val = ranks[idx] + if rank_val >= long_threshold: + weights[i] = 1.0 + long_count += 1 + elif rank_val <= short_threshold: + weights[i] = -1.0 + short_count += 1 + + if long_count > 0: + long_weight = 1.0 / long_count + for i in range(n_assets): + if weights[i] > 0: + weights[i] = long_weight + if short_count > 0: + short_weight = -1.0 / short_count + for i in range(n_assets): + if weights[i] < 0: + weights[i] = short_weight + + total_leverage = 0.0 + for i in range(n_assets): + total_leverage += abs(weights[i]) + + if total_leverage > max_leverage and total_leverage > 0: + scale = max_leverage / total_leverage + for i in range(n_assets): + weights[i] *= scale + + return weights diff --git a/src/quant_research_starter/backtest/profile_backtest.py b/src/quant_research_starter/backtest/profile_backtest.py new file mode 100644 index 0000000..2aa38bf --- /dev/null +++ b/src/quant_research_starter/backtest/profile_backtest.py @@ -0,0 +1,55 @@ +"""Simple profiler to identify hotspots in backtest.""" + +import cProfile +import pstats +import sys +from io import StringIO +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from quant_research_starter.backtest.vectorized import VectorizedBacktest +from quant_research_starter.data import SampleDataLoader + + +def profile_backtest(): + """Profile the backtest to identify hotspots.""" + loader = SampleDataLoader() + prices = loader.load_sample_prices() + + signals = prices.pct_change(20).fillna(0) + + profiler = cProfile.Profile() + profiler.enable() + + backtest = VectorizedBacktest( + prices=prices, + signals=signals, + initial_capital=1_000_000, + transaction_cost=0.001, + ) + backtest.run(weight_scheme="rank") + + profiler.disable() + + s = StringIO() + stats = pstats.Stats(profiler, stream=s) + stats.sort_stats("cumulative") + stats.print_stats(20) + + print("Top 20 functions by cumulative time:") + print(s.getvalue()) + + stats.sort_stats("tottime") + stats.print_stats(20) + + print("\nTop 20 functions by total time:") + s2 = StringIO() + stats = pstats.Stats(profiler, stream=s2) + stats.sort_stats("tottime") + stats.print_stats(20) + print(s2.getvalue()) + + +if __name__ == "__main__": + profile_backtest() diff --git a/src/quant_research_starter/backtest/setup_cython.py b/src/quant_research_starter/backtest/setup_cython.py new file mode 100644 index 0000000..ceec22b --- /dev/null +++ b/src/quant_research_starter/backtest/setup_cython.py @@ -0,0 +1,18 @@ +"""Setup script for Cython extensions.""" + +import numpy +from Cython.Build import cythonize +from setuptools import Extension, setup + +extensions = [ + Extension( + "cython_opt", + ["cython_opt.pyx"], + include_dirs=[numpy.get_include()], + extra_compile_args=["-O3"], + ) +] + +setup( + ext_modules=cythonize(extensions, compiler_directives={"language_level": "3"}), +) diff --git a/src/quant_research_starter/benchmarks/bench_opt.py b/src/quant_research_starter/benchmarks/bench_opt.py new file mode 100644 index 0000000..c10bd8b --- /dev/null +++ b/src/quant_research_starter/benchmarks/bench_opt.py @@ -0,0 +1,251 @@ +"""Benchmark script comparing vanilla vs Numba vs Cython implementations.""" + +import sys +import time +from pathlib import Path + +import numpy as np +import pandas as pd + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from quant_research_starter.backtest.numba_opt import ( + NUMBA_AVAILABLE, + compute_portfolio_value, + compute_strategy_returns, + compute_turnover, + rank_based_weights, +) +from quant_research_starter.backtest.vectorized import VectorizedBacktest + +try: + from quant_research_starter.backtest.cython_opt import ( + compute_strategy_returns_cython, + compute_turnover_cython, + ) + + CYTHON_AVAILABLE = True +except ImportError: + CYTHON_AVAILABLE = False + + +def generate_test_data(n_days: int, n_assets: int, seed: int = 42) -> tuple: + """Generate synthetic test data.""" + np.random.seed(seed) + dates = pd.date_range("2020-01-01", periods=n_days, freq="D") + prices = pd.DataFrame( + np.random.randn(n_days, n_assets).cumsum(axis=0) + 100, + index=dates, + columns=[f"ASSET_{i}" for i in range(n_assets)], + ) + signals = pd.DataFrame( + np.random.randn(n_days, n_assets), + index=dates, + columns=[f"ASSET_{i}" for i in range(n_assets)], + ) + return prices, signals + + +def benchmark_vanilla(prices: pd.DataFrame, signals: pd.DataFrame) -> float: + """Benchmark vanilla implementation.""" + start = time.perf_counter() + backtest = VectorizedBacktest( + prices=prices, + signals=signals, + initial_capital=1_000_000, + transaction_cost=0.001, + ) + results = backtest.run(weight_scheme="rank") + elapsed = time.perf_counter() - start + return elapsed, results + + +def benchmark_numba(prices: pd.DataFrame, signals: pd.DataFrame) -> float: + """Benchmark Numba-accelerated implementation.""" + if not NUMBA_AVAILABLE: + return None, None + + start = time.perf_counter() + + returns_df = prices.pct_change().dropna() + aligned_signals = signals.loc[returns_df.index] + + returns_arr = returns_df.values + n_days, n_assets = returns_arr.shape + + weights_list = [] + current_weights = np.zeros(n_assets, dtype=np.float64) + for date in returns_df.index: + signal_row = aligned_signals.loc[date].values.astype(np.float64) + weights = compute_rank_weights_numba(signal_row, 1.0, 0.9, 0.1) + current_weights = weights.copy() + weights_list.append(current_weights) + + weights = np.array(weights_list, dtype=np.float64) + weights_prev = np.vstack([np.zeros((1, n_assets), dtype=np.float64), weights[:-1]]) + + turnover = compute_turnover(weights, weights_prev) + strat_ret = compute_strategy_returns( + weights_prev, returns_arr.astype(np.float64), turnover, 0.001 + ) + portfolio_value = compute_portfolio_value(strat_ret, 1_000_000.0) + + elapsed = time.perf_counter() - start + results = { + "portfolio_value": pd.Series(portfolio_value, index=returns_df.index), + "returns": pd.Series( + np.diff(portfolio_value) / portfolio_value[:-1], index=returns_df.index[1:] + ), + } + return elapsed, results + + +def compute_rank_weights_numba(signals, max_leverage, long_pct, short_pct): + """Helper to compute rank weights using Numba.""" + return rank_based_weights(signals, max_leverage, long_pct, short_pct) + + +def benchmark_cython(prices: pd.DataFrame, signals: pd.DataFrame) -> float: + """Benchmark Cython-accelerated implementation.""" + if not CYTHON_AVAILABLE: + return None, None + + start = time.perf_counter() + + returns_df = prices.pct_change().dropna() + aligned_signals = signals.loc[returns_df.index] + + returns_arr = returns_df.values.astype(np.float64) + n_days, n_assets = returns_arr.shape + + weights_list = [] + current_weights = np.zeros(n_assets, dtype=np.float64) + for date in returns_df.index: + signal_row = aligned_signals.loc[date].values.astype(np.float64) + weights = compute_rank_weights_cython(signal_row, 1.0) + current_weights = weights + weights_list.append(current_weights) + + weights = np.array(weights_list, dtype=np.float64) + weights_prev = np.vstack([np.zeros((1, n_assets), dtype=np.float64), weights[:-1]]) + + turnover = compute_turnover_cython(weights, weights_prev) + strat_ret = compute_strategy_returns_cython( + weights_prev, returns_arr, turnover, 0.001 + ) + + portfolio_value = compute_portfolio_value(strat_ret, 1_000_000) + + elapsed = time.perf_counter() - start + results = { + "portfolio_value": pd.Series(portfolio_value, index=returns_df.index), + "returns": pd.Series( + np.diff(portfolio_value) / portfolio_value[:-1], index=returns_df.index[1:] + ), + } + return elapsed, results + + +def compute_rank_weights_cython(signals, max_leverage): + """Helper to compute rank weights (simplified for Cython benchmark).""" + valid_mask = ~np.isnan(signals) + valid_signals = signals[valid_mask] + if len(valid_signals) == 0: + return np.zeros_like(signals) + + ranks = np.argsort(np.argsort(valid_signals)) + 1 + long_threshold = np.percentile(ranks, 90) + short_threshold = np.percentile(ranks, 10) + + weights = np.zeros_like(signals) + valid_idx = 0 + for i in range(len(signals)): + if valid_mask[i]: + if ranks[valid_idx] >= long_threshold: + weights[i] = 1.0 + elif ranks[valid_idx] <= short_threshold: + weights[i] = -1.0 + valid_idx += 1 + + long_count = (weights > 0).sum() + short_count = (weights < 0).sum() + + if long_count > 0: + weights[weights > 0] = 1.0 / long_count + if short_count > 0: + weights[weights < 0] = -1.0 / short_count + + total_leverage = abs(weights).sum() + if total_leverage > max_leverage: + weights *= max_leverage / total_leverage + + return weights + + +def run_benchmarks(): + """Run benchmarks across different dataset sizes.""" + test_configs = [ + (252, 10, "Small"), + (1000, 50, "Medium"), + (2520, 100, "Large"), + ] + + results = [] + + print("=" * 80) + print("Backtest Performance Benchmarks") + print("=" * 80) + print(f"Numba available: {NUMBA_AVAILABLE}") + print(f"Cython available: {CYTHON_AVAILABLE}") + print() + + for n_days, n_assets, label in test_configs: + print(f"\n{label} dataset: {n_days} days, {n_assets} assets") + print("-" * 80) + + prices, signals = generate_test_data(n_days, n_assets) + + vanilla_time, vanilla_results = benchmark_vanilla(prices, signals) + print(f"Vanilla: {vanilla_time:.4f}s") + + if NUMBA_AVAILABLE: + numba_time, numba_results = benchmark_numba(prices, signals) + if numba_time: + speedup = vanilla_time / numba_time + print(f"Numba: {numba_time:.4f}s ({speedup:.2f}x speedup)") + results.append( + { + "dataset": label, + "n_days": n_days, + "n_assets": n_assets, + "vanilla": vanilla_time, + "numba": numba_time, + "numba_speedup": speedup, + } + ) + + if CYTHON_AVAILABLE: + cython_time, cython_results = benchmark_cython(prices, signals) + if cython_time: + speedup = vanilla_time / cython_time + print(f"Cython: {cython_time:.4f}s ({speedup:.2f}x speedup)") + if results: + results[-1]["cython"] = cython_time + results[-1]["cython_speedup"] = speedup + + print("\n" + "=" * 80) + print("Summary") + print("=" * 80) + for r in results: + print(f"\n{r['dataset']} ({r['n_days']} days, {r['n_assets']} assets):") + print(f" Vanilla: {r['vanilla']:.4f}s") + if "numba" in r: + print(f" Numba: {r['numba']:.4f}s ({r['numba_speedup']:.2f}x)") + if "cython" in r: + print(f" Cython: {r['cython']:.4f}s ({r['cython_speedup']:.2f}x)") + + return results + + +if __name__ == "__main__": + run_benchmarks()