なぜテストするのか、テストとは何か
トレーディングのバグは画面上の例外ではなく、もっともらしく見える間違った数字です。損益の符号が反転している、手数料が二重にかかる、明日の価格をこっそり使うシグナルなどです。テストは、答えが分かっている値と照らしてコードを確認する小さなプログラムで、こうしたバグを市場ではなく手元で見つけます。pytest は test_* という名前の関数を見つけ、それぞれ実行し、assert が失敗したものを報告します。テストはフレームワークなしでも読めます。入力を用意し、関数を呼び、期待を assert するだけです。
# 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")浮動小数、エラー、多数のケース
浮動小数は近似値なので、0.1 + 0.2 == 0.3 は偽です。数値は許容誤差付きで比べます。pytest.approx(expected) は小さな相対差(既定では100万分の1。加えて 1e-12 というごく小さな絶対値の下限があります)を許し、相対誤差が役に立たない0付近の値には絶対許容誤差を加えられます。不正な入力が拒否されることを確かめるには、pytest.raises(ValueError) を使います。その例外が起きたときだけ成功し、起きない(または別の例外)なら失敗します。1つのテストを多数のケースで走らせるには @pytest.mark.parametrize で入力と期待値の表を渡します。各行が別々に報告され、ケースの追加は1行の変更で済みます。
# 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}")フィクスチャ、シード、何をテストするか
フィクスチャは、テストが名前で要求する共通の準備です。小さな価格系列、シードを固定した乱数生成器、一時ファイルなどです。乱数データではシードを固定する(または表示する)ことで、失敗を再現できます。100回に1回失敗して再現できないテストは、ないほうがましです。内部の細部ではなく、気にする動作をテストします。手で確かめた既知のケース、境界(空の入力、ゼロ、要素1つ)、常に成り立つべき不変条件(確率は0から1の間、同じ価格で買って売れば手数料分だけ損をする)、そして直したバグごとの回帰テストです。あらゆる入力で成り立つ規則を述べられるなら、多数のランダム入力から反例を探すのは、安くて強力なテストです。
# 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- テストは入力を用意し、関数を呼び、結果を
assertする。pytest はすべてのtest_*関数を実行して失敗を報告する - 浮動小数は
pytest.approxで比べ(0付近では絶対許容誤差を加える)、エラーはpytest.raisesで期待し、ケース表にはparametrizeを使う - 乱数シードを固定し、境界・不変条件・直したバグをテストし、反例を探し、信頼する前に各テストを一度失敗させる
次は Python コードのプロファイリングです。高速化を試みる前に、時間がどこに使われているかを測ります。
ラボ:採点される内容
is_closeで許容誤差付きに浮動小数を比べる25点run_casesでケース表を実行する25点raisesでエラーを期待する25点find_counterexampleで反例を探す25点