# ETF Rotation Strategy: Backtesting Plan

Reference: `strategy_spec.py`

| Parameter | Value |
|---|---|
| **Universe** | `["SPY", "QQQ", "IWM", "EFA", "EEM"]` |
| **Momentum Lookback** | 6 months (≈126 trading days) |
| **Rebalance Frequency** | Monthly (every ≈21 trading days) |
| **Top N to Hold** | 2 strongest ETFs |
| **SMA Filter Period** | 50 trading days *(assumed; the spec does not specify this — see §7)* |
| **Transaction Cost** | 5 bps (0.0005) per trade, one way |
| **Rebalancing Method** | Cash-weighted equal split within held ETFs; turnover taxed at cost |

---

## 1. Data Loading

For each ticker `T ∈ {SPY, QQQ, IWM, EFA, EEM}`:

```python
# Step 1a — Retrieve daily adjusted close for the full date range of backtest window
daily_close[T] = fetch_adj_close(T, start_date, end_date)          # shape: (N_days,)
daily_volume[T] = fetch_volume(T, start_date, end_date)            # shape: (N_days,)

# Step 1b — Clean / align
# - Merge all tickers on the same calendar date (inner join → only dates present in ALL).
# - Drop columns with NaN; fill any gap < 5 consecutive days via forward-fill within each ticker.
aligned_dates, aligned_close = inner_join(daily_close)             # shape: (N_aligned, 5)
```

**Data source note**: `yfinance` or equivalent free API is sufficient for paper backtests. Adjusted close must include dividends so that total-return momentum is captured.

---

## 2. Monthly Slicing — Create Rebalance Windows

```python
# Step 2a — Determine calendar months (or trading-month buckets)
calendar_months = roll_forward_months(aligned_dates, freq="ME")    # calendar-month end or trading-month alias

# Step 2b — For each month m, define:
#   anchor[m] = last trading day of month m (the rebalance timestamp)
#   data_window[m] = [anchor[m] - LOOKBACK_DAYS, anchor[m]]  →  LOOKBACK_DAYS = int(6 * 21) ≈ 126
```

---

## 3. Momentum & Filter Calculation — Per Rebalance Date

For each rebalance date `t = anchor[m]`:

```python
def compute_universe_scores(t):
    scores = {}
    eligible = []

    for T, prices in aligned_close.items():
        # --- SMA filter ---
        sma_50 = rolling_mean(prices[t - 49 : t + 1], window=50)   # trailing 50-day SMA
        if prices[t] < sma_50:                                     # below SMA → excluded
            continue

        # --- Momentum (6-month return) ---
        start_idx = max(0, index_in_array(aligned_dates, t - 126))
        momentum = (prices[t] / prices[start_idx]) - 1.0           # simple log or arithmetic? → arithmetic

        scores[T] = momentum
        eligible.append(T)

    return scores, eligible
```

---

## 4. Ranking & Selection — "Strongest Few"

```python
# Step 4a — Sort eligible ETFs by momentum descending
sorted_tickers = sort_by_value(scores, descending=True)

# Step 4b — Pick the top MIN(TOP_N, len(eligible)) tickers
tickers_to_hold = sorted_tickers[: min(TOP_N, len(eligible))]   # guaranteed ≤ TOP_N (2)
```

**Edge case**: if fewer than 1 ETF passes the SMA filter, go to all-cash for that month (equivalent to holding a zero-return ETF like `SHY`).

---

## 5. Trade Simulation — Position & P&L Tracking

### 5a. Portfolio State Variables

```python
holdings    = {T: 0.0 for T in ALL_TICKERS}     # dollar weights (shares × price / NAV)
cash        = initial_capital                    # cash balance
portfolio_history = []                           # [(date, total_value, holdings_dict), ...]
trades_log      = []                             # list of trade events: {date, ticker, direction, shares, cost_bps}
```

### 5b. Rebalance Logic (executed at `t = anchor[m]`)

```python
def rebalance(t, tickers_to_hold):
    global cash, holdings

    current_value = sum(holdings[T] * close[T][index_of(t)] for T in ALL_TICKERS) + cash

    # --- Determine target weights ---
    W_target = 1.0 / len(tickers_to_hold) if tickers_to_hold else 0.0   # equal split; or 0 if cash

    # --- Close positions in ETFs we're no longer holding (full exit) ---
    for T in [t for t in ALL_TICKERS if h[T] > 1e-9 and t not in tickers_to_hold]:
        shares_sold = h[T] / close[T][index_of(t)]                       # convert weight → shares
        proceeds      = shares_sold * close[T][index_of(t)]
        tc            = proceeds * TC_BPS                                  # transaction cost (one way)
        net_proceeds  = proceeds - tc

        cash += net_proceeds
        h[T] = 0.0
        trades_log.append({date: t, ticker: T, direction: "SELL", shares: shares_sold, cost_bps: TC_BPS * 100})

    # --- Open / add to positions in tickers_to_hold ---
    target_value_each = current_value * W_target
    for T in tickers_to_hold:
        current_value_in_T = h[T] * close[T][index_of(t)]
        delta_value        = target_value_each - current_value_in_T
        if abs(delta_value) < threshold_abs_min:                          # min trade size filter
            continue

        shares_bought = delta_value / close[T][index_of(t)]
        cost_usd      = (current_value_in_T + shares_bought * close[T][index_of(t)]) * TC_BPS  # approx total notional × bps
        net_shares    = max(0, shares_bought - cost_usd / close[T][index_of(t)])               # adjust for cost

        holdings[T] += net_shares
        cash -= (net_shares * close[T][index_of(t)] + cost_usd)       # debited from cash pool

        trades_log.append({date: t, ticker: T, direction: "BUY", shares: net_shares, cost_bps: TC_BPS * 100})
```

### 5c. Daily Mark-to-Market (between rebalances)

```python
for each trading_day d in aligned_dates:
    day_value = sum(holdings[T] * close[T][index_of(d)] for T in ALL_TICKERS) + cash
    portfolio_history.append({date: d, total_value: day_value})
```

---

## 6. Daily Returns & Cumulative Returns

```python
# Portfolio value series → daily returns
portfolio_value = [record['total_value'] for record in portfolio_history]
daily_returns   = [v[i] / v[i-1] - 1.0 for i in range(1, len(portfolio_value))]

# Per-ticker benchmark (SPY buy-and-hold)
spy_daily_returns = [(spy_close[i] / spy_close[i-1]) - 1.0 for i in range(len(spy_close))]

# Cumulative returns
cum_ret_strategy = cumprod(1 + daily_returns) - 1
cum_ret_benchmark = cumprod(1 + spy_daily_returns) - 1
```

---

## 7. Metrics to Compute (delegated to `return_calculation.py`)

| Metric | Formula | Notes |
|---|---|---|
| **CAGR** | `(V_final / V_initial) ^ (252 / N_trading_days) − 1` | Annualized using 252 trading days/year |
| **Sharpe Ratio** | `mean(daily_returns) / std(daily_returns) × √252` | Uses simple daily returns; risk-free rate ≈ 0 (paper trade) |
| **Max Drawdown** | `min over t of [(V_t − V_peak(t)) / V_peak(t)]` | Peak is running max of portfolio value |
| **Win Rate** | `# positive daily returns / total days` | Simple % of up-days |
| **Turnover** | `sum(|Δweight_T|) / 2 × N_rebalances` | Annualized turnover = yearly avg monthly |
| **Benchmark Comparison** — Buy-and-hold SPY CAGR, HP Sharpe | — | Reference line |

---

## 8. Walk-Forward Sensitivity (optional but recommended)

```python
for lookback in [3, 6, 12]:          # months
    for top_n in [1, 2, 3, 5]:
        run_backtest(lookback=lookback, N=top_n)
        record("CAGR", "Sharpe", "MaxDD")
```

This produces a parameter heatmap to show robustness of the "6-month lookback / top-2" choice.

---

## Pseudo Code — Full Walkthrough (summary)

```
LOAD  {SPY, QQQ, IWM, EFA, EEM} daily adj_close → aligned matrix C[date, ticker]

FOR each calendar month m:
    t = anchor(m)                                    // last trading day of month m

    # Compute momentum & SMA filter
    FOR each T in ALL_TICKERS:
        sma_50   = mean(C[t-49:t+1, T])
        mom      = C[t, T] / C[max(t-126, 0), T] - 1
        pass_sma = (C[t, T] >= sma_50)
        IF NOT pass_sma: continue

    SORT eligible (T, mom) descending; pick top TOP_N → tickers_to_hold(TTH)

    # Rebalance portfolio
    sell_all_non_TTH()                                // realized at t prices ± cost
    buy_equal_weight(TTH)                             // same day, same prices ± cost

    FOR each trading day d in month m:
        mark_to_market(d, holdings, cash)            // record daily NAV
```

The `mark_to_month` and `compute_metrics` hooks call into `return_calculation.py` for final CAGR / Sharpe / DD.
