# vectorbt 1.1.2: NaN 하나가 CVaR을 바꾸는 이유 — 꼬리 표본과 isclose 테스트 함정

> Canonical: https://vxi.me/ko/posts/vectorbt-nan-cvar-tail-sample-test-assertions/
> Locale: ko-kr
> Published: 2026-10-18
> Updated: 2026-10-18
> Markdown: https://vxi.me/ko/posts/vectorbt-nan-cvar-tail-sample-test-assertions.md

수익률 배열에 `NaN`이 하나 포함돼도 CVaR(Conditional Value at Risk, 조건부 위험가치)은 정상적인 숫자를 출력할 수 있습니다. 그래서 결측값을 나중에 처리하기로 미루면, 정작 **손실 꼬리의 평균에 몇 개의 관측값이 들어갔는지**가 바뀐 사실을 놓치기 쉽습니다.

[vectorbt v1.1.1](https://github.com/polakowo/vectorbt/releases/tag/v1.1.1)과 [v1.1.2](https://github.com/polakowo/vectorbt/releases/tag/v1.1.2)의 고정 소스 코드를 비교하고, 합성 수익률 20개에 NaN 하나를 추가해 따로 계산했습니다. 하위 10% 기준에서 **구버전의 원본 길이 `n=21` 규칙은 -4.6667%, 신버전의 유효 표본 `n=20` 규칙은 -5.5000%**입니다. 가장 나쁜 두 수익률은 같고 꼬리에 포함되는 관측값의 개수만 달라졌습니다.

이 글의 근거는 구분해야 합니다. Numba와 Rust의 변경은 고정된 upstream 소스를 확인했지만, 출력 숫자는 직접 작성한 Python 표준 라이브러리 합성 모델을 실행해 얻었습니다. **vectorbt·Numba·Rust 자체 또는 실제 시장 백테스트를 실행한 결과는 아닙니다**.

## NaN 하나로 꼬리 표본 수가 바뀌는 이유

이 CVaR 구현은 양의 손실 금액이 아니라 **수익률 왼쪽 꼬리의 부호 있는 평균**을 반환합니다. 꼬리 비율 `alpha`에 대한 선택 규칙은 다음과 같습니다.

```text
k = int((n - 1) * alpha)
signed_tail_mean = mean(按从小到大排序后前 k+1 条收益)
```

`k`는 음수가 아닌 인덱스이므로 `int`가 사실상 내림과 같습니다. 문제는 `n`이 전체 원본 배열의 길이인지, 실제 계산에 참여하는 유효 수익률 수인지입니다. 제외할 NaN도 길이에 포함하면 절단 경계에서 유효한 값이 하나 더 선택될 수 있습니다.

차이를 보기 쉽게 합성 모델의 `alpha=0.10`을 선택했습니다. 이는 upstream의 `cutoff=0.10`에 해당하며, 하위 함수의 기본값은 `cutoff=0.05`입니다. 접근자는 지정하지 않으면 `self.defaults["cutoff"]`를 참조합니다. 20개 가상 수익률 중 가장 낮은 값은 -7%, -4%, -3%이고, 여기에 NaN 한 개를 끼워 넣었습니다.

| 계산 규칙 | `n` | `k` | 포함된 수익률 | 부호 있는 꼬리 평균 |
| --- | ---: | ---: | --- | ---: |
| 구버전: 원본 길이 | 21 | 2 | -7%, -4%, -3% | **-4.6667%** |
| 신버전: 유효 표본만 | 20 | 1 | -7%, -4% | **-5.5000%** |
| NaN 없는 대조군 | 20 | 1 | -7%, -4% | **-5.5000%** |

이 경우 -3%가 추가로 포함되면서 평균이 0 쪽으로 약 **0.83%포인트** 이동했습니다. 모든 구버전 CVaR이 위험을 과소평가했다는 뜻은 아닙니다. 표본 수, 결측 위치와 절단 기준에 따라 차이가 없을 수도 있습니다.

## Numba와 Rust의 버전별 소스 비교

GitHub의 `v1.1.2` 태그는 커밋 [`f0d2afba7af8a6e6c1b02afde27c9a16413e86ff`](https://github.com/polakowo/vectorbt/commit/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff)을 직접 가리킵니다. v1.1.1은 별도의 이전 커밋입니다. 이 기준으로 비교해야 나중에 바뀌는 `main` 내용을 혼동하지 않습니다.

자신의 설치에서 확인하려면 [`ReturnsAccessor.cond_value_at_risk(cutoff=..., engine=...)`](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/vectorbt/returns/accessors.py#L900-L912)에서 [`vectorbt.returns.dispatch.cond_value_at_risk`](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/vectorbt/returns/dispatch.py#L847-L867)를 따라가면 됩니다. Numba 내부 함수는 `cond_value_at_risk_1d_nb(returns, cutoff=0.05)`, Rust는 `cond_value_at_risk_1d(returns, cutoff)`이며 롤링 CVaR도 1차원 함수를 이용합니다.

[Numba의 nb.py](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/vectorbt/returns/nb.py#L634-L642)에서 함수 서명과 JIT 데코레이터를 생략한 핵심 변경은 다음과 같습니다.

```diff
 # v1.1.1
 if len(returns) == 0:
     return np.nan
 cutoff_index = int((len(returns) - 1) * cutoff)

 # v1.1.2
+returns = returns[~np.isnan(returns)]
 if len(returns) == 0:
     return np.nan
 cutoff_index = int((len(returns) - 1) * cutoff)
```

수식은 그대로지만 **수식에 전달되는 배열이 달라졌습니다**. 새 버전은 NaN을 먼저 걸러낸 뒤 유효한 배열이 비어 있는지 확인합니다. 입력 전체가 NaN일 때는 구버전과 신버전 모두 결국 **NaN을 반환**합니다. 의미 있는 변화는 정상 값과 결측값이 섞인 경우입니다.

Rust [구버전](https://github.com/polakowo/vectorbt/blob/v1.1.1/rust/src/returns.rs#L764-L776)은 원본 슬라이스를 복사하고 비교 과정에서 NaN을 뒤로 보내지만 `cutoff_index`는 원래 길이로 계산했습니다. [수정 버전](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/rust/src/returns.rs#L763-L770)은 `!value.is_nan()` 필터를 먼저 적용합니다.

Numba에서 쓰는 것은 완전 정렬 `np.sort`가 아니라 `np.partition`입니다. [NumPy 문서](https://numpy.org/doc/2.2/reference/generated/numpy.partition.html)에 따르면 NaN은 일반 숫자보다 뒤에 놓이므로 예제의 결측값은 가장 나쁜 세 수익률에 들어가지 않고 기존 `k`만 키웁니다. 사용된 필터는 `isnan`이며 **`isfinite`가 아닙니다**. 무한대까지 제거한다고 가정하면 안 됩니다.

[upstream 회귀 테스트](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/tests/test_returns.py#L521-L564)에는 NaN, 빈 배열, 전부 NaN인 배열, 롤링 윈도, 선택적 Rust 일치 검사 등이 들어 있습니다. 이는 테스트 소스를 확인했다는 뜻이며 공식 테스트를 현 환경에서 직접 실행한 결과는 아닙니다. [PR #879](https://github.com/polakowo/vectorbt/pull/879)에 수정 내역이 있습니다.

## 별개의 두 번째 오류: isclose가 비교를 건너뛰다

위험 지표가 표본을 올바르게 선택하더라도 테스트 단언이 실제로 숫자를 비교하지 않으면 문제가 남습니다. 같은 버전에 병합된 독립적인 [PR #878](https://github.com/polakowo/vectorbt/pull/878)은 `tests/utils.py::isclose`를 수정했습니다. [구버전 함수](https://github.com/polakowo/vectorbt/blob/v1.1.1/tests/utils.py#L9-L16)는 이렇게 시작했습니다.

```python
if np.isnan(a) == np.isnan(b):
    return True
```

`1.0`과 `2.0`을 넣으면 `np.isnan` 결과가 둘 다 `False`입니다. 그래서 `False == False`가 참이 되어 허용 오차를 따지기도 전에 `True`를 반환합니다. 구버전 `np.isinf(a) == np.isinf(b)`에도 비슷한 조기 반환 문제가 있었습니다. [수정 코드](https://github.com/polakowo/vectorbt/blob/f0d2afba7af8a6e6c1b02afde27c9a16413e86ff/tests/utils.py#L9-L14)는 실제로 NaN이나 무한대가 등장할 때만 별도 처리하고 유한한 숫자는 허용 오차로 비교합니다.

두 변경은 **같은 릴리스의 독립적인 수정**입니다. CVaR 오류가 이 `isclose` 때문에 숨겨졌다는 인과관계는 확인되지 않았습니다. 올바른 꼬리 표본 선택과 올바른 단언 비교를 각각 검증해야 합니다.

## 전체 Python 코드와 실행 출력

아래의 `tail_mean`은 구버전 Rust처럼 NaN을 뒤에 두는 선택 과정과 두 가지 표본 수 규칙을 모델링합니다. `old_isclose`는 구버전 테스트 도우미의 첫 잘못된 조기 반환만 재현합니다. `CLEAN`에는 가상 수익률 20개가 모두 들어 있습니다.

**Python 3.13.5에서 실제 실행한 표준 라이브러리 전체 코드**이며 5개 `unittest`를 생략하지 않았습니다.

```python
"""Synthetic CVaR tail-mean demo; source-inspection model, not vectorbt runtime."""
import math
import sys
import unittest

# All 20 returns below are fictional decimal returns. NaN is NOT zero.
CLEAN = [
    -0.07, -0.04, -0.03, -0.02, -0.01, 0.0, 0.01, 0.02, 0.03, 0.0,
    -0.02, 0.01, 0.02, 0.03, 0.01, 0.0, 0.02, 0.01, 0.0, 0.04,
]
DIRTY = CLEAN[:8] + [float('nan')] + CLEAN[8:]
ALPHA = 0.10  # Deliberate teaching cutoff; upstream default is 0.05.


def tail_mean(data, alpha=ALPHA, *, drop_nan):
    """Model v1.1.1 raw-count vs v1.1.2 valid-count tail selection.

    Returns (signed tail mean, cutoff_index, selected tail values).
    The original Rust comparison puts NaN last; this is a stdlib model.
    """
    values = [v for v in data if not math.isnan(v)] if drop_nan else list(data)
    if not values:
        return float('nan'), None, []
    ordered = sorted(values, key=lambda v: (math.isnan(v), 0 if math.isnan(v) else v))
    k = int((len(values) - 1) * alpha)
    selected = ordered[:k + 1]
    return sum(selected) / len(selected), k, selected


def old_isclose(a, b):
    # Models the v1.1.1 tests/utils.py early-return bug only.
    if math.isnan(a) == math.isnan(b):
        return True
    return math.isclose(a, b, rel_tol=1e-6)


class SyntheticChecks(unittest.TestCase):
    def test_old_new_tail_count(self):
        self.assertAlmostEqual(tail_mean(DIRTY, drop_nan=False)[0], -0.04666666666666667)
        self.assertAlmostEqual(tail_mean(DIRTY, drop_nan=True)[0], -0.055)
        self.assertEqual(tail_mean(DIRTY, drop_nan=False)[1], 2)
        self.assertEqual(tail_mean(DIRTY, drop_nan=True)[1], 1)

    def test_no_nan_control(self):
        self.assertAlmostEqual(tail_mean(CLEAN, drop_nan=False)[0], -0.055)
        self.assertAlmostEqual(tail_mean(CLEAN, drop_nan=True)[0], -0.055)

    def test_nan_position_invariance(self):
        for i in (0, 8, 20):
            values = CLEAN[:i] + [float('nan')] + CLEAN[i:]
            self.assertAlmostEqual(tail_mean(values, drop_nan=True)[0], -0.055)

    def test_empty_and_all_nan(self):
        self.assertTrue(math.isnan(tail_mean([], drop_nan=True)[0]))
        self.assertTrue(math.isnan(tail_mean([float('nan')] * 3, drop_nan=True)[0]))

    def test_test_helper_false_positive(self):
        self.assertTrue(old_isclose(1.0, 2.0))
        self.assertFalse(math.isclose(1.0, 2.0, rel_tol=1e-6))


if __name__ == '__main__':
    old, old_k, old_tail = tail_mean(DIRTY, drop_nan=False)
    new, new_k, new_tail = tail_mean(DIRTY, drop_nan=True)
    print('SYNTHETIC_INPUT: 20 valid returns + one NaN; alpha=0.10')
    print(f'RAW_COUNT: n={len(DIRTY)}, k={old_k}, tail={old_tail}, signed_mean={old:.8f}')
    print(f'VALID_COUNT: n={len(CLEAN)}, k={new_k}, tail={new_tail}, signed_mean={new:.8f}')
    print(f'NO_NAN_CONTROL: raw={tail_mean(CLEAN, drop_nan=False)[0]:.8f}, '
          f'valid={tail_mean(CLEAN, drop_nan=True)[0]:.8f}')
    print(f'OLD_ISCLOSE_FALSE_POSITIVE: isclose(1.0,2.0)={old_isclose(1.0, 2.0)}')
    suite = unittest.defaultTestLoader.loadTestsFromTestCase(SyntheticChecks)
    result = unittest.TextTestRunner(stream=sys.stdout, verbosity=1).run(suite)
    print(f'CHECKS_PASS={result.testsRun - len(result.failures) - len(result.errors)}/{result.testsRun}')
    sys.exit(0 if result.wasSuccessful() else 1)
```

`cvar_nan_demo.py`로 저장하고 실행합니다.

```bash
python3 cvar_nan_demo.py
```

종료 코드 `0`에서 기록한 원본 표준 출력:

```text
SYNTHETIC_INPUT: 20 valid returns + one NaN; alpha=0.10
RAW_COUNT: n=21, k=2, tail=[-0.07, -0.04, -0.03], signed_mean=-0.04666667
VALID_COUNT: n=20, k=1, tail=[-0.07, -0.04], signed_mean=-0.05500000
NO_NAN_CONTROL: raw=-0.05500000, valid=-0.05500000
OLD_ISCLOSE_FALSE_POSITIVE: isclose(1.0,2.0)=True
.....
----------------------------------------------------------------------
Ran 5 tests in 0.000s

OK
CHECKS_PASS=5/5
```

NaN이 없는 대조군에서는 두 규칙이 같고, NaN 삽입 위치를 바꿔도 신버전은 같은 값을 반환합니다. 유효 표본이 없으면 0으로 채우지 않고 NaN을 반환합니다. **5/5**는 이 다섯 개의 자체 합성 검사가 통과했다는 뜻입니다.

## 실제 위험 지표 QA에서 챙길 점

결측값 처리도 **지표의 의미 계약**입니다. CVaR과 함께 `n_raw`, `n_valid`, `n_nan`, 수익률 단위, `cutoff`, 롤링 윈도 크기와 최소 유효 표본 수를 추적해야 합니다. 선택적 Rust 백엔드를 사용하면 동일 입력과 검증된 허용 오차로 Numba 결과와 비교합니다.

NaN은 공급업체의 누락 가격, 거래 정지, 상장 이전, 아직 확정되지 않은 수익률 또는 ETL 오류를 뜻할 수 있습니다. 이를 제거해 이번 구현의 분모는 일관되게 만들 수 있지만, 손실이 클 때일수록 데이터가 사라지는 **비무작위 결측**의 통계 문제까지 해결하지는 못합니다.

이전에 쓴 [같은 종목의 반복 선택과 승률 분모](/posts/quant-winrate-repeated-stock-picks/)도 유사합니다. 그 글에서는 신호 이벤트, 고유 종목, 선택 날짜를 구별했고, 여기서는 원래 배열 위치와 왼쪽 꼬리에 실제 참여하는 유효한 수익률을 구분합니다.

실제 라이브러리까지 검증하려면 격리 환경에서 v1.1.1과 v1.1.2를 각각 설치해 Numba 회귀 테스트를 실행하고, 호환되는 Rust 확장이 있다면 두 백엔드와 롤링 결과를 대조해야 합니다.

전략의 우위를 논하기 전에, 위험 통계가 올바른 유효 표본을 세고 단언 함수가 실제로 수치를 비교하는지부터 확인할 필요가 있습니다.

---

*근거 기록: vectorbt v1.1.2는 2026년 10월 9일에 출시됐습니다. 고정 소스 검토와 Python 3.13.5 합성 실험은 10월 18일의 기록입니다. 기준 커밋: `f0d2afba7af8a6e6c1b02afde27c9a16413e86ff`. 예시는 가상 데이터이며 실거래 백테스트나 투자 조언이 아닙니다.*
