-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_data_utils.py
More file actions
227 lines (182 loc) Β· 8.39 KB
/
Copy pathtest_data_utils.py
File metadata and controls
227 lines (182 loc) Β· 8.39 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
"""
tests/test_data_utils.py
========================
Unit tests for the pure calculation functions in data_utils.py.
These tests do NOT require a BigQuery connection β they only exercise
local Python/pandas logic and can run in CI without GCP credentials.
Run locally:
pytest tests/ -v
pytest tests/ --cov=data_utils --cov-report=term-missing
"""
import numpy as np
import pandas as pd
import pytest
from data_utils import (
clean_oas_to_basis_points,
compute_spread_zscore,
regime_flag,
yoy_change,
)
# ββ Fixtures ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@pytest.fixture
def flat_spread():
"""A constant spread series β z-score should always be NaN or 0."""
dates = pd.date_range("2020-01-01", periods=300, freq="B")
return pd.Series(100.0, index=dates, name="oas")
@pytest.fixture
def trending_spread():
"""A spread that rises linearly from 100 to 599 over 500 business days."""
dates = pd.date_range("2018-01-01", periods=500, freq="B")
values = np.linspace(100, 599, 500)
return pd.Series(values, index=dates, name="oas")
@pytest.fixture
def yoy_df():
"""
DataFrame spanning two years.
Dates chosen so that 2024-01-02 - 1 year = 2023-01-02,
and 2023-01-02 is in the data (satisfies the <= filter in yoy_change).
"""
return pd.DataFrame(
{
"date": pd.to_datetime(["2022-01-02", "2023-01-02", "2024-01-02"]),
"value": [100.0, 110.0, 132.0],
}
)
# ββ Test 1: compute_spread_zscore βββββββββββββββββββββββββββββββββββββββββ
class TestCleanOasToBasisPoints:
def test_converts_long_oas_values_to_bp(self):
df = pd.DataFrame(
{
"series_key": ["ig_oas", "hy_oas", "ig_yield"],
"value": [0.81, 3.25, 5.12],
}
)
result = clean_oas_to_basis_points(df)
assert result.loc[0, "value"] == pytest.approx(81.0)
assert result.loc[1, "value"] == pytest.approx(325.0)
assert result.loc[2, "value"] == pytest.approx(5.12)
def test_does_not_double_convert_existing_bp_values(self):
df = pd.DataFrame(
{
"series_key": ["ig_oas", "hy_oas"],
"value": [81.0, 325.0],
}
)
result = clean_oas_to_basis_points(df)
assert result["value"].tolist() == [81.0, 325.0]
def test_converts_wide_oas_columns_only(self):
df = pd.DataFrame(
{
"date": pd.to_datetime(["2024-01-01"]),
"ig_oas": [0.81],
"hy_oas": [3.25],
"ig_yield": [5.12],
}
)
result = clean_oas_to_basis_points(df)
assert result.loc[0, "ig_oas"] == pytest.approx(81.0)
assert result.loc[0, "hy_oas"] == pytest.approx(325.0)
assert result.loc[0, "ig_yield"] == pytest.approx(5.12)
class TestComputeSpreadZscore:
"""
Z-score = (x - rolling_mean) / rolling_std.
A constant series has std = 0, so values can't be standardised β
the result should be NaN everywhere (division by zero).
For a series with real variance, z-scores should be centred near 0
and the most recent extreme value should have a large positive z-score.
"""
def test_constant_series_returns_nan(self, flat_spread):
result = compute_spread_zscore(flat_spread, window=252)
# Constant series: std = 0 β all z-scores NaN
assert result.dropna().empty, "Z-scores for a constant series should all be NaN (std = 0)"
def test_output_length_matches_input(self, trending_spread):
result = compute_spread_zscore(trending_spread, window=252)
assert len(result) == len(trending_spread)
def test_early_values_are_nan_before_window(self, trending_spread):
window = 252
result = compute_spread_zscore(trending_spread, window=window)
# With min_periods = window // 2 = 126, the first 125 values are NaN
assert result.iloc[: window // 2 - 1].isna().all(), "Values before min_periods should be NaN"
def test_last_value_is_large_positive(self, trending_spread):
result = compute_spread_zscore(trending_spread, window=252)
last_z = result.dropna().iloc[-1]
assert last_z > 1.0, f"Spread at its 500-day high should have a positive z-score, got {last_z:.2f}"
# ββ Test 2: yoy_change ββββββββββββββββββββββββββββββββββββββββββββββββββββ
class TestYoyChange:
"""
Year-over-year % change = (latest - prev_year) / |prev_year| * 100.
Uses the last row and the most recent row whose date is β₯ 1 year before it.
"""
def test_correct_percentage(self, yoy_df):
# latest = 132 (2024-01-02), one year ago = 110 (2023-01-03)
# YoY = (132 - 110) / 110 * 100 = 20.0
result = yoy_change(yoy_df, "value")
assert result == pytest.approx(20.0, abs=0.2), f"Expected ~20.0% YoY change, got {result}"
def test_returns_nan_for_single_row(self):
df = pd.DataFrame(
{
"date": pd.to_datetime(["2024-01-01"]),
"value": [100.0],
}
)
result = yoy_change(df, "value")
assert np.isnan(result), "Single-row DataFrame should return NaN"
def test_returns_nan_when_no_prior_year(self):
# Only 6 months of data β no row far enough back for a YoY comparison
df = pd.DataFrame(
{
"date": pd.date_range("2024-01-01", periods=3, freq="90D"),
"value": [100.0, 105.0, 110.0],
}
)
result = yoy_change(df, "value")
assert np.isnan(result), "Should return NaN when there is no data point >= 1 year before latest"
def test_positive_growth_is_positive(self, yoy_df):
result = yoy_change(yoy_df, "value")
assert result > 0
def test_negative_growth_is_negative(self):
# 2024-01-02 - 1 year = 2023-01-02; use 2023-01-01 so it satisfies <=
df = pd.DataFrame(
{
"date": pd.to_datetime(["2023-01-01", "2024-01-02"]),
"value": [200.0, 150.0],
}
)
result = yoy_change(df, "value")
assert result < 0, f"Expected negative YoY change, got {result}"
# ββ Test 3: regime_flag βββββββββββββββββββββββββββββββββββββββββββββββββββ
class TestRegimeFlag:
"""
regime_flag(percentile) classifies the current spread level
into four regimes and returns (label, color_hex).
Boundaries:
>= 75 β Wide β stressed (#A32D2D, red)
>= 50 β Above median (#BA7517, amber)
>= 25 β Below median (#3B6D11, green)
< 25 β Tight β rich (#185FA5, blue)
"""
@pytest.mark.parametrize(
"percentile, expected_label, expected_color",
[
(90, "Wide β stressed", "#A32D2D"),
(75, "Wide β stressed", "#A32D2D"), # boundary: exactly 75
(74.9, "Above median", "#BA7517"),
(50, "Above median", "#BA7517"), # boundary: exactly 50
(49.9, "Below median", "#3B6D11"),
(25, "Below median", "#3B6D11"), # boundary: exactly 25
(24.9, "Tight β rich", "#185FA5"),
(0, "Tight β rich", "#185FA5"),
],
)
def test_all_boundaries(self, percentile, expected_label, expected_color):
label, color = regime_flag(percentile)
assert label == expected_label, f"percentile={percentile}: expected label '{expected_label}', got '{label}'"
assert color == expected_color, f"percentile={percentile}: expected color '{expected_color}', got '{color}'"
def test_returns_tuple_of_two_strings(self):
result = regime_flag(50)
assert isinstance(result, tuple) and len(result) == 2 # noqa: PLR2004
assert all(isinstance(x, str) for x in result)
def test_color_is_valid_hex(self):
for percentile in [0, 25, 50, 75, 100]:
_, color = regime_flag(percentile)
assert color.startswith("#") and len(color) == 7, f"Color '{color}' is not a valid 7-char hex string" # noqa: PLR2004