SPB Git

Repository Public

test_metrics.py 6.3 KB 117 lines python

1"""Metrics against small examples computed by hand."""2from __future__ import annotations34import math56import numpy as np7import pandas as pd8import pytest910from conftest import frame11from spq.engine import TRADE_COLUMNS, BacktestConfig, BacktestResult, buy_and_hold, equal_weight12from spq.metrics import compute_metrics, max_drawdown, return_distribution, sharpe_ratio, sortino_ratio131415def result(equity, interval="1d", trades=(), open_positions=(), exposure=None):16    idx = pd.date_range("2024-01-01", periods=len(equity), freq={"1d": "1D", "1h": "1h"}[interval], tz="UTC")17    eq = pd.Series(equity, index=idx, dtype="float64")18    exp = pd.Series(exposure if exposure is not None else np.zeros(len(eq)), index=idx)19    return BacktestResult(symbols=["X"], config=BacktestConfig(interval), equity=eq, cash=eq * 0, exposure=exp,20                          decisions=pd.DataFrame(), trades=pd.DataFrame(list(trades), columns=TRADE_COLUMNS),21                          open_positions=list(open_positions))222324def test_return_metrics_by_hand():25    # equity 100 -> 110 -> 99 -> 108.9 : returns +10 %, -10 %, +10 %26    m = compute_metrics(result([100.0, 110.0, 99.0, 108.9], "1d", exposure=[0, 1, 1, 0.5]))27    mean = 0.1 / 328    std = math.sqrt(((0.1 - mean) ** 2 * 2 + (-0.1 - mean) ** 2) / 2)      # ddof = 1 -> 0.1154729    downside = math.sqrt((0.1 ** 2) / 3)                                      # only the -10 % bar -> 0.05773530    assert m["total_return"] == pytest.approx(0.089)31    assert m["cagr"] == pytest.approx(1.089 ** (365 / 3) - 1)32    assert m["volatility"] == pytest.approx(std * math.sqrt(365))33    assert std == pytest.approx(0.1154700538)34    assert m["sharpe"] == pytest.approx(mean / std * math.sqrt(365))35    assert m["sharpe"] == pytest.approx(0.2886751346 * math.sqrt(365))36    assert m["sortino"] == pytest.approx(mean / downside * math.sqrt(365))37    assert m["max_drawdown"] == pytest.approx(0.1)                          # 110 -> 9938    assert m["calmar"] == pytest.approx(m["cagr"] / 0.1)39    assert m["exposure"] == pytest.approx(0.625)40    assert m["trades"] == 0 and m["win_rate"] is None and m["profit_factor"] is None41    assert m["fees_paid"] == 0 and m["turnover"] == 042    assert m["returns"]["n"] == 3 and m["returns"]["positive_fraction"] == pytest.approx(2 / 3)43    assert m["returns"]["min"] == pytest.approx(-0.1) and m["returns"]["max"] == pytest.approx(0.1)444546@pytest.mark.parametrize("interval,bars", [("1h", 8760), ("1d", 365)])47def test_annualisation_follows_the_interval(interval, bars):48    eq = [100.0, 101.0, 100.5, 102.0, 101.0]49    r = np.diff(eq) / np.array(eq[:-1])50    m = compute_metrics(result(eq, interval))51    assert m["sharpe"] == pytest.approx(r.mean() / r.std(ddof=1) * math.sqrt(bars))52    assert m["volatility"] == pytest.approx(r.std(ddof=1) * math.sqrt(bars))53    assert m["cagr"] == pytest.approx((eq[-1] / eq[0]) ** (bars / 4) - 1)545556def test_trade_metrics_by_hand():57    t0 = pd.Timestamp("2024-01-02", tz="UTC")58    trades = [59        (t0, t0, "X", "buy", 1.0, 50.0, 50.0, 0.05, 0.05, 0.0),60        (t0, t0, "X", "sell", 0.5, 120.0, 60.0, 0.06, 0.06, 9.8),61        (t0, t0, "X", "sell", 0.2, 100.0, 20.0, 0.02, 0.02, -5.0),62    ]63    m = compute_metrics(result([100.0, 104.0, 108.0], "1d", trades,64                               open_positions=[{"symbol": "X", "units": 0.3, "cost": 28.0, "value": 30.0}]))65    # closed lots: +9.8, -5.0, and the open position marked at the last close: +2.066    assert m["trades"] == 3 and m["closed_lots"] == 367    assert m["win_rate"] == pytest.approx(2 / 3)68    assert m["profit_factor"] == pytest.approx(11.8 / 5.0)69    assert m["fees_paid"] == pytest.approx(0.13) and m["slippage_paid"] == pytest.approx(0.13)70    assert m["turnover"] == pytest.approx(130.0 / 104.0)                    # traded notional / mean equity71    assert m["turnover_annual"] == pytest.approx(130.0 / 104.0 * 365 / 2)727374def test_undefined_ratios_are_null_not_infinite():75    flat = compute_metrics(result([100.0, 100.0, 100.0, 100.0]))76    assert flat["sharpe"] is None and flat["sortino"] is None and flat["calmar"] is None77    assert flat["max_drawdown"] == 0 and flat["total_return"] == 0 and flat["volatility"] == 078    up = compute_metrics(result([100.0, 101.0, 103.0]))79    assert up["sortino"] is None and up["calmar"] is None and up["max_drawdown"] == 080    assert math.isnan(sharpe_ratio([0.01])) and math.isnan(sortino_ratio([0.01, 0.02]))818283def test_max_drawdown_and_distribution():84    assert max_drawdown([100, 120, 90, 130, 65, 70]) == pytest.approx(0.5)   # 130 -> 6585    assert max_drawdown([1, 2, 3]) == 086    d = return_distribution(np.array([-0.02, 0.0, 0.01, 0.03]))87    assert d["mean"] == pytest.approx(0.005) and d["median"] == pytest.approx(0.005) and d["n"] == 488    assert d["std"] == pytest.approx(np.std([-0.02, 0.0, 0.01, 0.03], ddof=1))899091def test_buy_and_hold_benchmark_by_hand():92    # one asset, fee 10 bps, slippage 20 bps, bought at the open of the second bar93    data = {"X": frame([100, 100, 110, 121], [100, 110, 121, 133.1])}94    cfg = BacktestConfig("1h", fee_bps=10, slippage_bps=20, initial_cash=1000)95    res = buy_and_hold(data, cfg)96    units = 1000 / 1.001 / (100 * 1.002)97    np.testing.assert_allclose(res.equity.to_numpy(), [1000, units * 110, units * 121, units * 133.1])98    assert len(res.trades) == 1 and res.trades["ts"].iloc[0] == data["X"].index[1]99    m = compute_metrics(res)100    assert m["total_return"] == pytest.approx(units * 133.1 / 1000 - 1)101    assert m["fees_paid"] == pytest.approx(1000 / 1.001 * 0.001)102    assert m["slippage_paid"] == pytest.approx(units * 100 * 0.002)103    assert m["win_rate"] == 1.0 and m["profit_factor"] is None and m["trades"] == 1104    assert m["exposure"] == pytest.approx(0.75)105106107def test_equal_weight_benchmark_rebalances_and_buy_and_hold_does_not():108    a = frame([100, 100, 100, 150, 150, 150], [100, 100, 150, 150, 150, 150])109    b = frame([100, 100, 100, 100, 100, 100], [100, 100, 100, 100, 100, 100])110    cfg = BacktestConfig("1h", fee_bps=0, slippage_bps=0, initial_cash=1000)111    bh = buy_and_hold({"A": a, "B": b}, cfg)112    assert len(bh.trades) == 2                                # bought once, 50/50113    assert bh.equity.iloc[-1] == pytest.approx(1250.0)114    ew = equal_weight({"A": a, "B": b}, cfg)115    assert len(ew.trades) > 2                                 # sells A after its rise to restore 50/50116    assert (ew.trades["side"] == "sell").any()117
Files main
Menu

Theme