Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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锁及版本一致性测试
同步使用该提交;旧发布标签保持原版本。
Expand Down
11 changes: 10 additions & 1 deletion src/quant_factors/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions src/quant_factors/families.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",),
Expand Down
33 changes: 33 additions & 0 deletions tests/test_downside_definition.py
Original file line number Diff line number Diff line change
@@ -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)
)
Loading