Skip to content

Repository files navigation

Equity Factor Timing — Value-Spread Z-Score

Can the gap between cheap and expensive stocks be used to time when a value factor HML (High Minus Low) is worth trading? Answer: Not really, tested two different trading ways (binary in V1, continuous in V1.5), neither one worked

Problem

HML is a standard factor: long cheap (value) stocks, short expensive (growth) stocks. This project tests a specific hypothesis: when the VALUE SPREAD (how much cheaper value stocks are than growth stocks, compared to its own recent history) is unusually wide, HML should do better going in the future than when the spread is thinner.

Data

Three datasets from the Kenneth French Data Library, pulled via pandas_datareader:

Series Used for Span
5 Factors (2x3), monthly HML, Mkt-RF, RF 1963-07 till present
Portfolios formed on BE/ME, annual, value-weighted Book-to-market by decile (Lo 10 / Hi 10) 1927 till present
Momentum factor, monthly Context only, not used in the signal 1927 till present

Book-to-market is only done annualy (financial reports are once yearly), so I forward filled it to monthly.

Method

  1. VALUE SPREAD : log(Hi10 BE/ME) − log(Lo10 BE/ME), annual, forward fill to monthly
  2. Z-SCORE : rolling 20-year (240 month) window: (spread − rolling mean) / rolling std Window length and the signal rules below were committed in [PREREG.md`](PREREG.md) before any backtests were run, to stop retrying parameters until something looks good
  3. SIGNAL (V1, binary): z > 0 → long HML that month; z ≤ 0 → flat (0% that month).
  4. LOOK AHEAD BIAS: checked explicitly in src/check_lookahead.py

Since a full 20 year (240 month) window is required for a z-score to exists, the backtest only covers 1983-06 till now

Results

Full sample (1983-06 – present):

Strategy Ann. return Ann. vol Sharpe Max drawdown Hit rate
Timed HML 1.8% 9.3% -0.12 -51.3% 29.8%
Buy & hold HML 2.2% 10.9% -0.06 -57.8% 50.1%
Buy & hold market 11.6% 15.4% 0.57 -50.3% 64.8%

Sharpe delta (timed − buy & hold HML): -0.06. The signal does not improve on simply holding HML over the full sample. Chart: results/cumulative_returns.png.

Split-sample robustness (src/robustness_check.py):

Period Timed HML Sharpe Buy & hold HML Sharpe Delta
Pre-2000 -0.75 -0.33 -0.42
2000-onward +0.10 +0.07 +0.03

The bad part of the performance is not split perfectly, its mostly concentrated in the pre 2000 period. After 2000, the signal is roughly neutral, marginally positive. Chart: results/robustness_split_sample.png.

Statistical significance (src/bootstrap_ci.py): Block bootstrap (5,000 draws, 24-month blocks, so I don't break the month to month correlation) gives sharpe delta a 95% confidence interval of (-0.24, +0.15). Since zero sits inside that range, I can't call the -0.06 real. It's not that the signal loses money, it's that this sample can't tell the signal apart from doing nothing at all.

V1.5 — Continuous Position Sizing

V1's rule was binary: fully long HML or fully flat. Hence: did the binary rule tell us that the z-score had no good signal at all. I wanted to try a different dataset (Bloomberg instead of Fama French) and the position rule (continuous sizing). Since trying both at the same time doesnt make sense, if the result moved, I wouldn't know which change did it, i wanted to do the strategy change first.

So V1.5 isolates the position-sizing part only: same data, same 240-month z-score, same sample period as V1, just a different position rule. Commited in PREREG_V1.5.md before any V1.5 backtest code ran.

Rule: exposure_t = clip(z_t, -2, 2) / 2: exposure scales linearly with how stretched the value spread is, capped at full (±1) exposure past a 2 std-dev reading. Unlike V1, this goes short HML when z_t < 0, since a "continuous" rule thats only long or flat isn't really testing continuous sizing.

Full sample (1983-06 – present):

Strategy Ann. return Ann. vol Sharpe Max drawdown Hit rate
Continuous HML (V1.5) 1.6% 7.7% -0.19 -27.6% 50.3%
Buy & hold HML 2.2% 10.9% -0.06 -57.8% 50.1%
Buy & hold market 11.6% 15.4% 0.57 -50.3% 64.8%

Sharpe delta (V1.5 − buy & hold HML): -0.131, even worse than V1's -0.06. Block bootstrap 95% CI: (-0.49, +0.20), even wider than V1's, and still spans zero, so this also isn't distinguishable from noise.

Split-sample:

Period V1.5 Sharpe Buy & hold Sharpe Delta (for comparison, V1's delta)
Pre-2000 -1.13 -0.33 -0.80 -0.42
2000-onward +0.15 +0.07 +0.08 +0.03

Chart: results/v1_vs_v1.5_comparison.png.

Interpreting the result: Continuous sizing didn't save the signal it got even worse. It is visible mostly in the 2000 split: V1 went flat (not short) through the pre-2000 period where the signal was already wrong sided, while V1.5's short leg only multiplied that same period's loss (-0.80 vs -0.42). Post-2000 the two versions land in the same small, positive area. Both CIs still contain zero, so the truth is the same as V1's. No proof that timing power exists in this sample.

V1.6 & V1.7 Market Timing on Extreme Value Spread

Timing the market itself, not HML Same signal and trigger, strategies: V1.6: hold the market always, add leverage above the trigger, financed at RF. V1.7: hold the market always, flip 100% short above the trigger, no borrowing.

Strategy Sharpe delta vs. buy & hold market 95% bootstrap CI
V1.6 (lever up) -0.093 (-0.18, -0.01)
V1.7 (flip short) +0.195 (0.01, 0.44)

V1.6's CI stays below zero, V1.7's clears zero on the low end. Even though it looks like v1.7 says it found a positive result, it basically shorted the whole dotcom and 2022 crashes, otehrwise held the market and did nothing. Full comparison in notebooks/eval_v1_6_v1_7.ipynb.

Multiple testing

Missed something above: 4 variants got tested (V1, V1.5, V1.6, V1.7), each pre-registered on its own, but i never corrected for running 4 tests on basically the same idea. Fixed that in src/multiple_testing_correction.py, Bonferroni over the 4, straight from the saved bootstrap draws.

Strategy Raw 95% CI Bonferroni CI (98.75%) Still excludes zero?
V1 (-0.24, 0.15) (-0.32, 0.20) no
V1.5 (-0.49, 0.20) (-0.67, 0.26) no
V1.6 (-0.18, -0.01) (-0.20, 0.00) no
V1.7 (0.01, 0.44) (-0.01, 0.51) no

Both V1.6 and V1.7 looked significant on their own CI, not anymore once corrected for testing 4 things. So V1.7 isnt a found edge, its hypothesis generating at best, one uncorrected result out of 4 tries landing outside zero is exactly what youd expect by chance, not evidence.

What's next (V2)

V1 and V1.5 together: Two independent null results with two different sizing strategies (binary and cont) on the same data. Neither saved the hypothesis. Only thing left to test is the data source V2 takes data from Bloomberg, using V1.5's continuous rule, once Bloomberg access is available. If V2 comes back null too, a third independent null result would confirm that this specific hypothesis doesn't work.

Limitations

  • ca 43 years of monthly data (517 usable observations) is not a lot of statistical power for a subtle timing effect. The Sharpe deltas above are point estimates, not evidence of the strategy working or not.
  • Binary threshold (V1) vs continuous sizing (V1.5) tested both, neither shows timing power. V1.5's continuous rule also adds a short leg, which increases return varianc, hence why its bootstrap CI is wider than V1's.
  • Annual to monthly forward fill is an approximation. The real value spread only updates once a year. Treating it as constant for the 12 months in between is okaay, but not exact.
  • No transaction costs modelled. In v1, it was only 9 position changes over the full sample, hence this wouldn't make significant changes.
  • Split sample is not true out-of-sample testing: both halves were seen before the split was chosen

Reproducing this

python -m venv venv
venv\Scripts\activate
pip install -r requirements.txt

python src\main\fetch_data.py        # pulls and cleans the raw data
python src\main\build_signal.py      # builds the value spread and z-score
python src\main\check_lookahead.py   # verifies no look-ahead bias

python src\strat_v1\backtest.py           # V1 backtest, saves results/
python src\strat_v1\robustness_check.py   # V1 pre/post-2000 split-sample check
python src\strat_v1\bootstrap_ci.py       # V1 block-bootstrap CI on the Sharpe delta

python src\strat_v1_5\backtest.py         # V1.5 backtest (continuous exposure)
python src\strat_v1_5\robustness_check.py # V1.5 pre/post-2000 split-sample check
python src\strat_v1_5\bootstrap_ci.py     # V1.5 block-bootstrap CI

python src\strat_v1_6\backtest.py         # V1.6 backtest (leveraged market)
python src\strat_v1_6\robustness_check.py # V1.6 pre/post-2000 split-sample check
python src\strat_v1_6\bootstrap_ci.py     # V1.6 block-bootstrap CI

python src\strat_v1_7\backtest.py         # V1.7 backtest (flip short on trigger)
python src\strat_v1_7\robustness_check.py # V1.7 pre/post-2000 split-sample check
python src\strat_v1_7\bootstrap_ci.py     # V1.7 block-bootstrap CI

python src\compare_v1_v1_5.py             # side-by-side V1 vs V1.5 comparison chart

Repo structure

├── PREREG.md
├── PREREG_V1.5.md
├── PREREG_V1.6.md
├── PREREG_V1.7.md
├── data/
│   ├── raw/
│   └── processed/
├── src/
│   ├── main/                   (shared by every strategy version)
│   │   ├── fetch_data.py
│   │   ├── build_signal.py
│   │   ├── check_lookahead.py
│   │   └── metrics.py
│   ├── strat_v1/
│   │   ├── backtest.py
│   │   ├── robustness_check.py
│   │   └── bootstrap_ci.py
│   ├── strat_v1_5/
│   │   ├── backtest.py
│   │   ├── robustness_check.py
│   │   └── bootstrap_ci.py
│   ├── strat_v1_6/              (market, leveraged on high z)
│   │   ├── backtest.py
│   │   ├── robustness_check.py
│   │   └── bootstrap_ci.py
│   ├── strat_v1_7/              (market, flip short on high z)
│   │   ├── backtest.py
│   │   ├── robustness_check.py
│   │   └── bootstrap_ci.py
│   └── compare_v1_v1_5.py
├── notebooks/
│   ├── eda.ipynb
│   └── eval_v1_6_v1_7.ipynb
└── results/

About

Testing whether the value spread times the HML(High minus Low) factor. Four pre-registered variants, none survive multiple-testing correction.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages