Files
wehub-resource-sync a2cb1f9821
Wiki / validate (push) Has been cancelled
Deploy Wiki / Deploy to Cloudflare Pages (push) Has been cancelled
CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:55 +08:00

299 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Equivalence tests for optimized factor operators.
Tests verify that the fast paths (bottleneck / numpy stride) produce
identical results to the original pandas rolling().apply() fallback.
"""
from __future__ import annotations
import importlib
import os
import sys
import warnings
import numpy as np
import pandas as pd
import pytest
# ── Helpers to force fallback path ──
def _reload_base_with_bottleneck(enabled: bool):
"""Reload base.py with bottleneck enabled or disabled."""
os.environ["VIBE_TRADING_DISABLE_BOTTLENECK"] = "0" if enabled else "1"
# Force reimport of _backend and base
if "src.factors._backend" in sys.modules:
del sys.modules["src.factors._backend"]
if "src.factors.base" in sys.modules:
del sys.modules["src.factors.base"]
mod = importlib.import_module("src.factors.base")
return mod
# ── Reference implementations (original pandas path) ──
def _ref_ts_rank(df: pd.DataFrame, n: int) -> pd.DataFrame:
def _last_rank(arr: np.ndarray) -> float:
if np.isnan(arr).all():
return np.nan
last = arr[-1]
if np.isnan(last):
return np.nan
valid = arr[~np.isnan(arr)]
if valid.size == 0:
return np.nan
less = (valid < last).sum()
eq = (valid == last).sum()
rank_avg = less + 0.5 * (eq + 1)
return float(rank_avg / valid.size)
return df.rolling(window=n, min_periods=n).apply(_last_rank, raw=True)
def _ref_ts_argmax(df: pd.DataFrame, n: int) -> pd.DataFrame:
def _argmax_last(arr: np.ndarray) -> float:
if np.isnan(arr).all():
return np.nan
arr_filled = np.where(np.isnan(arr), -np.inf, arr)
return float(np.argmax(arr_filled))
return df.rolling(window=n, min_periods=n).apply(_argmax_last, raw=True)
def _ref_ts_argmin(df: pd.DataFrame, n: int) -> pd.DataFrame:
def _argmin_last(arr: np.ndarray) -> float:
if np.isnan(arr).all():
return np.nan
arr_filled = np.where(np.isnan(arr), np.inf, arr)
return float(np.argmin(arr_filled))
return df.rolling(window=n, min_periods=n).apply(_argmin_last, raw=True)
def _ref_decay_linear(df: pd.DataFrame, n: int) -> pd.DataFrame:
weights = np.arange(n, 0, -1, dtype=np.float64)
weights /= weights.sum()
def _apply(arr: np.ndarray) -> float:
if np.isnan(arr).any():
return np.nan
return float(np.dot(arr, weights))
return df.rolling(window=n, min_periods=n).apply(_apply, raw=True)
# ── Fixtures ──
@pytest.fixture
def random_df():
"""100 rows × 10 columns random DataFrame."""
np.random.seed(42)
return pd.DataFrame(np.random.randn(100, 10))
@pytest.fixture
def random_df_with_nan():
"""100 rows × 10 columns with scattered NaN values."""
np.random.seed(42)
df = pd.DataFrame(np.random.randn(100, 10))
df.iloc[10, 2] = np.nan
df.iloc[30, 5] = np.nan
df.iloc[50, 0] = np.nan
df.iloc[70, 8] = np.nan
return df
# ── ts_rank tests ──
class TestTsRank:
@pytest.mark.parametrize("n", [3, 5, 10, 20])
def test_equivalence_clean(self, random_df, n):
"""Fast path matches pandas reference on clean data."""
base = _reload_base_with_bottleneck(True)
result = base.ts_rank(random_df, n)
expected = _ref_ts_rank(random_df, n)
pd.testing.assert_frame_equal(result, expected, atol=1e-12)
@pytest.mark.parametrize("n", [5, 10])
def test_equivalence_with_nan(self, random_df_with_nan, n):
"""Fast path matches pandas reference with NaN values."""
base = _reload_base_with_bottleneck(True)
result = base.ts_rank(random_df_with_nan, n)
expected = _ref_ts_rank(random_df_with_nan, n)
pd.testing.assert_frame_equal(result, expected, atol=1e-12)
def test_warmup_is_nan(self, random_df):
"""First n-1 rows must be NaN."""
base = _reload_base_with_bottleneck(True)
n = 10
result = base.ts_rank(random_df, n)
assert result.iloc[: n - 1].isna().all().all()
def test_fallback_path(self, random_df):
"""Fallback (bottleneck disabled) still works correctly."""
base = _reload_base_with_bottleneck(False)
result = base.ts_rank(random_df, 5)
expected = _ref_ts_rank(random_df, 5)
pd.testing.assert_frame_equal(result, expected, atol=1e-12)
def test_all_nan_window_emits_no_runtime_warning(self):
"""All-NaN windows stay NaN without divide-by-zero noise."""
base = _reload_base_with_bottleneck(True)
df = pd.DataFrame(np.nan, index=range(12), columns=list("abc"))
with warnings.catch_warnings():
warnings.simplefilter("error", RuntimeWarning)
result = base.ts_rank(df, 5)
assert result.isna().all().all()
def test_constant_window(self):
"""All-same values: rank should be (n+1)/(2n) for average rank."""
base = _reload_base_with_bottleneck(True)
df = pd.DataFrame(np.ones((20, 3)))
result = base.ts_rank(df, 5)
# For 5 identical values: less=0, eq=5, rank_avg=0+0.5*(5+1)=3, pct=3/5=0.6
expected_val = 0.6
assert np.allclose(result.iloc[4:].values, expected_val)
def test_invalid_window(self):
base = _reload_base_with_bottleneck(True)
with pytest.raises(ValueError, match="window must be >= 1"):
base.ts_rank(pd.DataFrame(), 0)
# ── ts_argmax tests ──
class TestTsArgmax:
@pytest.mark.parametrize("n", [3, 5, 10, 20])
def test_equivalence_clean(self, random_df, n):
base = _reload_base_with_bottleneck(True)
result = base.ts_argmax(random_df, n)
expected = _ref_ts_argmax(random_df, n)
pd.testing.assert_frame_equal(result, expected)
def test_warmup_is_nan(self, random_df):
base = _reload_base_with_bottleneck(True)
n = 10
result = base.ts_argmax(random_df, n)
assert result.iloc[: n - 1].isna().all().all()
def test_fallback_path(self, random_df):
base = _reload_base_with_bottleneck(False)
result = base.ts_argmax(random_df, 5)
expected = _ref_ts_argmax(random_df, 5)
pd.testing.assert_frame_equal(result, expected)
def test_integer_output(self, random_df):
"""argmax returns integer indices (0-based), not floats."""
base = _reload_base_with_bottleneck(True)
result = base.ts_argmax(random_df, 5)
valid = result.iloc[4:]
assert (valid == valid.astype(int)).all().all()
def test_invalid_window(self):
base = _reload_base_with_bottleneck(True)
with pytest.raises(ValueError, match="window must be >= 1"):
base.ts_argmax(pd.DataFrame(), 0)
# ── ts_argmin tests ──
class TestTsArgmin:
@pytest.mark.parametrize("n", [3, 5, 10, 20])
def test_equivalence_clean(self, random_df, n):
base = _reload_base_with_bottleneck(True)
result = base.ts_argmin(random_df, n)
expected = _ref_ts_argmin(random_df, n)
pd.testing.assert_frame_equal(result, expected)
def test_warmup_is_nan(self, random_df):
base = _reload_base_with_bottleneck(True)
n = 10
result = base.ts_argmin(random_df, n)
assert result.iloc[: n - 1].isna().all().all()
def test_fallback_path(self, random_df):
base = _reload_base_with_bottleneck(False)
result = base.ts_argmin(random_df, 5)
expected = _ref_ts_argmin(random_df, 5)
pd.testing.assert_frame_equal(result, expected)
def test_invalid_window(self):
base = _reload_base_with_bottleneck(True)
with pytest.raises(ValueError, match="window must be >= 1"):
base.ts_argmin(pd.DataFrame(), 0)
# ── decay_linear tests ──
class TestDecayLinear:
@pytest.mark.parametrize("n", [3, 5, 10, 20])
def test_equivalence_clean(self, random_df, n):
base = _reload_base_with_bottleneck(True)
result = base.decay_linear(random_df, n)
expected = _ref_decay_linear(random_df, n)
pd.testing.assert_frame_equal(result, expected, atol=1e-10)
@pytest.mark.parametrize("n", [5, 10])
def test_equivalence_with_nan(self, random_df_with_nan, n):
base = _reload_base_with_bottleneck(True)
result = base.decay_linear(random_df_with_nan, n)
expected = _ref_decay_linear(random_df_with_nan, n)
pd.testing.assert_frame_equal(result, expected, atol=1e-10)
def test_warmup_is_nan(self, random_df):
base = _reload_base_with_bottleneck(True)
n = 10
result = base.decay_linear(random_df, n)
assert result.iloc[: n - 1].isna().all().all()
def test_no_lookahead(self, random_df):
"""Modifying future values must not change past outputs."""
base = _reload_base_with_bottleneck(True)
n = 10
df_mod = random_df.copy()
df_mod.iloc[50:] = 999.0
result_orig = base.decay_linear(random_df, n)
result_mod = base.decay_linear(df_mod, n)
# Output at row 49 (index 49) should be identical
pd.testing.assert_series_equal(
result_orig.iloc[49], result_mod.iloc[49], atol=1e-10
)
def test_fallback_path(self, random_df):
base = _reload_base_with_bottleneck(False)
result = base.decay_linear(random_df, 5)
expected = _ref_decay_linear(random_df, 5)
pd.testing.assert_frame_equal(result, expected, atol=1e-10)
def test_invalid_window(self):
base = _reload_base_with_bottleneck(True)
with pytest.raises(ValueError, match="window must be >= 1"):
base.decay_linear(pd.DataFrame(), 0)
# ── Backend detection tests ──
class TestBackend:
def test_bottleneck_available(self):
"""bottleneck should be available in test environment."""
from src.factors._backend import HAS_BOTTLENECK
assert HAS_BOTTLENECK is True
def test_disable_env_var(self):
"""VIBE_TRADING_DISABLE_BOTTLENECK=1 should disable bottleneck."""
_reload_base_with_bottleneck(False)
from src.factors._backend import HAS_BOTTLENECK
assert HAS_BOTTLENECK is False
# Restore
_reload_base_with_bottleneck(True)