Por que testar e como é um teste
No trading, um bug não é uma exceção na tela; é um número errado que parece plausível: um sinal trocado num P&L, uma taxa aplicada duas vezes, um sinal que usa em silêncio o preço de amanhã. Testes são pequenos programas que verificam seu código contra respostas conhecidas, de modo que esse bug é pego na sua máquina e não no mercado. O pytest encontra funções chamadas test_*, executa cada uma e informa as em que um assert falha. Um teste se lê sem framework: prepare uma entrada, chame a função e faça assert do que espera.
# 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")Floats, erros e muitos casos
Floats são aproximados, então 0.1 + 0.2 == 0.3 é falso. Compare números com tolerância: pytest.approx(expected) aceita uma pequena diferença relativa (por padrão uma parte em um milhão, mais um piso absoluto minúsculo de 1e-12) e dá para somar uma tolerância absoluta para valores próximos de zero, onde a relativa não serve. Para verificar que uma entrada ruim é rejeitada, pytest.raises(ValueError) só passa se essa exceção ocorrer, e falha se nenhuma (ou outra) ocorrer. Para rodar um teste com muitos casos, @pytest.mark.parametrize fornece uma tabela de entradas e valores esperados, informa cada linha separadamente e faz de adicionar um caso uma mudança de uma linha.
# 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, sementes e o que testar
Uma fixture é uma preparação compartilhada que os testes pedem pelo nome: uma pequena série de preços, um gerador aleatório com semente, um arquivo temporário. Com dados aleatórios, fixe a semente (ou imprima-a) para poder reproduzir uma falha: um teste que falha uma vez em cem e não pode ser repetido é pior que nenhum. Teste o comportamento que importa e não os detalhes internos: casos conhecidos à mão, casos-limite (entrada vazia, zero, um elemento), invariantes que sempre valem (uma probabilidade está entre 0 e 1, comprar e vender ao mesmo preço perde exatamente as taxas) e um teste de regressão para cada bug corrigido. Quando se consegue enunciar uma regra válida para toda entrada, procurar um contraexemplo entre muitas entradas aleatórias é um teste barato e poderoso.
# 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- Um teste prepara a entrada, chama a função e faz
assertdo resultado; o pytest executa cada funçãotest_*e informa as falhas. - Compare floats com
pytest.approx(some tolerância absoluta perto de zero), espere erros compytest.raisese useparametrizepara tabelas de casos. - Fixe as sementes, teste casos-limite, invariantes e bugs corrigidos, procure contraexemplos e faça cada teste falhar uma vez antes de confiar nele.
A seguir: profiling de código Python, medir para onde vai o tempo antes de tentar acelerá-lo.
Lab: o que é avaliado
- Comparar floats com tolerância em
is_close25 pts - Executar uma tabela de casos com
run_cases25 pts - Esperar um erro com
raises25 pts - Caçar uma falha com
find_counterexample25 pts