Files
quant_engine/tests/test_factor_diagnostics.py
T
2026-10-04 03:47:02 +08:00

319 lines
15 KiB
Python

"""Deterministic diagnostics contracts with independently checkable samples."""
from __future__ import annotations
import importlib
from math import sqrt
import numpy as np
import pandas as pd
import pytest
def api():
return importlib.import_module("quant_engine.factor_diagnostics")
def panel(values, *, days=1, assets=None):
assets = assets or ["A", "B", "C"]
index = pd.MultiIndex.from_product(
[pd.date_range("2026-09-21", periods=days), assets], names=["date", "asset"]
)
return pd.DataFrame(values, index=index)
def test_pairwise_matrix_keeps_complete_security_date_pairs():
frame = panel({"pe": [None, 0, 1, 2, 3, 4, 5, 6],
"pb": [1000, None, 1, 2, 3, 4, 5, 6]}, assets=list("ABCDEFGH"))
original = frame.copy()
result = api().correlation_matrix(frame)
assert result["matrix"][0][1] == pytest.approx(1)
assert result["n_pairs"] == [[7, 6], [6, 7]]
assert result["status"][0][1] == "ok"
assert result["method"]["aggregation"] == "pooled_asset_session_pairwise"
assert result["method"]["positive_semidefinite_guaranteed"] is False
pd.testing.assert_frame_equal(frame, original)
def test_matrix_empty_constant_and_short_diagonals_are_undefined():
frame = panel({"empty": [None] * 3, "constant": [0.05] * 3, "short": [1, 2, None]})
result = api().correlation_matrix(frame)
assert result["matrix"] == [[None] * 3 for _ in range(3)]
assert result["n_pairs"][0][0] == 0
assert result["status"][0][0] == "no_pairs"
assert result["status"][1][1] == "constant_both"
assert result["status"][2][2] == "insufficient_pairs"
def test_rank_correlation_uses_average_ties_after_pairing():
frame = panel({"f": [1, 1, 2, None], "r": [1, 2, 3, 999]}, assets=list("ABCD"))
result = api().correlation_matrix(frame, method="spearman")
assert result["matrix"][0][1] == pytest.approx(sqrt(3) / 2)
assert result["n_pairs"][0][1] == 3
def test_daily_ic_is_not_one_pooled_correlation():
frame = panel({"f": [1, 2, 3, 101, 102, 103]}, days=2)
returns = pd.Series([3, 2, 1, 101, 102, 103], index=frame.index)
result = api().daily_ic(frame, returns)
assert [r["ic"] for r in result["rows"]] == pytest.approx([-1, 1])
assert result["summary"][0]["ic_mean"] == pytest.approx(0)
assert result["summary"][0]["ic_std"] == pytest.approx(sqrt(2))
assert result["summary"][0]["ic_ir"] == 0
assert result["summary"][0]["ic_t"] == 0
assert result["method"]["ir_annualized"] is False
assert result["method"]["t_method"] == "naive_iid_unadjusted"
assert result["method"]["serial_correlation_adjusted"] is False
def test_daily_summary_matches_three_known_days_and_keeps_zero():
frame = panel({"f": [1, 2, 3] * 3}, days=3)
returns = pd.Series([3, 2, 1, 1, 0, 1, 1, 2, 3], index=frame.index)
result = api().daily_ic(frame, returns)
assert [r["ic"] for r in result["rows"]] == pytest.approx([-1, 0, 1])
assert [r["rank_ic"] for r in result["rows"]] == pytest.approx([-1, 0, 1])
summary = result["summary"][0]
assert (summary["n_days"], summary["ic_valid_days"], summary["rank_ic_valid_days"]) == (3, 3, 3)
assert summary["ic_std"] == pytest.approx(1)
assert summary["ic_ir"] == pytest.approx(0, abs=1e-15)
assert summary["ic_t"] == pytest.approx(0, abs=1e-15)
assert summary["rank_ic_ir"] == pytest.approx(0, abs=1e-15)
assert summary["rank_ic_t"] == pytest.approx(0, abs=1e-15)
def test_undefined_days_and_factor_observation_domain_survive_alignment():
frame = panel({"f": [1, 2, 3, None, None, None, 1, 2, 3]}, days=3)
returns = pd.Series([1, 2, 3], index=frame.index[:3])
extra = pd.Series([100], index=pd.MultiIndex.from_tuples(
[(pd.Timestamp("2026-09-25"), "D")], names=["date", "asset"]))
result = api().daily_ic(frame, pd.concat([returns, extra]))
assert len(result["rows"]) == 3
assert [r["n_pairs"] for r in result["rows"]] == [3, 0, 0]
assert [r["n_observations"] for r in result["rows"]] == [3, 3, 3]
assert [r["ic_status"] for r in result["rows"]] == ["ok", "no_pairs", "no_pairs"]
summary = result["summary"][0]
assert summary["ic_valid_days"] == 1
assert summary["ic_missing_days"] == 2
assert summary["ic_mean"] == pytest.approx(1)
assert summary["ic_std"] is summary["ic_ir"] is summary["ic_t"] is None
assert summary["ic_status"] == "insufficient_days"
assert result["method"]["extra_return_keys"] == 1
def test_daily_summary_of_constant_ic_has_no_ratio_statistics():
frame = panel({"f": [1, 2, 3] * 3}, days=3)
returns = pd.Series([1, 1, 2] * 3, index=frame.index)
summary = api().daily_ic(frame, returns)["summary"][0]
assert summary["ic_mean"] == pytest.approx(sqrt(3) / 2)
assert summary["ic_std"] == 0
assert summary["ic_ir"] is summary["ic_t"] is None
assert summary["ic_status"] == "constant_values"
assert summary["rank_ic_ir"] is summary["rank_ic_t"] is None
def test_permutation_and_input_copies_do_not_change_diagnostics():
frame = panel({"f": [1, 2, 3, 4, 5, 6]}, days=2)
returns = pd.Series([1, 3, 2, 4, 6, 5], index=frame.index)
original_frame, original_returns = frame.copy(), returns.copy()
baseline = api().daily_ic(frame, returns)
assert api().daily_ic(frame.iloc[::-1], returns.iloc[[2, 0, 4, 1, 5, 3]]) == baseline
pd.testing.assert_frame_equal(frame, original_frame)
pd.testing.assert_series_equal(returns, original_returns)
def test_large_finite_inputs_reuse_scaled_correlation_without_overflow():
frame = panel({"a": [-1e308, 0, 1e308], "b": [-1e307, 0, 1e307]})
assert api().correlation_matrix(frame)["matrix"][0][1] == pytest.approx(1)
@pytest.mark.parametrize("value", [True, "1.2", np.inf, -np.inf, object()])
def test_diagnostics_reject_invalid_numbers(value):
frame = panel({"f": [value, 2, 3]})
with pytest.raises(ValueError, match=r"Values|Infinite"):
api().correlation_matrix(frame)
@pytest.mark.parametrize("kind", ["duplicate_keys", "bad_names", "bad_date", "timezone", "empty_asset", "duplicate_factors"])
def test_diagnostics_reject_ambiguous_keys(kind):
frame = panel({"f": [1, 2, 3]})
if kind == "duplicate_keys":
frame.index = pd.MultiIndex.from_tuples([frame.index[0]] * 3, names=["date", "asset"])
elif kind == "bad_names":
frame.index.names = ["day", "asset"]
elif kind == "bad_date":
frame.index = pd.MultiIndex.from_product([["2026-09-21"], list("ABC")], names=["date", "asset"])
elif kind == "timezone":
frame.index = pd.MultiIndex.from_product([pd.date_range("2026-09-21", periods=1, tz="UTC"), list("ABC")], names=["date", "asset"])
elif kind == "empty_asset":
frame.index = pd.MultiIndex.from_product([pd.date_range("2026-09-21", periods=1), ["", "B", "C"]], names=["date", "asset"])
else:
frame = pd.concat([frame, frame], axis=1)
with pytest.raises(ValueError, match=r"keys|dates|identifiers"):
api().correlation_matrix(frame)
@pytest.mark.parametrize("minimum", [True, 0, 2, 3.1, "3"])
def test_minimum_pairs_is_explicit_and_at_least_three(minimum):
with pytest.raises(ValueError, match="min_pairs"):
api().correlation_matrix(panel({"f": [1, 2, 3]}), min_pairs=minimum)
def test_all_missing_and_empty_inputs_remain_observed_not_identity_or_zero():
frame = panel({"f": [None] * 3})
result = api().daily_ic(frame, pd.Series([None] * 3, index=frame.index))
assert result["summary"][0]["ic_mean"] is None
assert result["summary"][0]["ic_status"] == "no_valid_days"
empty = frame.iloc[:0]
assert api().correlation_matrix(empty)["matrix"] == [[None]]
assert api().daily_ic(empty, pd.Series([], index=empty.index, dtype=float))["rows"] == []
def prices(values=(100, 110, 121, 133.1)):
sessions = pd.DatetimeIndex(["2026-09-18", "2026-09-21", "2026-09-22", "2026-09-23"])
index = pd.MultiIndex.from_product([sessions, ["A"]], names=["date", "asset"])
return pd.Series(values, index=index), sessions
def forward(series, sessions, **changes):
options = {"entry_lag_sessions": 0, "holding_sessions": 2,
"price_field": "close", "price_basis": "synthetic_comparable"}
options.update(changes)
return api().forward_returns(series, sessions=sessions, **options)
def test_forward_returns_use_explicit_calendar_endpoints_and_compound_price_ratio():
series, sessions = prices()
result = forward(series, sessions)
assert result.returns.iloc[0] == pytest.approx(0.21)
assert result.intervals.iloc[0]["entry_date"] == "2026-09-18"
assert result.intervals.iloc[0]["exit_date"] == "2026-09-22"
assert result.intervals.iloc[-1]["status"] == "insufficient_calendar"
assert np.isnan(result.returns.iloc[-1])
assert result.metadata["formula"] == "exit_price / entry_price - 1"
assert result.metadata["entry_lag_sessions"] == 0
assert result.metadata["holding_sessions"] == 2
assert result.metadata["execution_eligibility"] == "not_established"
assert result.metadata["historical_availability"] == "not_established"
def test_forward_entry_lag_changes_both_endpoints():
series, sessions = prices([10, 20, 30, 80])
result = forward(series, sessions, entry_lag_sessions=1)
assert result.returns.iloc[0] == pytest.approx(3)
assert result.intervals.iloc[0]["entry_date"] == "2026-09-21"
assert result.intervals.iloc[0]["exit_date"] == "2026-09-23"
def test_forward_does_not_skip_missing_prices_or_derive_calendar_from_rows():
series, sessions = prices([100, None, 121, 133.1])
result = forward(series.drop(index=(sessions[1], "A")), sessions, holding_sessions=1)
assert len(result.returns) == 4
assert np.isnan(result.returns.iloc[0])
assert np.isnan(result.returns.iloc[1])
assert result.intervals.iloc[0]["status"] == "missing_price"
assert result.intervals.iloc[0]["exit_date"] == "2026-09-21"
assert result.returns.iloc[2] == pytest.approx(0.1)
@pytest.mark.parametrize("options", [{"holding_sessions": 0}, {"holding_sessions": True},
{"holding_sessions": 1.5}, {"entry_lag_sessions": -1},
{"entry_lag_sessions": False}, {"price_field": ""},
{"price_basis": ""}])
def test_forward_rejects_ambiguous_interval_parameters(options):
series, sessions = prices()
with pytest.raises(ValueError, match=r"sessions|price field"):
forward(series, sessions, **options)
@pytest.mark.parametrize("kind", ["duplicate", "descending", "timezone", "intraday", "outside"])
def test_forward_rejects_invalid_calendar(kind):
series, sessions = prices()
if kind == "duplicate":
sessions = sessions.insert(1, sessions[0])
elif kind == "descending":
sessions = sessions[::-1]
elif kind == "timezone":
sessions = sessions.tz_localize("UTC")
elif kind == "intraday":
sessions = sessions + pd.Timedelta(hours=1)
else:
sessions = sessions[:-1]
with pytest.raises(ValueError, match=r"sessions|dates|calendar"):
forward(series, sessions)
@pytest.mark.parametrize("value", [0, -1, True, "2", np.inf])
def test_forward_rejects_nonpositive_or_nonfinite_prices(value):
series, sessions = prices([value, 110, 121, 133.1])
with pytest.raises(ValueError, match=r"positive|Values|Infinite"):
forward(series, sessions)
def test_result_version_and_parameters_do_not_grant_source_or_decision_admission():
frame = panel({"f": [1, 2, 3]})
result = api().daily_ic(frame, pd.Series([1, 2, 3], index=frame.index))
assert result["contract_version"] == api().FACTOR_DIAGNOSTICS_VERSION
assert result["method"]["min_pairs"] == 3
assert result["method"]["min_days"] == 2
assert result["production_algorithm_version"] is None
assert result["source_admission"] == result["historical_availability"] == "not_established"
assert result["decision_eligible"] is False
def test_nonzero_daily_summary_uses_three_days_not_nine_asset_rows():
frame = panel({"f": [1, 2, 3] * 3}, days=3)
returns = pd.Series([.01, .02, .03, .02, .03, .01, .01, -.02, .01], index=frame.index)
result = api().daily_ic(frame, returns)
assert [point["ic"] for point in result["rows"]] == pytest.approx([1, -.5, 0], abs=1e-15)
summary = result["summary"][0]
for prefix in ("ic", "rank_ic"):
assert summary[f"{prefix}_valid_days"] == 3
assert summary[f"{prefix}_mean"] == pytest.approx(1 / 6)
assert summary[f"{prefix}_std"] == pytest.approx(sqrt(7 / 12))
assert summary[f"{prefix}_ir"] == pytest.approx(.2182178902359924)
assert summary[f"{prefix}_t"] == pytest.approx(.3779644730092272)
assert summary[f"{prefix}_p"] == pytest.approx(.7418011102528389)
def test_each_side_has_three_values_but_only_two_common_pairs():
frame = panel({"left": [1, 2, 3, None], "right": [None, 2, 3, 4]}, assets=list("ABCD"))
result = api().correlation_matrix(frame)
assert result["n_pairs"][0][1] == 2
assert result["matrix"][0][1] is None
assert result["status"][0][1] == "insufficient_pairs"
def test_rank_preserves_distinct_tiny_values_beside_a_large_outlier():
frame = panel({"left": [1e-308, 2e-308, 1e308], "right": [1, 2, 3]})
assert api().correlation_matrix(frame, method="spearman")["matrix"][0][1] == pytest.approx(1)
def test_daily_rank_ic_preserves_tiny_distinct_observations():
frame = panel({"f": [1e-200, 2e-200, 3e-200, 1e308]}, assets=list("ABCD"))
returns = pd.Series([4, 3, 2, 1], index=frame.index)
result = api().daily_ic(frame, returns)
assert result["rows"][0]["rank_ic"] == pytest.approx(-1)
assert result["rows"][0]["rank_ic_status"] == "ok"
def test_affine_equivalent_daily_ic_does_not_turn_roundoff_into_extreme_significance():
frame = panel({"f": [1, 2, 3, 10, 20, 30, 101, 102, 103]}, days=3)
returns = pd.Series([1, 1, 2, 101, 101, 102, 100001, 100001, 100002], index=frame.index)
result = api().daily_ic(frame, returns)
assert [point["ic"] for point in result["rows"]] == pytest.approx([sqrt(3) / 2] * 3)
summary = result["summary"][0]
assert summary["ic_mean"] == pytest.approx(sqrt(3) / 2)
assert summary["ic_valid_days"] == 3
assert summary["ic_ir"] is summary["ic_t"] is summary["ic_p"] is None
assert summary["ic_std"] <= result["method"]["minimum_ic_std_for_ratios"]
assert summary["ic_status"] in {"constant_values", "below_resolution"}
@pytest.mark.parametrize("offset,step", [(1e16, 2.0), (1e12, np.spacing(1e12))])
def test_pearson_preserves_representable_differences_beside_a_large_offset(offset, step):
frame = panel({"f": offset + np.arange(5) * step}, assets=list("ABCDE"))
returns = pd.Series([2, 5, 1, 4, 3], index=frame.index)
matrix = api().correlation_matrix(frame.assign(other=returns))
assert matrix["matrix"][0][1] == pytest.approx(.1, abs=1e-14)
daily = api().daily_ic(frame, returns)
assert daily["rows"][0]["ic"] == pytest.approx(.1, abs=1e-14)