diff --git a/.coverage b/.coverage deleted file mode 100644 index 3493a68..0000000 Binary files a/.coverage and /dev/null differ diff --git a/README.md b/README.md index c6d50e8..c6434c7 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ QuantResearchStarter aims to provide a clean, well-documented starting point for * **Data management** — download market data or generate synthetic price series for experiments. * **Factor library** — example implementations of momentum, value, size, and volatility factors. -* **Vectorized backtesting engine** — supports transaction costs, slippage, and portfolio constraints. +* **Vectorized backtesting engine** — supports transaction costs, slippage, portfolio constraints, and configurable rebalancing frequencies (daily, weekly, monthly). * **Risk & performance analytics** — returns, drawdowns, Sharpe, turnover, and other risk metrics. * **CLI & scripts** — small tools to generate data, compute factors, and run backtests from the terminal. * **Production-ready utilities** — type hints, tests, continuous integration, and documentation scaffolding. @@ -93,6 +93,30 @@ results = bt.run() print(results.performance.summary()) ``` +### Rebalancing Frequency + +The backtester supports different rebalancing frequencies to match your strategy needs: + +```python +from quant_research_starter.backtest import VectorizedBacktest + +# Daily rebalancing (default) +bt_daily = VectorizedBacktest(prices, signals, rebalance_freq="D") + +# Weekly rebalancing (reduces turnover and transaction costs) +bt_weekly = VectorizedBacktest(prices, signals, rebalance_freq="W") + +# Monthly rebalancing (lowest turnover) +bt_monthly = VectorizedBacktest(prices, signals, rebalance_freq="M") + +results = bt_monthly.run() +``` + +Supported frequencies: +- `"D"`: Daily rebalancing (default) +- `"W"`: Weekly rebalancing (rebalances when the week changes) +- `"M"`: Monthly rebalancing (rebalances when the month changes) + > The code above is illustrative—see `examples/` for fully working notebooks and scripts. --- diff --git a/src/quant_research_starter.egg-info/PKG-INFO b/src/quant_research_starter.egg-info/PKG-INFO index 41e22c9..189a179 100644 --- a/src/quant_research_starter.egg-info/PKG-INFO +++ b/src/quant_research_starter.egg-info/PKG-INFO @@ -65,7 +65,7 @@ QuantResearchStarter aims to provide a clean, well-documented starting point for * **Data management** — download market data or generate synthetic price series for experiments. * **Factor library** — example implementations of momentum, value, size, and volatility factors. -* **Vectorized backtesting engine** — supports transaction costs, slippage, and portfolio constraints. +* **Vectorized backtesting engine** — supports transaction costs, slippage, portfolio constraints, and configurable rebalancing frequencies (daily, weekly, monthly). * **Risk & performance analytics** — returns, drawdowns, Sharpe, turnover, and other risk metrics. * **CLI & scripts** — small tools to generate data, compute factors, and run backtests from the terminal. * **Production-ready utilities** — type hints, tests, continuous integration, and documentation scaffolding. @@ -136,6 +136,30 @@ results = bt.run() print(results.performance.summary()) ``` +### Rebalancing Frequency + +The backtester supports different rebalancing frequencies to match your strategy needs: + +```python +from quant_research_starter.backtest import VectorizedBacktest + +# Daily rebalancing (default) +bt_daily = VectorizedBacktest(prices, signals, rebalance_freq="D") + +# Weekly rebalancing (reduces turnover and transaction costs) +bt_weekly = VectorizedBacktest(prices, signals, rebalance_freq="W") + +# Monthly rebalancing (lowest turnover) +bt_monthly = VectorizedBacktest(prices, signals, rebalance_freq="M") + +results = bt_monthly.run() +``` + +Supported frequencies: +- `"D"`: Daily rebalancing (default) +- `"W"`: Weekly rebalancing (rebalances when the week changes) +- `"M"`: Monthly rebalancing (rebalances when the month changes) + > The code above is illustrative—see `examples/` for fully working notebooks and scripts. --- diff --git a/src/quant_research_starter/backtest/vectorized.py b/src/quant_research_starter/backtest/vectorized.py index f5ea782..4fc2bbf 100644 --- a/src/quant_research_starter/backtest/vectorized.py +++ b/src/quant_research_starter/backtest/vectorized.py @@ -64,16 +64,30 @@ def run(self, weight_scheme: str = "rank") -> Dict: """ print("Running backtest...") - # Vectorized returns-based backtest with daily rebalancing + # Vectorized returns-based backtest with configurable rebalancing returns_df = self.prices.pct_change().dropna() aligned_signals = self.signals.loc[returns_df.index] - # Compute daily target weights from signals - weights = aligned_signals.apply( - lambda row: self._calculate_weights(row, weight_scheme), axis=1 - ) - # Ensure full DataFrame with same columns order - weights = weights.reindex(columns=self.prices.columns).fillna(0.0) + # Track rebalancing + prev_rebalance_date = None + current_weights = pd.Series(0.0, index=self.prices.columns) + + # Compute daily weights from signals (rebalance only on rebalance dates) + weights_list = [] + for date in returns_df.index: + if self._should_rebalance(date, prev_rebalance_date): + # Rebalance: compute new target weights + current_weights = self._calculate_weights( + aligned_signals.loc[date], weight_scheme + ) + prev_rebalance_date = date + + # Append current weights (maintain between rebalances) + weights_list.append(current_weights) + + weights = pd.DataFrame( + weights_list, index=returns_df.index, columns=self.prices.columns + ).fillna(0.0) # Previous day weights for PnL calculation weights_prev = weights.shift(1).fillna(0.0) @@ -104,11 +118,42 @@ def run(self, weight_scheme: str = "rank") -> Dict: return self._generate_results() - def _should_rebalance(self, date: pd.Timestamp) -> bool: - """Check if we should rebalance on given date.""" - # Simple daily rebalancing for now - # Could be extended for weekly/monthly rebalancing - return True + def _should_rebalance( + self, date: pd.Timestamp, prev_rebalance_date: Optional[pd.Timestamp] = None + ) -> bool: + """Check if we should rebalance on given date. + + Args: + date: Current date to check + prev_rebalance_date: Last rebalance date (None for first rebalance) + + Returns: + True if should rebalance, False otherwise + """ + # Always rebalance on first date + if prev_rebalance_date is None: + return True + + if self.rebalance_freq == "D": + # Daily rebalancing + return True + elif self.rebalance_freq == "W": + # Weekly rebalancing - rebalance if week changed + return ( + date.isocalendar()[1] != prev_rebalance_date.isocalendar()[1] + or date.year != prev_rebalance_date.year + ) + elif self.rebalance_freq == "M": + # Monthly rebalancing - rebalance if month changed + return ( + date.month != prev_rebalance_date.month + or date.year != prev_rebalance_date.year + ) + else: + raise ValueError( + f"Unsupported rebalance frequency: {self.rebalance_freq}. " + f"Supported frequencies: 'D' (daily), 'W' (weekly), 'M' (monthly)" + ) def _calculate_weights(self, signals: pd.Series, scheme: str) -> pd.Series: """Convert signals to portfolio weights.""" diff --git a/src/quant_research_starter/factors/__init__.py b/src/quant_research_starter/factors/__init__.py index 1efc072..feea7ea 100644 --- a/src/quant_research_starter/factors/__init__.py +++ b/src/quant_research_starter/factors/__init__.py @@ -1,19 +1,19 @@ -"""Factors module public API.""" - -from .base import Factor -from .bollinger import BollingerBandsFactor -from .momentum import CrossSectionalMomentum, MomentumFactor -from .size import SizeFactor -from .value import ValueFactor -from .volatility import IdiosyncraticVolatility, VolatilityFactor - -__all__ = [ - "Factor", - "BollingerBandsFactor", - "CrossSectionalMomentum", - "MomentumFactor", - "SizeFactor", - "ValueFactor", - "IdiosyncraticVolatility", - "VolatilityFactor", -] +"""Factors module public API.""" + +from .base import Factor +from .bollinger import BollingerBandsFactor +from .momentum import CrossSectionalMomentum, MomentumFactor +from .size import SizeFactor +from .value import ValueFactor +from .volatility import IdiosyncraticVolatility, VolatilityFactor + +__all__ = [ + "Factor", + "BollingerBandsFactor", + "CrossSectionalMomentum", + "MomentumFactor", + "SizeFactor", + "ValueFactor", + "IdiosyncraticVolatility", + "VolatilityFactor", +] diff --git a/tests/test_backtest.py b/tests/test_backtest.py index f6333f4..f904a40 100644 --- a/tests/test_backtest.py +++ b/tests/test_backtest.py @@ -1,127 +1,207 @@ -"""Tests for backtesting engine.""" - -import numpy as np -import pandas as pd -import pytest - -from quant_research_starter.backtest import VectorizedBacktest - - -@pytest.fixture -def sample_data(): - """Create sample price and signal data for backtesting.""" - dates = pd.date_range("2020-01-01", periods=100, freq="D") - symbols = ["AAPL", "GOOGL", "MSFT"] - - # Generate price data - np.random.seed(42) - prices_data = {} - for symbol in symbols: - returns = np.random.normal(0.0005, 0.02, len(dates)) - prices = 100 * np.cumprod(1 + returns) - prices_data[symbol] = prices - - prices = pd.DataFrame(prices_data, index=dates) - - # Generate simple momentum signals - signals_data = {} - for symbol in symbols: - # Simple mean-reverting signal - signals_data[symbol] = np.random.normal(0, 1, len(dates)) - - signals = pd.DataFrame(signals_data, index=dates) - - return prices, signals - - -class TestVectorizedBacktest: - """Test vectorized backtesting engine.""" - - def test_initialization(self, sample_data): - """Test backtest initialization.""" - prices, signals = sample_data - backtest = VectorizedBacktest(prices, signals) - - assert backtest.prices.equals(prices) - assert backtest.signals.equals(signals) - assert backtest.initial_capital == 1_000_000 - - def test_data_alignment(self): - """Test that prices and signals are properly aligned.""" - dates = pd.date_range("2020-01-01", periods=50, freq="D") - symbols = ["A", "B"] - - # Prices with some dates - prices = pd.DataFrame(np.random.randn(50, 2), index=dates, columns=symbols) - - # Signals with different dates - signal_dates = dates[10:40] # Subset of dates - signals = pd.DataFrame( - np.random.randn(30, 2), index=signal_dates, columns=symbols - ) - - backtest = VectorizedBacktest(prices, signals) - - # Should only use common dates - common_dates = dates[10:40] - assert len(backtest.prices) == len(common_dates) - assert len(backtest.signals) == len(common_dates) - - def test_backtest_run_basic(self, sample_data): - """Test basic backtest execution.""" - prices, signals = sample_data - backtest = VectorizedBacktest(prices, signals, initial_capital=100000) - results = backtest.run() - - # Check that all expected results are present - expected_keys = [ - "portfolio_value", - "returns", - "positions", - "cash", - "trades", - "initial_capital", - "final_value", - "total_return", - ] - for key in expected_keys: - assert key in results - - # Check portfolio value series - pv = results["portfolio_value"] - assert isinstance(pv, pd.Series) - assert len(pv) == len(prices) - assert pv.iloc[0] == 100000 # Initial capital - - # Check returns series - returns = results["returns"] - assert isinstance(returns, pd.Series) - assert len(returns) == len(prices) - 1 # One less due to pct_change - - def test_different_weight_schemes(self, sample_data): - """Test backtest with different weight schemes.""" - prices, signals = sample_data - - for scheme in ["rank", "zscore", "long_short"]: - backtest = VectorizedBacktest(prices, signals) - results = backtest.run(weight_scheme=scheme) - - # Should complete without error - assert results["final_value"] > 0 - - def test_transaction_costs(self, sample_data): - """Test that transaction costs reduce returns.""" - prices, signals = sample_data - - # Backtest without costs - backtest_no_cost = VectorizedBacktest(prices, signals, transaction_cost=0.0) - results_no_cost = backtest_no_cost.run() - - # Backtest with costs - backtest_with_cost = VectorizedBacktest( - prices, signals, transaction_cost=0.01 # 1% cost - ) - results_with_cost = backtest_with_cost.run() - - # With costs should have lower final value (or equal) - assert results_with_cost["final_value"] <= results_no_cost["final_value"] +"""Tests for backtesting engine.""" + +import numpy as np +import pandas as pd +import pytest + +from quant_research_starter.backtest import VectorizedBacktest + + +@pytest.fixture +def sample_data(): + """Create sample price and signal data for backtesting.""" + dates = pd.date_range("2020-01-01", periods=100, freq="D") + symbols = ["AAPL", "GOOGL", "MSFT"] + + # Generate price data + np.random.seed(42) + prices_data = {} + for symbol in symbols: + returns = np.random.normal(0.0005, 0.02, len(dates)) + prices = 100 * np.cumprod(1 + returns) + prices_data[symbol] = prices + + prices = pd.DataFrame(prices_data, index=dates) + + # Generate simple momentum signals + signals_data = {} + for symbol in symbols: + # Simple mean-reverting signal + signals_data[symbol] = np.random.normal(0, 1, len(dates)) + + signals = pd.DataFrame(signals_data, index=dates) + + return prices, signals + + +class TestVectorizedBacktest: + """Test vectorized backtesting engine.""" + + def test_initialization(self, sample_data): + """Test backtest initialization.""" + prices, signals = sample_data + backtest = VectorizedBacktest(prices, signals) + + assert backtest.prices.equals(prices) + assert backtest.signals.equals(signals) + assert backtest.initial_capital == 1_000_000 + + def test_data_alignment(self): + """Test that prices and signals are properly aligned.""" + dates = pd.date_range("2020-01-01", periods=50, freq="D") + symbols = ["A", "B"] + + # Prices with some dates + prices = pd.DataFrame(np.random.randn(50, 2), index=dates, columns=symbols) + + # Signals with different dates + signal_dates = dates[10:40] # Subset of dates + signals = pd.DataFrame( + np.random.randn(30, 2), index=signal_dates, columns=symbols + ) + + backtest = VectorizedBacktest(prices, signals) + + # Should only use common dates + common_dates = dates[10:40] + assert len(backtest.prices) == len(common_dates) + assert len(backtest.signals) == len(common_dates) + + def test_backtest_run_basic(self, sample_data): + """Test basic backtest execution.""" + prices, signals = sample_data + backtest = VectorizedBacktest(prices, signals, initial_capital=100000) + results = backtest.run() + + # Check that all expected results are present + expected_keys = [ + "portfolio_value", + "returns", + "positions", + "cash", + "trades", + "initial_capital", + "final_value", + "total_return", + ] + for key in expected_keys: + assert key in results + + # Check portfolio value series + pv = results["portfolio_value"] + assert isinstance(pv, pd.Series) + assert len(pv) == len(prices) + assert pv.iloc[0] == 100000 # Initial capital + + # Check returns series + returns = results["returns"] + assert isinstance(returns, pd.Series) + assert len(returns) == len(prices) - 1 # One less due to pct_change + + def test_different_weight_schemes(self, sample_data): + """Test backtest with different weight schemes.""" + prices, signals = sample_data + + for scheme in ["rank", "zscore", "long_short"]: + backtest = VectorizedBacktest(prices, signals) + results = backtest.run(weight_scheme=scheme) + + # Should complete without error + assert results["final_value"] > 0 + + def test_transaction_costs(self, sample_data): + """Test that transaction costs reduce returns.""" + prices, signals = sample_data + + # Backtest without costs + backtest_no_cost = VectorizedBacktest(prices, signals, transaction_cost=0.0) + results_no_cost = backtest_no_cost.run() + + # Backtest with costs + backtest_with_cost = VectorizedBacktest( + prices, signals, transaction_cost=0.01 # 1% cost + ) + results_with_cost = backtest_with_cost.run() + + # With costs should have lower final value (or equal) + assert results_with_cost["final_value"] <= results_no_cost["final_value"] + + def test_rebalance_frequency_daily(self, sample_data): + """Test daily rebalancing (default behavior).""" + prices, signals = sample_data + backtest = VectorizedBacktest(prices, signals, rebalance_freq="D") + results = backtest.run() + + # Check that backtest runs successfully + assert results["final_value"] > 0 + assert len(results["portfolio_value"]) == len(prices) + + def test_rebalance_frequency_weekly(self, sample_data): + """Test weekly rebalancing.""" + prices, signals = sample_data + backtest = VectorizedBacktest(prices, signals, rebalance_freq="W") + results = backtest.run() + + # Check that backtest runs successfully + assert results["final_value"] > 0 + assert len(results["portfolio_value"]) == len(prices) + + # Weekly rebalancing should result in fewer position changes + # Count the number of times weights change + positions = results["positions"] + position_changes = (positions.diff().abs().sum(axis=1) > 0).sum() + + # Should be significantly fewer than daily (100 days) + # Approximately ~14 weeks in 100 days + assert position_changes < len(prices) - 1 + + def test_rebalance_frequency_monthly(self, sample_data): + """Test monthly rebalancing.""" + prices, signals = sample_data + backtest = VectorizedBacktest(prices, signals, rebalance_freq="M") + results = backtest.run() + + # Check that backtest runs successfully + assert results["final_value"] > 0 + assert len(results["portfolio_value"]) == len(prices) + + # Monthly rebalancing should result in fewer position changes than weekly + positions = results["positions"] + position_changes = (positions.diff().abs().sum(axis=1) > 0).sum() + + # Should be significantly fewer than daily + # Approximately ~3 months in 100 days + assert position_changes < len(prices) - 1 + + def test_rebalance_frequency_invalid(self, sample_data): + """Test that invalid rebalance frequency raises error.""" + prices, signals = sample_data + backtest = VectorizedBacktest(prices, signals, rebalance_freq="X") + + with pytest.raises(ValueError, match="Unsupported rebalance frequency"): + backtest.run() + + def test_rebalance_reduces_turnover(self, sample_data): + """Test that less frequent rebalancing reduces turnover.""" + prices, signals = sample_data + + # Daily rebalancing + backtest_daily = VectorizedBacktest( + prices, signals, rebalance_freq="D", transaction_cost=0.001 + ) + results_daily = backtest_daily.run() + + # Monthly rebalancing + backtest_monthly = VectorizedBacktest( + prices, signals, rebalance_freq="M", transaction_cost=0.001 + ) + results_monthly = backtest_monthly.run() + + # Count position changes as proxy for turnover + daily_changes = (results_daily["positions"].diff().abs().sum(axis=1) > 0).sum() + monthly_changes = ( + results_monthly["positions"].diff().abs().sum(axis=1) > 0 + ).sum() + + # Monthly should have fewer rebalances + assert monthly_changes < daily_changes