Repository Public
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