From 448f63947e303a8c4fa4da181f5075cb3b55e142 Mon Sep 17 00:00:00 2001 From: PureSaber Date: Tue, 13 Oct 2026 12:29:17 +0800 Subject: [PATCH] Add versioned zero-target downside deviation without changing legacy runs --- README.md | 5 +++++ src/quant_factors/core.py | 11 ++++++++++- src/quant_factors/families.py | 1 + tests/test_downside_definition.py | 33 +++++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 tests/test_downside_definition.py diff --git a/README.md b/README.md index 78aa429..c5f887b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,11 @@ python -m pip check ## Usage +`downside_deviation_20d_v2` is the opt-in zero-target downside deviation: +`sqrt(252 * rolling_mean(min(daily_return, 0)^2))`, using all 20 daily returns. +It is not part of the default factor set. The historical `downside_vol_20d` +retains its clipped-return standard-deviation formula for reproducible old runs. + 本版本将QDK固定到`fd080755eebdd5851d98e2b00830235f39ea5ce7`, 包含共享不可变快照与CAS接口。依赖声明、Python3.10–3.12锁及版本一致性测试 同步使用该提交;旧发布标签保持原版本。 diff --git a/src/quant_factors/core.py b/src/quant_factors/core.py index df81fba..2139e17 100644 --- a/src/quant_factors/core.py +++ b/src/quant_factors/core.py @@ -23,7 +23,7 @@ "volatility_10d": "10-day annualized volatility", "volatility_20d": "20-day annualized volatility", "volatility_60d": "60-day annualized volatility", - "downside_vol_20d": "20-day downside deviation annualized", + "downside_vol_20d": "Legacy std of zero-clipped returns; use downside_deviation_20d_v2", "mean_reversion_z_20d": "Z-score of close vs 20-day mean", "volume_surge_5d": "5d vs 20d volume ratio", "turnover_20d": "20-day average volume", @@ -39,6 +39,7 @@ "average_volume_20d": "20-day average share volume (legacy turnover_20d alias)", "turnover_rate_20d_v2": "20-day mean daily raw shares / PIT raw free-float shares", "amihud_illiq_20d_v2": "20-day absolute economic return / actual traded amount", + "downside_deviation_20d_v2": "20-day RMS negative return, zero target, annualized sqrt(252)", } ) FACTOR_REGISTRY.update(ACADEMIC_REGISTRY) @@ -70,11 +71,18 @@ def volatility(close: pd.Series, window: int = 20) -> pd.Series: def downside_vol(close: pd.Series, window: int = 20) -> pd.Series: + """Historical clipped-return standard deviation; retained for reproducibility.""" ret = close.pct_change() down = ret.where(ret < 0, 0.0) return down.rolling(window).std() * np.sqrt(252) +def downside_deviation(close: pd.Series, window: int = 20) -> pd.Series: + """Zero-target downside deviation over all observed returns, without filling gaps.""" + ret = close.pct_change(fill_method=None) + return np.sqrt(ret.clip(upper=0).pow(2).rolling(window).mean() * 252) + + def mean_reversion_z(close: pd.Series, window: int = 20) -> pd.Series: ma = close.rolling(window).mean() sd = close.rolling(window).std() @@ -155,6 +163,7 @@ def pb_inv(series: pd.Series) -> pd.Series: "volatility_20d": lambda c, v, h, low, pe, pb: volatility(c, 20), "volatility_60d": lambda c, v, h, low, pe, pb: volatility(c, 60), "downside_vol_20d": lambda c, v, h, low, pe, pb: downside_vol(c, 20), + "downside_deviation_20d_v2": lambda c, v, h, low, pe, pb: downside_deviation(c, 20), "mean_reversion_z_20d": lambda c, v, h, low, pe, pb: mean_reversion_z(c, 20), "volume_surge_5d": lambda c, v, h, low, pe, pb: volume_surge(v, 5, 20), "turnover_20d": lambda c, v, h, low, pe, pb: turnover(v, 20), diff --git a/src/quant_factors/families.py b/src/quant_factors/families.py index 2c5a20f..0a68213 100644 --- a/src/quant_factors/families.py +++ b/src/quant_factors/families.py @@ -74,6 +74,7 @@ def _lookup(table: dict[str, tuple[str, ...]], key: str, label: str) -> tuple[st "volatility_20d": ("volatility",), "volatility_60d": ("volatility",), "downside_vol_20d": ("volatility",), + "downside_deviation_20d_v2": ("volatility",), "mean_reversion_z_20d": ("reversal",), "volume_surge_5d": ("liquidity",), "turnover_20d": ("liquidity",), diff --git a/tests/test_downside_definition.py b/tests/test_downside_definition.py new file mode 100644 index 0000000..8490b83 --- /dev/null +++ b/tests/test_downside_definition.py @@ -0,0 +1,33 @@ +import numpy as np +import pandas as pd +import pytest + +from quant_factors.core import DEFAULT_FACTORS, FACTOR_REGISTRY, compute_factors, downside_deviation + + +def test_zero_target_downside_deviation_has_independent_hand_calculation(): + close = pd.Series(100 * 0.99 ** np.arange(21)) + assert downside_deviation(close).iloc[-1] == pytest.approx(0.01 * np.sqrt(252)) + assert downside_deviation(close).iloc[:20].isna().all() + mixed = pd.Series([100, 90, 99, 89.1]) # -10%, +10%, -10% + assert downside_deviation(mixed, 3).iloc[-1] == pytest.approx(np.sqrt(0.02 / 3 * 252)) + + +def test_versioned_factor_preserves_gaps_and_identifies_legacy_definition(): + close = pd.Series([100, 101, np.nan, 103, 104, 105]) + assert downside_deviation(close, 2).iloc[1:5].isna().all() + assert downside_deviation(close, 2).iloc[-1] == 0 + assert "Legacy" in FACTOR_REGISTRY["downside_vol_20d"] + name = "downside_deviation_20d_v2" + assert name not in DEFAULT_FACTORS + frame = pd.DataFrame( + { + "date": pd.date_range("2026-01-01", periods=21), + "symbol": "A", + "close": 100 * 0.99 ** np.arange(21), + "volume": 1, + } + ) + assert compute_factors(frame, factors=[name])[name].iloc[-1] == pytest.approx( + 0.01 * np.sqrt(252) + )