← Alle Insights

Property-Based Testing: Bugs finden, die Sie nicht erwartet haben.

Example-Based-Tests prüfen, was Sie sich beim Schreiben vorgestellt haben. Property-Based-Tests prüfen das, was Ihnen nicht eingefallen ist — und genau dort sitzen die Bugs, die ein Live-System um drei Uhr morgens kippen.

Was Property-Based Testing eigentlich ist.

Klassische Unit-Tests sind beispielbasiert: Sie schreiben einen konkreten Input, einen konkreten erwarteten Output, und prüfen Gleichheit. Das ist gut für Regressions-Sicherung, aber schlecht, um neue Bugs zu finden — denn jedes Beispiel ist genau eines, das Sie sich vorstellen konnten.

Property-Based Testing dreht den Spieß um: Sie definieren Invarianten, die für jeden plausiblen Input gelten müssen, und das Framework generiert hunderte oder tausende zufällige Inputs gegen diese Invarianten. Wenn ein Input die Invariante bricht, „shrinkt" das Framework den Input automatisch auf den kleinstmöglichen Fall, der den Bug noch reproduziert.

Hypothesis als Standard-Tool in Python.

Im Python-Ökosystem ist hypothesis der De-facto-Standard. Saubere Integration mit pytest, exzellentes Shrinking, riesige Bibliothek an Strategien (Strategies) zur Input-Generierung. Ein minimales Setup:

from hypothesis import given, strategies as st

@given(
    equity=st.floats(min_value=1_000, max_value=10_000_000,
                     allow_nan=False, allow_infinity=False),
    risk_pct=st.floats(min_value=0.001, max_value=0.05,
                       allow_nan=False, allow_infinity=False),
    stop_distance=st.floats(min_value=0.01, max_value=100.0,
                            allow_nan=False, allow_infinity=False),
)
def test_position_size_never_negative(equity, risk_pct, stop_distance):
    size = position_size(equity, risk_pct, stop_distance)
    assert size >= 0
    assert size * stop_distance <= equity * risk_pct * 1.0001

Hypothesis probiert hier hunderte Kombinationen — inklusive Randwerten wie 0.001, sehr kleinen Stop-Distanzen und Extrem-Equity. Wenn die Funktion irgendwo eine negative Größe zurückgibt, sehen Sie genau die minimale Eingabe, die das auslöst.

Konkrete Properties für Trading-Code.

Position-Sizing nie negativ, nie über Risiko-Budget

Das obige Beispiel deckt zwei Invarianten ab: Größe ist nie negativ, und das erwartete Verlustpotenzial bei Stop-Out überschreitet nie das definierte Risiko-Budget (plus minimale Floating-Point-Toleranz).

Stop-Loss immer in Trade-Richtung

@given(
    entry=st.floats(min_value=1.0, max_value=100_000.0, allow_nan=False),
    atr=st.floats(min_value=0.01, max_value=5_000.0, allow_nan=False),
    side=st.sampled_from(['long', 'short']),
)
def test_stop_loss_direction(entry, atr, side):
    stop = compute_stop(entry, atr, side)
    if side == 'long':
        assert stop < entry
    else:
        assert stop > entry

Trivial in der Aussage, aber genau hier sitzt einer der häufigsten Bugs nach Refactorings: Vorzeichen-Flip in der Stop-Berechnung. Ein einziger Property-Test fängt das zuverlässig.

Buy + Sell gleicher Größe = Netto-Null

@given(
    qty=st.floats(min_value=0.0001, max_value=1_000_000.0, allow_nan=False),
    price_buy=st.floats(min_value=0.01, max_value=100_000.0, allow_nan=False),
    price_sell=st.floats(min_value=0.01, max_value=100_000.0, allow_nan=False),
)
def test_round_trip_position_zero(qty, price_buy, price_sell):
    book = PositionBook()
    book.fill('AAPL', side='buy',  qty=qty, price=price_buy)
    book.fill('AAPL', side='sell', qty=qty, price=price_sell)
    assert book.position('AAPL') == 0

Diese Invariante hat bei mir bereits zweimal Float-Akkumulationsfehler aufgedeckt, die bei beispielbasierten Tests durchgerutscht waren — schlicht, weil ich nie zufällig die exakte Konstellation getroffen hatte.

Backtest-Returns konsistent bei Daten-Reordering

Ein Backtest, der dieselben Trades chronologisch korrekt verarbeitet, sollte für denselben Datensatz unabhängig von der Eingangs-Sortierung des DataFrames dasselbe Ergebnis liefern — Sortierung passiert intern, oder sollte zumindest:

@given(seed=st.integers(min_value=0, max_value=10_000))
def test_backtest_reorder_invariance(seed):
    bars = load_sample_bars()  # deterministische Fixture
    shuffled = bars.sample(frac=1.0, random_state=seed)
    r1 = run_backtest(bars)
    r2 = run_backtest(shuffled)
    assert abs(r1.total_return - r2.total_return) < 1e-9

Bugs, die Property-Tests bei mir gefunden haben.

Strategies sinnvoll einschränken.

Property-Based Testing ist nur so gut wie die Strategy. Wenn Sie st.floats() ohne Bounds verwenden, generiert Hypothesis auch 1e308, NaN und negative Null — und Ihr Test scheitert an Inputs, die in der Realität nie vorkommen. Realistische Bounds (Preise zwischen 0,01 und 100 000, Mengen zwischen 1e-4 und 1e6, ATR zwischen 0,01 und einigen Prozent des Preises) machen die Tests deutlich nützlicher.

Für komplexe Datenstrukturen lohnt sich st.composite: Sie bauen aus Primitiv-Strategies eine fachlich konsistente Strategy (z. B. einen plausiblen OHLC-Bar, in dem low ≤ open, close ≤ high immer gilt).

Meine Praxis: kritische Berechnungen, nicht jede Funktion.

Property-Based Testing ist kein Ersatz für klassische Unit- oder Integrations-Tests — sondern ein gezielter Zusatz. Bei den Mandanten, mit denen ich arbeite, setze ich Property-Tests konsequent für drei Bereiche ein:

Für reine UI-Logik, einfache Daten-Transformationen oder I/O-Wrapper sind klassische Tests schneller geschrieben und gut genug. Die Faustregel: Wo ein Bug echtes Geld kostet, lohnt sich der Aufwand für Properties. Wo nur ein Log-Eintrag falsch wäre, reicht ein Beispiel.

Sie wollen kritische Berechnungen in Ihrer Trading-Codebasis robust absichern? Erstgespräch buchen — wir identifizieren die Hotspots und bauen einen schlanken Property-Test-Layer auf.