Files
AH-Oil-Trader/tests/test_stichprobe.py
T
Axel HocksandClaude Opus 5 3391e04f32 Bibliotheken geprueft: zwei Ertraege, keine neue Laufzeit-Abhaengigkeit
Nicht nach Katalog beantwortet, sondern gegen die dokumentierten Schwachstellen
des Projekts - und beide Kandidaten wurden GEMESSEN, nicht empfohlen.

ERTRAG 1 - BLOCK-BOOTSTRAP (core/stichprobe.block_ci95), braucht GAR KEINE
Bibliothek. Die Luecke stand seit dem 07.08. woertlich in backtest_cost_gate:
"fuer ueberlappende Beobachtungen zu optimistisch ... dafuer braeuchte es einen
Block-Bootstrap". Behelf war entkoppeln(). Auf echten Daten (pbreak_predictions):
  naiv, alle Zeilen   n=4702  Breite 0,028   <- 3x zu eng
  entkoppelt (bisher) n= 552  Breite 0,083   <- 88 % Datenverlust
  Block L=30          n=4702  Breite 0,081   <- gleiche Breite, ALLE Daten
Und das Entkoppeln kostet nicht nur Genauigkeit, es VERSCHIEBT die Schaetzung
(0,399 gegen 0,427) - es nimmt systematisch die ERSTE Beruehrung je Level+Stunde.

KALIBRIERUNG GEMESSEN (AR(1), bekanntes Mittel, 200 Laeufe, Soll 95 %):
  rho    naiv   L=10   L=30   L=60
  0,00   94 %   94 %   91 %   92 %
  0,50   76 %   92 %   91 %   91 %
  0,90   40 %   80 %   89 %   90 %
  0,97   18 %   55 %   74 %   83 %
Die naive Zeile ist der Befund: bei rho=0,97 behauptet sie 95 % Sicherheit und
liegt in 18 % der Faelle richtig. Ehrlich zu den Grenzen: bei unabhaengigen
Daten kostet der Block ~3 Punkte (dort bleibt ci95 besser), und bei rho=0,97
deckt auch er nur 83 % - eine Verbesserung, keine Garantie.

ERTRAG 2 - hypothesis (dev-only), aufgenommen NACH einer Gegenprobe. Die Suite
prueft bisher BEISPIELE, und die waehle ich selbst aus. Die teuersten Fehler
sassen auf GRENZEN: room_info musste `frei` auf dem UNGERUNDETEN Abstand
entscheiden, weil 0,5951 gerundet durchs 0,6-Gate rutscht. hypothesis findet
genau diesen Fall (dist=0.59765625) in Sekunden. Erst danach aufgenommen.
Neu tests/test_invarianten.py (4 Eigenschafts-Tests auf REINEN Funktionen), per
importorskip - die Suite laeuft auch ohne.
Beim ersten Lauf fielen zwei Tests, beide waren MEINE Fehler: cluster rundet auf
3 Stellen (Invariante zu streng) und zwei inhaltsgleiche dicts sind ==
(Reihenfolge muss ueber die Identitaet geprueft werden). Genau dafuer sind
Property-Tests da. Mutationsprobe: Rundung verbogen -> 2 Tests fallen.

BEGRUENDET ABGELEHNT: numba (Backtests 11 s/12k Bars = nicht der Engpass, und
ein Rewrite verletzt die Bit-Gleichheits-Regel) · statsmodels/arch (jetzt 15
Zeilen stdlib mit eigener Kalibrierung) · polars/pyarrow (kein DataFrame-
Workload) · loguru/structlog/rich (das Logformat ist forensisch tragend) ·
tenacity (Wiederholungen haben eigene Semantik, _mark_dead) · httpx (requests
laeuft) · pandas_ta (Indikatoren sind validiert, ein Austausch verschoebe jede
dokumentierte Zahl) · mypy (kaum Annotationen, ty deckt die Aritaet ab).

requirements.txt (Laufzeit) bleibt UNVERAENDERT - auf einer frischen Maschine
soll der Bot mit moeglichst kleiner Oberflaeche wieder laufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:57:31 +02:00

139 lines
5.7 KiB
Python

"""Tests für `core/stichprobe.py` — die Stichproben-Hygiene, ohne die am
2026-08-07 DREIMAL derselbe Fehler passiert ist (Fälligkeitsbedingung,
Divergenz-Wächter, und meine eigene Auswertung).
⚠ Wichtigster Test unten: `test_faengt_den_fehler_vom_07_08` spielt die reale
Konstellation nach — eine geclusterte Stichprobe, deren rohe Rate weit von der
entkoppelten abweicht. Ohne ihn wäre nicht belegt, dass das Modul den Fall
überhaupt fängt.
"""
import pytest
from core.stichprobe import (anteil_ci, entkoppeln, schluessel_level_stunde,
unterscheidbar)
def test_entkoppeln_behaelt_erste_je_gruppe():
zeilen = [(3600, 75.0), (3601, 75.0), (3602, 75.004), (7200, 75.0), (7201, 80.0)]
ent = entkoppeln(zeilen, schluessel_level_stunde(1, 0))
# 75,00 in Stunde 1 (auch 75,004 rundet dorthin), 75,00 in Stunde 2, 80,00
assert len(ent) == 3
assert ent[0] == (3600, 75.0) # die ERSTE bleibt, nicht die letzte
assert [z[0] for z in ent] == [3600, 7200, 7201] # Reihenfolge erhalten
def test_entkoppeln_leer_und_ohne_duplikate():
assert entkoppeln([], schluessel_level_stunde(1, 0)) == []
z = [(0, 1.0), (3600, 2.0), (7200, 3.0)]
assert entkoppeln(z, schluessel_level_stunde(1, 0)) == z
def test_feldzugriff_tupel_dict_und_row():
"""⚠ Der erste Einbau scheiterte an genau diesem Punkt: ein sqlite3-Cursor
liefert ohne `row_factory` TUPEL, nicht dicts."""
k_idx = schluessel_level_stunde(1, 0)
k_name = schluessel_level_stunde("level", "ts")
assert k_idx((3600, 75.0)) == (75.0, 1)
assert k_name({"ts": 3600, "level": 75.0}) == (75.0, 1)
def test_anteil_ci_grundfaelle():
a, lo, hi = anteil_ci([1] * 50 + [0] * 50, 100)
assert a == pytest.approx(0.5)
assert lo < 0.5 < hi
# zu wenige Punkte -> KEIN Intervall vortaeuschen
a, lo, hi = anteil_ci([1, 0, 1], 3)
assert lo is None and hi is None
# Zaehl-Variante muss dasselbe liefern wie die Listen-Variante
assert anteil_ci(50, 100)[0] == pytest.approx(anteil_ci([1] * 50 + [0] * 50, 100)[0])
def test_anteil_ci_reproduzierbar():
"""Fester `seed` — sonst schwankt eine Alarmschwelle von Lauf zu Lauf."""
assert anteil_ci([1] * 30 + [0] * 70, 100) == anteil_ci([1] * 30 + [0] * 70, 100)
def test_unterscheidbar():
y = [1] * 45 + [0] * 55 # 45 %
assert unterscheidbar(y, 100, 0.44) is False # nah dran -> nicht belegbar
assert unterscheidbar(y, 100, 0.05) is True # weit weg -> belegbar
assert unterscheidbar([1, 0], 2, 0.5) is None # zu wenig -> keine Aussage
def test_faengt_den_fehler_vom_07_08():
"""Die reale Konstellation: 10 Level, je einmal 'break' und danach viele
Wiederholungen derselben Lage. Roh sieht die Rate ganz anders aus als
entkoppelt — genau das hat am 07.08. eine Untersuchung mit elf widerlegten
Hypothesen ausgeloest."""
zeilen = []
for i in range(10):
ts = 3600 * i
zeilen.append((ts, 75.0 + i, 1)) # erster Touch: Durchbruch
zeilen.extend((ts + 60 + j, 75.0 + i, 1) for j in range(19)) # 19 Wiederholungen
for i in range(10, 30):
zeilen.append((3600 * i, 75.0 + i, 0)) # 20 einzelne Abpraller
roh = sum(z[2] for z in zeilen) / len(zeilen)
ent = entkoppeln(zeilen, schluessel_level_stunde(1, 0))
entr = sum(z[2] for z in ent) / len(ent)
assert roh > 0.75, "Aufbau falsch — roh muesste stark ueberhoeht sein"
assert entr == pytest.approx(1 / 3, abs=0.02)
# Die Verzerrung muss GROSS sein, sonst prueft der Test nichts
assert roh - entr > 0.3
# ...und die rohe Rate muss ausserhalb des Intervalls der entkoppelten liegen
assert unterscheidbar([z[2] for z in ent], len(ent), roh) is True
# ── Block-Bootstrap fuer abhaengige Beobachtungen (2026-08-20) ──────────────
import random as _rnd
from core.stichprobe import autokorrelation, block_ci95
def _ar1(n, rho, seed):
"""AR(1)-Reihe mit Mittelwert 0 - so sehen ueberlappende Beobachtungen aus."""
r = _rnd.Random(seed)
out, v = [], 0.0
for _ in range(n):
v = rho * v + r.gauss(0, 1) * (1 - rho ** 2) ** 0.5
out.append(v)
return out
def test_autokorrelation_erkennt_unabhaengig_und_geclustert():
assert abs(autokorrelation(_ar1(3000, 0.0, 1))) < 0.10
assert autokorrelation(_ar1(3000, 0.9, 2)) > 0.7
def test_block_ci_ist_BREITER_als_das_naive_bei_abhaengigen_daten():
"""Der ganze Zweck: bei Clusterung muss das Intervall breiter werden.
⚠ Genau das war die dokumentierte Luecke - `ci95` unterstellt unabhaengige
Ziehungen und ist bei ueberlappenden Beobachtungen zu optimistisch.
"""
x = _ar1(2000, 0.9, 3)
lo_b, hi_b, bl = block_ci95(x)
assert bl > 2
naiv = 2 * 1.96 * (sum((v - sum(x) / len(x)) ** 2 for v in x)
/ len(x)) ** 0.5 / len(x) ** 0.5
assert (hi_b - lo_b) > 1.5 * naiv, (
"Block-KI ist nicht breiter als das naive - die Abhaengigkeit wird "
"nicht erfasst")
def test_block_ci_deckt_den_wahren_mittelwert_meistens():
"""Kalibrierung: bei AR(1) mit Mittel 0 muss die 0 meistens drin liegen.
⚠ Bewusst 'meistens' und nicht 'immer': gemessen deckt der Block-Bootstrap
bei rho=0,9 rund 90 % (das naive Verfahren nur 40 %). Er ist eine
Verbesserung, keine Garantie - und der Test haelt genau das fest.
"""
treffer = sum(1 for s in range(40)
if (lambda r: r[0] <= 0 <= r[1])(block_ci95(_ar1(800, 0.9, s),
wiederholungen=400)))
assert treffer >= 30, f"nur {treffer}/40 Ueberdeckung"
def test_block_ci_lehnt_zu_kleine_stichproben_ab():
lo, hi, bl = block_ci95([1.0, 2.0, 3.0])
assert lo is None and hi is None and bl == 0