---
name: property-based-testing
description: Write property-based tests with Hypothesis — verify code against invariants (properties that must always hold) over many auto-generated inputs, instead of a few hand-picked examples. Use when testing numerical/financial code where silent miscalculation is the real risk: factor normalization, weight construction, ranking, rolling windows, backtest accounting, look-ahead-bias checks.
---

# Property-based testing (Hypothesis)

Hypothesis (6.155) generates many inputs and checks that **properties** (invariants) hold for all of them, then **shrinks** any failure to a minimal counterexample. For quant code this catches the dangerous bug class — "silently computes the wrong number on an edge case" — that example-based tests miss.

## When to reach for it

Use property tests when you can state a rule that must ALWAYS be true, regardless of input:
- After cross-sectional standardization, each date's factor has mean ≈ 0 and std ≈ 1.
- Portfolio weights sum to 1 (long-only) or 0 (dollar-neutral); no NaN.
- A ranking is a permutation: same length, same set of ranks, monotonic in the input.
- Backtest equity = product of (1+returns); reversing/replaying is consistent.
- A function is idempotent: `f(f(x)) == f(x)` (e.g. winsorize, clip).
- No look-ahead: output at time t depends only on inputs ≤ t.

## Anatomy of a property test

```python
from hypothesis import given, strategies as st, settings, assume
import numpy as np

@given(st.lists(st.floats(allow_nan=False, allow_infinity=False,
                          min_value=-1e6, max_value=1e6),
                min_size=2, max_size=500))
def test_zscore_has_zero_mean_unit_std(xs):
    x = np.array(xs)
    # Discard *numerically* degenerate inputs, not just exactly-constant ones.
    # Near-constant large values (e.g. three copies of -385702.04) make np.std
    # return a tiny non-zero residue from catastrophic cancellation; dividing by
    # it explodes the z-score. Require the spread to be meaningful vs the scale.
    scale = max(abs(x).max(), 1.0)
    assume(x.std() > 1e-9 * scale)
    z = (x - x.mean()) / x.std()
    # Use scale-aware tolerance, never a bare ==. allclose mixes rel+abs tolerance.
    assert np.isclose(z.mean(), 0.0, atol=1e-6)
    assert np.isclose(z.std(), 1.0, rtol=1e-6)
```

> This exact example was found to FAIL under Hypothesis with the counterexample
> `[-385702.04, -385702.04, -385702.04]` — three near-equal large numbers whose
> floating-point `std()` is a spurious `~6e-11`, not 0. That is the lesson: the
> property test caught a real numerical edge case the naive `std()==0` guard missed.
> The fix is to (a) `assume` away inputs that are constant *to floating-point
> precision*, and (b) use scale-aware tolerances (`np.isclose`), never `1e-8` flat.

Run with `python -m pytest test_file.py` (pytest 9.0 is installed).

## Generating realistic quant data

```python
from hypothesis import given, strategies as st
from hypothesis.extra.numpy import arrays
import numpy as np

# a returns vector
returns = arrays(dtype=np.float64, shape=st.integers(2, 250),
                 elements=st.floats(-0.2, 0.2, allow_nan=False))

@given(returns)
def test_cum_return_monotone_in_positive(r):
    eq = np.cumprod(1 + r)
    assert eq.shape == r.shape
    assert np.all(np.isfinite(eq))
```

For DataFrames use `hypothesis.extra.pandas` (`data_frames`, `column`, `range_indexes`).

## Key building blocks

| Need | Strategy |
|------|----------|
| floats without NaN/inf | `st.floats(allow_nan=False, allow_infinity=False, min_value=.., max_value=..)` |
| price > 0 | `st.floats(min_value=1e-3, max_value=1e5)` |
| list/vector | `st.lists(elem, min_size, max_size)` |
| numpy array | `hypothesis.extra.numpy.arrays(dtype, shape, elements=..)` |
| DataFrame | `hypothesis.extra.pandas.data_frames([column(...)])` |
| pick from set | `st.sampled_from([...])` |
| composite/derived | `@st.composite` decorator |

## Tactics that make property tests pass *honestly*

- Use `assume(cond)` (or early `return`) to discard degenerate inputs (zero variance, empty cross-section) — but don't assume away so much that the test is vacuous.
- Compare against a **slow, obviously-correct reference** implementation ("oracle"), not against the function under test.
- Tolerance for float math: assert `abs(a-b) < 1e-8` or `np.allclose`, never `==`.
- `@settings(max_examples=500)` to search harder on critical invariants; `@settings(deadline=None)` if a test is legitimately slow.
- A shrunk counterexample IS the bug report — paste the minimal failing input into a regression test.

## Invariant checklist for THIS project (factor research)

- normalization: per-date mean≈0, std≈1, NaN-in → NaN-out (not 0)
- winsorize/clip: idempotent, bounds respected, order preserved
- ranking: permutation, ties handled, NaN segregated
- weights: sum to target, bounded, sum of |w| within gross limit
- IC: in [-1, 1]; sign flips when factor sign flips
- backtest: no look-ahead (shift returns vs signal correctly), equity finite & positive
- resampling/reindex: no silent forward-fill across a gap that introduces look-ahead

## Gotchas

- Hypothesis re-runs on every test session and remembers past failures in `.hypothesis/`; commit nothing there.
- Floating-point: `0.1 + 0.2 != 0.3`. Always use tolerances.
- If a test is flaky under Hypothesis, it's usually a real edge case (empty input, single element, all-equal, extreme magnitude) — investigate, don't suppress.
