diff --git a/backend_api_python/app/services/factors/registry.py b/backend_api_python/app/services/factors/registry.py index 4222d4709..fec8452a5 100644 --- a/backend_api_python/app/services/factors/registry.py +++ b/backend_api_python/app/services/factors/registry.py @@ -1077,8 +1077,21 @@ def _garman_klass_volatility(frame: pd.DataFrame, params: Mapping[str, Any]) -> def _amihud_illiquidity(frame: pd.DataFrame, params: Mapping[str, Any]) -> float: period = _period(params) - close = _require(frame["close"], period + 1) - volume = pd.to_numeric(frame["volume"], errors="coerce").iloc[-period:] - returns = close.pct_change().abs().iloc[-period:] - traded_value = (close.iloc[-period:] * volume).replace(0, np.nan) - return float((returns.to_numpy() / traded_value.to_numpy()).mean()) + raw_close = pd.to_numeric(frame["close"], errors="coerce").replace([np.inf, -np.inf], np.nan) + raw_volume = pd.to_numeric(frame["volume"], errors="coerce").replace([np.inf, -np.inf], np.nan) + valid_close = raw_close.notna().to_numpy() + close_values = raw_close.to_numpy(dtype=float)[valid_close] + if len(close_values) < period + 1: + raise FactorError("factor.insufficientHistory") + tail_close = close_values[-(period + 1):] + prev_close = tail_close[:-1] + curr_close = tail_close[1:] + curr_volume = raw_volume.to_numpy(dtype=float)[valid_close][-period:] + with np.errstate(divide="ignore", invalid="ignore"): + abs_returns = np.abs(curr_close / prev_close - 1.0) + traded_value = curr_close * curr_volume + ratio = abs_returns / np.where(traded_value > 0.0, traded_value, np.nan) + finite = ratio[np.isfinite(ratio)] + if finite.size == 0: + return float("nan") + return float(finite.mean()) diff --git a/backend_api_python/tests/test_factor_registry.py b/backend_api_python/tests/test_factor_registry.py index 6ca0c7833..3fc550f7a 100644 --- a/backend_api_python/tests/test_factor_registry.py +++ b/backend_api_python/tests/test_factor_registry.py @@ -138,3 +138,41 @@ def test_extended_factor_families_have_expected_invariants(): assert compute_factor("parkinson_volatility", frame) > 0 assert np.isfinite(compute_factor("skewness", frame)) assert np.isfinite(compute_factor("kurtosis", frame)) + + +def test_amihud_illiquidity_aligns_close_and_volume_with_missing_or_zero_bars(): + trailing_nan = pd.DataFrame({ + "close": [100.0, 102.0, 101.0, 104.0, 103.0, 106.0, np.nan], + "volume": [1_000.0, 1_100.0, 1_200.0, 1_300.0, 1_400.0, 1_500.0, 1_600.0], + }) + clean = trailing_nan.iloc[:-1] + assert compute_factor("amihud_illiquidity", trailing_nan, {"period": 5}) == pytest.approx( + compute_factor("amihud_illiquidity", clean, {"period": 5}) + ) + + internal_nan_mismatch = pd.DataFrame({ + "close": [100.0, 101.0, np.nan, 103.0, 104.0, np.nan, 106.0, 108.0], + "volume": [100.0, 200.0, 9_999.0, 300.0, 400.0, 9_999.0, 500.0, 600.0], + }) + expected = compute_factor( + "amihud_illiquidity", + internal_nan_mismatch.dropna(subset=["close"]).reset_index(drop=True), + {"period": 4}, + ) + assert compute_factor("amihud_illiquidity", internal_nan_mismatch, {"period": 4}) == pytest.approx( + expected + ) + + zero_volume = pd.DataFrame({ + "close": [100.0, 101.0, 102.0, 103.0, 104.0, 105.0], + "volume": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + }) + assert np.isnan(compute_factor("amihud_illiquidity", zero_volume, {"period": 5})) + + short_close = pd.DataFrame({ + "close": [100.0, np.nan, 102.0, 103.0], + "volume": [1_000.0, 1_000.0, 1_000.0, 1_000.0], + }) + with pytest.raises(FactorError) as caught: + compute_factor("amihud_illiquidity", short_close, {"period": 5}) + assert caught.value.code == "factor.insufficientHistory" diff --git a/backend_api_python/tests/test_factor_research.py b/backend_api_python/tests/test_factor_research.py index 8a1148351..7dc5c656f 100644 --- a/backend_api_python/tests/test_factor_research.py +++ b/backend_api_python/tests/test_factor_research.py @@ -459,3 +459,30 @@ def test_factor_research_warmup_bars_handles_shorthand_and_custom_multistage_fac [timestamp], ) assert panel.loc[timestamp].notna().all(), factor_id + + +def test_factor_research_correlation_survives_nan_close_in_amihud_illiquidity(): + index = pd.date_range("2025-01-01", periods=60, freq="B") + frames = {} + for offset, symbol in enumerate(["A", "B", "C", "D", "E", "F"], start=1): + close = pd.Series([100.0 + day * (0.2 + offset * 0.05) for day in range(len(index))], index=index) + if symbol == "F": + close.iloc[40] = float("nan") + frames[symbol] = pd.DataFrame({ + "open": close - 0.1, + "high": close + 1.0, + "low": close - 1.0, + "close": close, + "volume": [1_000.0 + day * 10.0 for day in range(len(index))], + }, index=index) + + result = FactorResearchEngine().run( + frames=frames, + factor_id="momentum_20", + start_date=index[30], + end_date=index[-1], + groups=3, + holding_period=5, + ) + + assert "amihud_illiquidity" in result["factorCorrelation"]["factors"]