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
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.
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.
- VALUE SPREAD :
log(Hi10 BE/ME) − log(Lo10 BE/ME), annual, forward fill to monthly - 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 - SIGNAL (V1, binary):
z > 0→ long HML that month;z ≤ 0→ flat (0% that month). - 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
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'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.
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.
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.
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.
- 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
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├── 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/