Pourquoi tester, et à quoi ressemble un test
En trading, un bug n'est pas une exception sur un écran ; c'est un nombre faux mais plausible : un signe inversé dans un P&L, des frais appliqués deux fois, un signal qui utilise discrètement le prix de demain. Les tests sont de petits programmes qui vérifient votre code contre des réponses connues, de sorte qu'un tel bug est attrapé sur votre machine et non sur le marché. pytest trouve les fonctions nommées test_*, exécute chacune et signale celles où un assert échoue. Un test se lit sans framework : préparez une entrée, appelez la fonction, faites un assert de ce que vous attendez.
# A test is a function with an assert; pytest runs every test_* function it finds
def net_pnl(entry, exit_price, quantity, fee):
return (exit_price - entry) * quantity - fee
def test_net_pnl_long_winner():
assert net_pnl(100.0, 102.0, 50, 1.0) == 99.0
def test_net_pnl_short_loser():
assert net_pnl(100.0, 102.0, -50, 1.0) == -101.0
for test in (test_net_pnl_long_winner, test_net_pnl_short_loser):
test() # pytest does this for you and reports failures
print(test.__name__, "passed")Flottants, erreurs et nombreux cas
Les flottants sont approximatifs, donc 0.1 + 0.2 == 0.3 est faux. Comparez les nombres avec une tolérance : pytest.approx(expected) accepte une petite différence relative (par défaut une partie par million, plus un plancher absolu minuscule de 1e-12), et l'on peut ajouter une tolérance absolue pour les valeurs proches de zéro, où une tolérance relative est inutile. Pour vérifier qu'une mauvaise entrée est rejetée, pytest.raises(ValueError) ne passe que si cette exception se produit, et échoue s'il n'y en a aucune (ou une autre). Pour exécuter un test sur de nombreux cas, @pytest.mark.parametrize fournit un tableau d'entrées et de valeurs attendues, rapporte chaque ligne séparément et fait d'un ajout de cas un changement d'une ligne.
# Floats need a tolerance: approx accepts a tiny relative difference, == does not
import pytest
print(0.1 + 0.2 == 0.3) # False
print(0.1 + 0.2 == pytest.approx(0.3)) # True
print(1e-10 == pytest.approx(0.0)) # False near zero a relative tolerance cannot help
print(1e-10 == pytest.approx(0.0, abs=1e-9)) # True a larger absolute tolerance does# raises: the test passes only if the expected exception happens
import pytest
def leverage(gross, equity):
if equity <= 0:
raise ValueError("equity must be positive")
return gross / equity
with pytest.raises(ValueError) as info:
leverage(1000.0, 0.0)
print("raised:", info.value) # raised: equity must be positive
try:
with pytest.raises(ValueError):
leverage(1000.0, 500.0) # no exception: pytest.raises itself fails
except BaseException as outcome:
print(type(outcome).__name__) # Failed# parametrize in miniature: one function, a table of cases, each row judged on its own
def fee(notional, rate=0.001, minimum=1.0):
return max(notional * rate, minimum)
cases = [
((100_000.0,), 100.0),
((500.0,), 1.0), # the minimum fee applies
((2_000.0, 0.002), 4.0),
((0.0,), 1.0),
]
for index, (args, expected) in enumerate(cases):
got = fee(*args)
print(index, "ok" if abs(got - expected) <= 1e-9 * abs(expected) else f"FAIL got {got}")Fixtures, graines et quoi tester
Une fixture est une préparation partagée que les tests demandent par leur nom : une petite série de prix, un générateur aléatoire à graine fixée, un fichier temporaire. Avec des données aléatoires, fixez la graine (ou affichez-la) pour qu'un échec soit reproductible : un test qui échoue une fois sur cent et ne peut pas être rejoué est pire que rien. Testez le comportement qui compte plutôt que les détails internes : des cas connus à la main, des cas limites (entrée vide, zéro, un seul élément), des invariants qui doivent toujours tenir (une probabilité est entre 0 et 1, acheter puis vendre au même prix perd exactement les frais), et un test de non-régression pour chaque bug corrigé. Quand on peut énoncer une règle valable pour toute entrée, chercher un contre-exemple parmi de nombreuses entrées aléatoires est un test peu coûteux et puissant.
# A seed makes a random test replayable; a property is searched for a counterexample
import random
def find_counterexample(prop, inputs):
for value in inputs:
if not prop(value):
return value
return None
rng = random.Random(42) # fixed seed: the same inputs on every run
returns = [round(rng.uniform(-0.05, 0.05), 4) for _ in range(1000)]
def compound_is_positive_growth(r):
return (1 + r) > 0 # a daily return above -100% keeps the value positive
print(find_counterexample(compound_is_positive_growth, returns)) # None the rule holds for all 1000
print(find_counterexample(lambda r: r > -0.049, returns)) # a return that breaks a wrong rule- Un test prépare l'entrée, appelle la fonction et fait un
assertdu résultat ; pytest exécute chaque fonctiontest_*et rapporte les échecs. - Comparez les flottants avec
pytest.approx(ajoutez une tolérance absolue près de zéro), attendez les erreurs avecpytest.raiseset utilisezparametrizepour les tables de cas. - Fixez les graines aléatoires, testez cas limites, invariants et bugs corrigés, cherchez des contre-exemples, et faites échouer chaque test une fois avant de lui faire confiance.
À suivre : le profilage du code Python, mesurer où passe le temps avant d'essayer de l'accélérer.
Lab : ce qui est noté
- Comparer des flottants avec une tolérance dans
is_close25 pts - Exécuter une table de cas avec
run_cases25 pts - S'attendre à une erreur avec
raises25 pts - Chasser une défaillance avec
find_counterexample25 pts