Skip to content

Commit f2211fb

Browse files
committed
feat: add weekly/monthly rebalancing option (#4)
- Update _should_rebalance to respect W and M frequencies - Refactor run method to use conditional rebalancing logic - Add comprehensive tests for D/W/M rebalancing - Document rebalance_freq parameter in README - All tests pass; daily default unchanged
1 parent f981e2c commit f2211fb

File tree

3 files changed

+144
-13
lines changed

3 files changed

+144
-13
lines changed

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ QuantResearchStarter aims to provide a clean, well-documented starting point for
2222

2323
* **Data management** — download market data or generate synthetic price series for experiments.
2424
* **Factor library** — example implementations of momentum, value, size, and volatility factors.
25-
* **Vectorized backtesting engine** — supports transaction costs, slippage, and portfolio constraints.
25+
* **Vectorized backtesting engine** — supports transaction costs, slippage, portfolio constraints, and configurable rebalancing frequencies (daily, weekly, monthly).
2626
* **Risk & performance analytics** — returns, drawdowns, Sharpe, turnover, and other risk metrics.
2727
* **CLI & scripts** — small tools to generate data, compute factors, and run backtests from the terminal.
2828
* **Production-ready utilities** — type hints, tests, continuous integration, and documentation scaffolding.
@@ -93,6 +93,30 @@ results = bt.run()
9393
print(results.performance.summary())
9494
```
9595

96+
### Rebalancing Frequency
97+
98+
The backtester supports different rebalancing frequencies to match your strategy needs:
99+
100+
```python
101+
from quant_research_starter.backtest import VectorizedBacktest
102+
103+
# Daily rebalancing (default)
104+
bt_daily = VectorizedBacktest(prices, signals, rebalance_freq="D")
105+
106+
# Weekly rebalancing (reduces turnover and transaction costs)
107+
bt_weekly = VectorizedBacktest(prices, signals, rebalance_freq="W")
108+
109+
# Monthly rebalancing (lowest turnover)
110+
bt_monthly = VectorizedBacktest(prices, signals, rebalance_freq="M")
111+
112+
results = bt_monthly.run()
113+
```
114+
115+
Supported frequencies:
116+
- `"D"`: Daily rebalancing (default)
117+
- `"W"`: Weekly rebalancing (rebalances when the week changes)
118+
- `"M"`: Monthly rebalancing (rebalances when the month changes)
119+
96120
> The code above is illustrative—see `examples/` for fully working notebooks and scripts.
97121
98122
---

src/quant_research_starter/backtest/vectorized.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,26 @@ def run(self, weight_scheme: str = "rank") -> Dict:
6464
"""
6565
print("Running backtest...")
6666

67-
# Vectorized returns-based backtest with daily rebalancing
67+
# Vectorized returns-based backtest with configurable rebalancing
6868
returns_df = self.prices.pct_change().dropna()
6969
aligned_signals = self.signals.loc[returns_df.index]
7070

71-
# Compute daily target weights from signals
72-
weights = aligned_signals.apply(
73-
lambda row: self._calculate_weights(row, weight_scheme), axis=1
74-
)
75-
# Ensure full DataFrame with same columns order
76-
weights = weights.reindex(columns=self.prices.columns).fillna(0.0)
71+
# Track rebalancing
72+
prev_rebalance_date = None
73+
current_weights = pd.Series(0.0, index=self.prices.columns)
74+
75+
# Compute daily weights from signals (rebalance only on rebalance dates)
76+
weights_list = []
77+
for date in returns_df.index:
78+
if self._should_rebalance(date, prev_rebalance_date):
79+
# Rebalance: compute new target weights
80+
current_weights = self._calculate_weights(aligned_signals.loc[date], weight_scheme)
81+
prev_rebalance_date = date
82+
83+
# Append current weights (maintain between rebalances)
84+
weights_list.append(current_weights)
85+
86+
weights = pd.DataFrame(weights_list, index=returns_df.index, columns=self.prices.columns).fillna(0.0)
7787

7888
# Previous day weights for PnL calculation
7989
weights_prev = weights.shift(1).fillna(0.0)
@@ -104,11 +114,34 @@ def run(self, weight_scheme: str = "rank") -> Dict:
104114

105115
return self._generate_results()
106116

107-
def _should_rebalance(self, date: pd.Timestamp) -> bool:
108-
"""Check if we should rebalance on given date."""
109-
# Simple daily rebalancing for now
110-
# Could be extended for weekly/monthly rebalancing
111-
return True
117+
def _should_rebalance(self, date: pd.Timestamp, prev_rebalance_date: Optional[pd.Timestamp] = None) -> bool:
118+
"""Check if we should rebalance on given date.
119+
120+
Args:
121+
date: Current date to check
122+
prev_rebalance_date: Last rebalance date (None for first rebalance)
123+
124+
Returns:
125+
True if should rebalance, False otherwise
126+
"""
127+
# Always rebalance on first date
128+
if prev_rebalance_date is None:
129+
return True
130+
131+
if self.rebalance_freq == "D":
132+
# Daily rebalancing
133+
return True
134+
elif self.rebalance_freq == "W":
135+
# Weekly rebalancing - rebalance if week changed
136+
return date.isocalendar()[1] != prev_rebalance_date.isocalendar()[1] or \
137+
date.year != prev_rebalance_date.year
138+
elif self.rebalance_freq == "M":
139+
# Monthly rebalancing - rebalance if month changed
140+
return date.month != prev_rebalance_date.month or \
141+
date.year != prev_rebalance_date.year
142+
else:
143+
raise ValueError(f"Unsupported rebalance frequency: {self.rebalance_freq}. "
144+
f"Supported frequencies: 'D' (daily), 'W' (weekly), 'M' (monthly)")
112145

113146
def _calculate_weights(self, signals: pd.Series, scheme: str) -> pd.Series:
114147
"""Convert signals to portfolio weights."""

tests/test_backtest.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,77 @@ def test_transaction_costs(self, sample_data):
125125

126126
# With costs should have lower final value (or equal)
127127
assert results_with_cost["final_value"] <= results_no_cost["final_value"]
128+
129+
def test_rebalance_frequency_daily(self, sample_data):
130+
"""Test daily rebalancing (default behavior)."""
131+
prices, signals = sample_data
132+
backtest = VectorizedBacktest(prices, signals, rebalance_freq="D")
133+
results = backtest.run()
134+
135+
# Check that backtest runs successfully
136+
assert results["final_value"] > 0
137+
assert len(results["portfolio_value"]) == len(prices)
138+
139+
def test_rebalance_frequency_weekly(self, sample_data):
140+
"""Test weekly rebalancing."""
141+
prices, signals = sample_data
142+
backtest = VectorizedBacktest(prices, signals, rebalance_freq="W")
143+
results = backtest.run()
144+
145+
# Check that backtest runs successfully
146+
assert results["final_value"] > 0
147+
assert len(results["portfolio_value"]) == len(prices)
148+
149+
# Weekly rebalancing should result in fewer position changes
150+
# Count the number of times weights change
151+
positions = results["positions"]
152+
position_changes = (positions.diff().abs().sum(axis=1) > 0).sum()
153+
154+
# Should be significantly fewer than daily (100 days)
155+
# Approximately ~14 weeks in 100 days
156+
assert position_changes < len(prices) - 1
157+
158+
def test_rebalance_frequency_monthly(self, sample_data):
159+
"""Test monthly rebalancing."""
160+
prices, signals = sample_data
161+
backtest = VectorizedBacktest(prices, signals, rebalance_freq="M")
162+
results = backtest.run()
163+
164+
# Check that backtest runs successfully
165+
assert results["final_value"] > 0
166+
assert len(results["portfolio_value"]) == len(prices)
167+
168+
# Monthly rebalancing should result in fewer position changes than weekly
169+
positions = results["positions"]
170+
position_changes = (positions.diff().abs().sum(axis=1) > 0).sum()
171+
172+
# Should be significantly fewer than daily
173+
# Approximately ~3 months in 100 days
174+
assert position_changes < len(prices) - 1
175+
176+
def test_rebalance_frequency_invalid(self, sample_data):
177+
"""Test that invalid rebalance frequency raises error."""
178+
prices, signals = sample_data
179+
backtest = VectorizedBacktest(prices, signals, rebalance_freq="X")
180+
181+
with pytest.raises(ValueError, match="Unsupported rebalance frequency"):
182+
backtest.run()
183+
184+
def test_rebalance_reduces_turnover(self, sample_data):
185+
"""Test that less frequent rebalancing reduces turnover."""
186+
prices, signals = sample_data
187+
188+
# Daily rebalancing
189+
backtest_daily = VectorizedBacktest(prices, signals, rebalance_freq="D", transaction_cost=0.001)
190+
results_daily = backtest_daily.run()
191+
192+
# Monthly rebalancing
193+
backtest_monthly = VectorizedBacktest(prices, signals, rebalance_freq="M", transaction_cost=0.001)
194+
results_monthly = backtest_monthly.run()
195+
196+
# Count position changes as proxy for turnover
197+
daily_changes = (results_daily["positions"].diff().abs().sum(axis=1) > 0).sum()
198+
monthly_changes = (results_monthly["positions"].diff().abs().sum(axis=1) > 0).sum()
199+
200+
# Monthly should have fewer rebalances
201+
assert monthly_changes < daily_changes

0 commit comments

Comments
 (0)