|
| 1 | +# Position-Based Backtester - Final Decisions |
| 2 | + |
| 3 | +**Date**: 2025-10-23 |
| 4 | +**Status**: Decisions finalized, ready for implementation |
| 5 | + |
| 6 | +--- |
| 7 | + |
| 8 | +## Design Decisions (Finalized) |
| 9 | + |
| 10 | +### Decision 1: Strategy Position Access |
| 11 | +**Choice**: Strategies can access positions via **alternative data loader** (existing pattern) |
| 12 | + |
| 13 | +**Implication**: |
| 14 | +- **NO NEW `PositionStrategyBase` CLASS NEEDED** |
| 15 | +- Use existing `StrategyBase` |
| 16 | +- Strategies output same `Dict[str, float]` format |
| 17 | +- Backtester interprets values as **shares** instead of **weights** |
| 18 | +- Strategies wanting position info use alternative data (e.g., `daily_data['POSITIONS:AAPL']`) |
| 19 | + |
| 20 | +**Example**: |
| 21 | +```python |
| 22 | +class MyStrategy(StrategyBase): # Use existing base class! |
| 23 | + def step(self, current_date, daily_data): |
| 24 | + # Access position via alternative data if needed |
| 25 | + # current_pos = daily_data.get('POSITIONS:AAPL', {}).get('shares', 0) |
| 26 | + |
| 27 | + # Return share quantities (not weights!) |
| 28 | + return {'AAPL': 10, 'MSFT': -5} # Buy 10 AAPL, sell 5 MSFT |
| 29 | +``` |
| 30 | + |
| 31 | +--- |
| 32 | + |
| 33 | +### Decision 2: Execution Price Model |
| 34 | +**Choice**: Execute on **close** (same as weights backtester) |
| 35 | + |
| 36 | +**Note**: Check if original backtester has open/close option. If it does, replicate that. |
| 37 | + |
| 38 | +**Action Item**: Verify if `Backtester` supports execution price configuration |
| 39 | +- [ ] Check current `Backtester` for execution price parameter |
| 40 | +- [ ] If exists, replicate same parameter in `PositionBacktester` |
| 41 | +- [ ] If not, default to close only for MVP |
| 42 | + |
| 43 | +--- |
| 44 | + |
| 45 | +### Decision 3: Cost Basis Method |
| 46 | +**Choice**: **Defer to external analyzer** - not calculated in core backtester |
| 47 | + |
| 48 | +**Implication**: |
| 49 | +- Core backtester does NOT track cost basis |
| 50 | +- Core backtester does NOT calculate realized vs unrealized P&L |
| 51 | +- Backtester only tracks: positions, actions, prices, total value |
| 52 | +- **Analyzer** can calculate cost basis, realized P&L post-hoc using any method (average, FIFO, LIFO, etc.) |
| 53 | + |
| 54 | +**Simplification**: This significantly reduces backtester complexity! |
| 55 | + |
| 56 | +--- |
| 57 | + |
| 58 | +### Decision 4: Short Selling |
| 59 | +**Choice**: **Yes** - allow negative positions |
| 60 | + |
| 61 | +**Implication**: No validation preventing negative positions |
| 62 | + |
| 63 | +--- |
| 64 | + |
| 65 | +### Decision 5: Fractional Shares |
| 66 | +**Choice**: **Yes** - allow fractional shares |
| 67 | + |
| 68 | +**Implication**: Use `float` for position quantities, not `int` |
| 69 | + |
| 70 | +--- |
| 71 | + |
| 72 | +### Decision 6: Benchmark Comparison |
| 73 | +**Choice**: Follow existing backtester pattern (extensible functions) |
| 74 | + |
| 75 | +**Default Approach**: |
| 76 | +- Benchmark holds position size of 1 per ticker, OR |
| 77 | +- Benchmark holds average notional position of strategy over time |
| 78 | + |
| 79 | +**Implication**: Same benchmark architecture as weights backtester |
| 80 | +- Support benchmark functions |
| 81 | +- Allow custom callables |
| 82 | +- Defer sophisticated comparison to analyzers |
| 83 | + |
| 84 | +--- |
| 85 | + |
| 86 | +### Decision 7: Initial Positions |
| 87 | +**Choice**: **No** - always start with empty portfolio |
| 88 | + |
| 89 | +**Implication**: All backtests start with zero positions |
| 90 | + |
| 91 | +--- |
| 92 | + |
| 93 | +### Decision 8: Universe Exit Handling |
| 94 | +**Choice**: **Follow weights backtester behavior** |
| 95 | + |
| 96 | +**Action Item**: Verify what weights backtester does |
| 97 | +- [ ] Check `Backtester.run_backtest()` for universe exit handling |
| 98 | +- [ ] Replicate same logic in `PositionBacktester` |
| 99 | + |
| 100 | +**Hypothesis**: Likely force-sells positions when ticker exits universe |
| 101 | + |
| 102 | +--- |
| 103 | + |
| 104 | +### Decision 9: File Structure |
| 105 | +**Clarification**: No new strategy files needed (using existing `StrategyBase`) |
| 106 | + |
| 107 | +**New Files Required**: |
| 108 | +``` |
| 109 | +portwine/backtester/position_core.py # PositionBacktester only |
| 110 | +tests/test_position_backtester.py # Tests |
| 111 | +``` |
| 112 | + |
| 113 | +**No new files in**: |
| 114 | +- `strategies/` - Use existing `StrategyBase` |
| 115 | +- `analyzers/` - Defer to later phase |
| 116 | + |
| 117 | +--- |
| 118 | + |
| 119 | +### Decision 10: Data Requirements |
| 120 | +**Choice**: Use **exact same data interface** as weights backtester |
| 121 | + |
| 122 | +**Implication**: |
| 123 | +- Same `DataInterface`, `MultiDataInterface`, `RestrictedDataInterface` |
| 124 | +- Same price access patterns |
| 125 | +- If weights backtester uses close only, position backtester uses close only |
| 126 | +- If weights backtester supports OHLCV, position backtester gets it automatically |
| 127 | + |
| 128 | +--- |
| 129 | + |
| 130 | +### Decision 11: Testing Approach |
| 131 | +**Choice**: **Hybrid** (write feature, test it, build next feature on top) |
| 132 | + |
| 133 | +--- |
| 134 | + |
| 135 | +### Decision 12: MVP Feature Set |
| 136 | +**Choice**: Start with **minimal working backtester**, iterate rapidly |
| 137 | + |
| 138 | +**MVP Definition**: |
| 139 | +- `PositionBacktester` class with `run_backtest()` method |
| 140 | +- Interprets strategy output as share quantities |
| 141 | +- Tracks positions over time |
| 142 | +- Tracks actions over time |
| 143 | +- Tracks prices over time |
| 144 | +- Calculates portfolio value (sum of position × price) |
| 145 | +- Returns results dict with DataFrames |
| 146 | +- NO cost basis tracking (defer to analyzer) |
| 147 | +- NO realized/unrealized split (defer to analyzer) |
| 148 | +- NO benchmarks initially (add if easy) |
| 149 | + |
| 150 | +--- |
| 151 | + |
| 152 | +### Decision 13: Numba Optimization Timing |
| 153 | +**Choice**: **Python first**, optimize later |
| 154 | + |
| 155 | +**Implication**: Get correctness first, add Numba JIT after tests pass |
| 156 | + |
| 157 | +--- |
| 158 | + |
| 159 | +## Revised Architecture |
| 160 | + |
| 161 | +### Core Insight: Much Simpler Than Original Design! |
| 162 | + |
| 163 | +**Original Plan** (too complex): |
| 164 | +- New `PositionStrategyBase` class |
| 165 | +- Complex cost basis tracking in backtester |
| 166 | +- Realized vs unrealized P&L split |
| 167 | +- Position injection into strategy |
| 168 | + |
| 169 | +**Revised Plan** (your approach): |
| 170 | +- Use existing `StrategyBase` ✓ |
| 171 | +- No cost basis in backtester (defer to analyzer) ✓ |
| 172 | +- Simple portfolio value calculation ✓ |
| 173 | +- Position access via alternative data (existing pattern) ✓ |
| 174 | + |
| 175 | +--- |
| 176 | + |
| 177 | +## Simplified Position Backtester Architecture |
| 178 | + |
| 179 | +### Input |
| 180 | +```python |
| 181 | +strategy = StrategyBase(['AAPL', 'MSFT']) # Existing class! |
| 182 | + |
| 183 | +# Strategy returns shares (backtester interprets as quantities) |
| 184 | +def step(self, current_date, daily_data): |
| 185 | + return {'AAPL': 10} # Buy 10 shares |
| 186 | +``` |
| 187 | + |
| 188 | +### Processing |
| 189 | +```python |
| 190 | +# PositionBacktester.run_backtest() |
| 191 | +for date in datetime_index: |
| 192 | + actions = strategy.step(date, data) # {'AAPL': 10} |
| 193 | + |
| 194 | + # Update positions |
| 195 | + for ticker, action in actions.items(): |
| 196 | + positions[ticker] += action |
| 197 | + |
| 198 | + # Record state |
| 199 | + record_positions(date, positions) |
| 200 | + record_actions(date, actions) |
| 201 | + record_prices(date, current_prices) |
| 202 | + |
| 203 | + # Calculate portfolio value |
| 204 | + portfolio_value = sum(positions[t] * prices[t] for t in tickers) |
| 205 | +``` |
| 206 | + |
| 207 | +### Output |
| 208 | +```python |
| 209 | +results = { |
| 210 | + 'positions_df': DataFrame, # (days × tickers) share quantities |
| 211 | + 'actions_df': DataFrame, # (days × tickers) buy/sell quantities |
| 212 | + 'prices_df': DataFrame, # (days × tickers) execution prices |
| 213 | + 'portfolio_value': Series, # (days,) total portfolio value |
| 214 | + 'benchmark_returns': Series # (days,) if benchmark provided |
| 215 | +} |
| 216 | +``` |
| 217 | + |
| 218 | +### Post-Processing (Analyzers) |
| 219 | +```python |
| 220 | +# Analyzer calculates cost basis, realized P&L, etc. |
| 221 | +# Using whatever method desired (average, FIFO, LIFO) |
| 222 | + |
| 223 | +analyzer = PositionCostBasisAnalyzer(method='average') |
| 224 | +pnl = analyzer.analyze(results) |
| 225 | +# Returns: realized_pnl, unrealized_pnl, cost_basis_history, etc. |
| 226 | +``` |
| 227 | + |
| 228 | +--- |
| 229 | + |
| 230 | +## Simplified Output API |
| 231 | + |
| 232 | +### Minimal Output (MVP) |
| 233 | +```python |
| 234 | +{ |
| 235 | + 'positions_df': pd.DataFrame, # Share positions over time |
| 236 | + 'actions_df': pd.DataFrame, # Buy/sell actions over time |
| 237 | + 'prices_df': pd.DataFrame, # Execution prices |
| 238 | + 'portfolio_value': pd.Series, # Total value (Σ position × price) |
| 239 | +} |
| 240 | +``` |
| 241 | + |
| 242 | +### With Benchmark (MVP+) |
| 243 | +```python |
| 244 | +{ |
| 245 | + # ... everything above, plus: |
| 246 | + 'benchmark_returns': pd.Series, # Or benchmark_portfolio_value? |
| 247 | +} |
| 248 | +``` |
| 249 | + |
| 250 | +**Note**: No realized/unrealized split, no cost basis - that's analyzer territory! |
| 251 | + |
| 252 | +--- |
| 253 | + |
| 254 | +## Key Questions to Verify Before Implementation |
| 255 | + |
| 256 | +### Question 1: Weights Backtester Execution Price |
| 257 | +**Need to check**: Does current `Backtester` have execution price configuration? |
| 258 | + |
| 259 | +**File to check**: `portwine/backtester/core.py` - look for parameters in `run_backtest()` |
| 260 | + |
| 261 | +**Expected**: Likely just uses close, maybe has option for next open |
| 262 | + |
| 263 | +--- |
| 264 | + |
| 265 | +### Question 2: Weights Backtester Universe Exit |
| 266 | +**Need to check**: What happens when ticker exits universe with open position? |
| 267 | + |
| 268 | +**File to check**: `portwine/backtester/core.py` - look in main loop for universe changes |
| 269 | + |
| 270 | +**Expected**: Likely force-liquidates or raises warning |
| 271 | + |
| 272 | +--- |
| 273 | + |
| 274 | +### Question 3: Strategy Output Format |
| 275 | +**Need to verify**: Current strategies return `Dict[str, float]` for weights |
| 276 | + |
| 277 | +**Confirmation**: Position strategies will return same format, just interpreted as shares |
| 278 | + |
| 279 | +**Validation**: Should we add any new validation besides "ticker in universe"? |
| 280 | + |
| 281 | +--- |
| 282 | + |
| 283 | +## Implementation Simplifications |
| 284 | + |
| 285 | +### Removed from Original Plan: |
| 286 | +1. ❌ `PositionStrategyBase` class (use existing `StrategyBase`) |
| 287 | +2. ❌ Cost basis tracking in backtester (defer to analyzer) |
| 288 | +3. ❌ Realized vs unrealized P&L calculation (defer to analyzer) |
| 289 | +4. ❌ Position injection into strategy (use alternative data) |
| 290 | +5. ❌ Complex benchmark conversion (use simple approach) |
| 291 | +6. ❌ Initial positions support (not needed) |
| 292 | +7. ❌ New data interfaces (use existing) |
| 293 | + |
| 294 | +### Kept in Plan: |
| 295 | +1. ✅ `PositionBacktester` class (new file) |
| 296 | +2. ✅ Position tracking (core feature) |
| 297 | +3. ✅ Action tracking (core feature) |
| 298 | +4. ✅ Price tracking (core feature) |
| 299 | +5. ✅ Portfolio value calculation (simple sum) |
| 300 | +6. ✅ Results dict with DataFrames (match existing API) |
| 301 | +7. ✅ Benchmark support (same extensible pattern) |
| 302 | + |
| 303 | +--- |
| 304 | + |
| 305 | +## Estimated Complexity Reduction |
| 306 | + |
| 307 | +**Original Estimate**: 5-6 weeks, 2000+ lines of code |
| 308 | + |
| 309 | +**Revised Estimate**: 2-3 weeks, 500-800 lines of code |
| 310 | + |
| 311 | +**Reason**: |
| 312 | +- No new strategy base class (~200 lines saved) |
| 313 | +- No cost basis tracking (~300 lines saved) |
| 314 | +- No realized/unrealized split (~200 lines saved) |
| 315 | +- Reuse existing data interfaces (~400 lines saved) |
| 316 | +- Simpler validation (~100 lines saved) |
| 317 | + |
| 318 | +--- |
| 319 | + |
| 320 | +## Next Steps |
| 321 | + |
| 322 | +1. ✅ Decisions finalized |
| 323 | +2. ⏳ Verify weights backtester behavior (execution price, universe exit) |
| 324 | +3. ⏳ Create iteration-by-iteration implementation plan |
| 325 | +4. ⏳ Begin implementation |
| 326 | + |
| 327 | +--- |
| 328 | + |
| 329 | +## Implementation Philosophy |
| 330 | + |
| 331 | +**Your approach** (lean, iterative): |
| 332 | +- Start with simplest possible working version |
| 333 | +- Test immediately |
| 334 | +- Add one feature at a time |
| 335 | +- Build on tested foundation |
| 336 | +- Defer complexity to analyzers |
| 337 | + |
| 338 | +**This aligns perfectly with**: |
| 339 | +- Unix philosophy (do one thing well) |
| 340 | +- Separation of concerns (backtester ≠ analyzer) |
| 341 | +- Existing portwine architecture (analyzers consume backtest results) |
| 342 | + |
| 343 | +--- |
| 344 | + |
| 345 | +## Summary |
| 346 | + |
| 347 | +The position backtester is **much simpler** than originally designed: |
| 348 | + |
| 349 | +1. **No new strategy class** - use existing `StrategyBase` |
| 350 | +2. **No cost basis** - defer to analyzers |
| 351 | +3. **Simple position tracking** - just accumulate actions |
| 352 | +4. **Simple portfolio value** - just sum position × price |
| 353 | +5. **Same data interfaces** - reuse everything |
| 354 | +6. **Same patterns** - execution, benchmarks, validation |
| 355 | + |
| 356 | +This is a **weekend project**, not a month-long effort! |
0 commit comments