Files
ggx/tests/test_universe.py
T
simon fb6608193b 功能:Web 前端与报告格式化(工作区中此前未提交的工作)
说明:本提交**不是本轮会话所做**,而是工作区里此前遗留的未提交改动。
为把历史分开,先单独提交它,再提交本轮会话的修改。

包含:
- Web 前端:web/index.html、web/app.js(统一 SPA,含回测/画像/Walk-forward 页面)
- 后端接口:web/server.py 路由、web/analysis.py(新增个股分析)
- 报告层:report/format.py(新增统一数字格式化 NumFmt)、
  report/{backtest,profile,sensitivity,universe,walkforward}_report.py 接入 NumFmt、
  report/renderer.py
- 股息率口径:factor/dividend_yield.py(毛刺消除 smooth_spikes)
- 筛选:universe/selector.py、universe/filters/dividend.py
- 绩效/敏感性:analysis/performance.py、analysis/sensitivity.py
- 部署:deploy/install-service.sh
- 测试:tests/test_format.py、tests/test_dividend_smoothing.py(新增)、
  tests/test_web.py、tests/test_universe.py

提交时全量测试 403 项通过。
2026-10-04 12:47:10 +08:00

622 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""单位换算与筛选滤网测试。
这里覆盖的都是**开发过程中真实踩到过**的坑,每个测试对应一次静默错误:
1. ``total_mv`` 是万元而阈值配的是元 → 筛选结果为空(不报错);
2. 拿季报 ROE(年初至今累计)去比「5 年年均 ROE」→ 好公司全被误杀;
3. 银行负债率天然 90%+ → 整个金融板块被误杀;
4. 分红除权晚于 asof → 稳定分红公司被误判为「连续分红 0 年」。
"""
from __future__ import annotations
from datetime import date
import pandas as pd
import pytest
from hdiv.data.units import (
normalize_financial_panel,
normalize_market_panel,
pct_to_ratio,
verify_market_units,
vol_shou_to_shares,
wan_to_shares,
wan_to_yuan,
)
# ---------------------------------------------------------------------------
# 单位换算
# ---------------------------------------------------------------------------
def test_wan_to_yuan() -> None:
assert wan_to_yuan(pd.Series([1.0, 100.0])).tolist() == [1e4, 1e6]
def test_wan_to_shares() -> None:
assert wan_to_shares(pd.Series([125619.78])).iloc[0] == pytest.approx(1.2561978e9)
def test_pct_to_ratio() -> None:
assert pct_to_ratio(pd.Series([5.08])).iloc[0] == pytest.approx(0.0508)
def test_vol_shou_to_shares() -> None:
assert vol_shou_to_shares(pd.Series([100])).iloc[0] == 10000
def test_normalize_market_panel_units() -> None:
"""茅台 2024-06-28 实测值:总市值 1.84 万亿元,总股本 12.56 亿股。"""
df = pd.DataFrame(
{
"symbol": ["600519.SH"],
"close": [1467.39],
"total_share": [125619.78], # 万股
"total_mv": [184333208.97], # 万元
"circ_mv": [184333208.97],
"dv_ttm": [5.1720], # 百分数
"turnover_rate": [0.25],
}
)
out = normalize_market_panel(df)
assert out["total_mv"].iloc[0] == pytest.approx(1.8433320897e12, rel=1e-6)
assert out["total_share"].iloc[0] == pytest.approx(1.2561978e9, rel=1e-9)
assert out["dv_ttm"].iloc[0] == pytest.approx(0.051720)
assert out["_units"].iloc[0] == "yuan/shares/ratio"
def test_normalize_market_panel_does_not_mutate_input() -> None:
df = pd.DataFrame({"total_mv": [100.0], "total_share": [10.0]})
before = df.copy()
normalize_market_panel(df)
pd.testing.assert_frame_equal(df, before)
def test_normalize_financial_panel_converts_pct() -> None:
df = pd.DataFrame({"roe": [15.13], "debt_to_assets": [90.23], "total_revenue": [1.78e11]})
out = normalize_financial_panel(df)
assert out["roe"].iloc[0] == pytest.approx(0.1513)
assert out["debt_to_assets"].iloc[0] == pytest.approx(0.9023)
assert out["total_revenue"].iloc[0] == 1.78e11, "金额列本就以元计,不得被换算"
def test_verify_market_units_detects_correct_data() -> None:
df = pd.DataFrame(
{
"close": [10.0, 20.0],
"total_share": [1e9, 5e8],
"total_mv": [1e10, 1e10],
}
)
v = verify_market_units(df)
assert v["checked"] == 2 and v["bad"] == 0
assert v["median_ratio"] == pytest.approx(1.0)
def test_identity_alone_cannot_detect_wan_vs_yuan() -> None:
"""恒等式对「整体万元/元混淆」无效 —— 因为 元/股 × 万股 = 万元。
这是一个容易误以为「有了恒等式检查就安全」的陷阱,必须显式记录:
原始单位下比值仍然恰好是 1。
"""
raw = pd.DataFrame(
{"close": [1467.39], "total_share": [125619.78], "total_mv": [184333208.97]}
)
v = verify_market_units(raw)
assert v["identity_ok"] is True, "恒等式在原始单位下同样成立"
assert v["unit_ok"] is False, "但绝对量级检查必须发现单位错误"
assert v["detected_unit"] == "wan"
def test_verify_market_units_accepts_normalized_yuan() -> None:
norm = normalize_market_panel(
pd.DataFrame(
{"close": [1467.39], "total_share": [125619.78], "total_mv": [184333208.97]}
)
)
v = verify_market_units(norm)
assert v["unit_ok"] is True and v["identity_ok"] is True
assert v["detected_unit"] == "yuan"
def test_verify_market_units_detects_partial_conversion() -> None:
"""只换算一个字段(常见疏漏)也必须被抓出来。"""
df = pd.DataFrame(
{"close": [10.0], "total_share": [1e9], "total_mv": [1e10 / 1e4]} # 市值漏换算
)
v = verify_market_units(df)
assert v["bad"] == 1
def test_verify_market_units_empty() -> None:
assert verify_market_units(pd.DataFrame())["checked"] == 0
# ---------------------------------------------------------------------------
# 滤网:行业豁免
# ---------------------------------------------------------------------------
class _FakeRepo:
def __init__(self, st: set[str] | None = None, susp: set[str] | None = None) -> None:
self._st = st or set()
self._susp = susp or set()
def st_symbols(self, asof, include_delisting=True): # noqa: ANN001, ARG002
return self._st
def suspended_on(self, asof): # noqa: ANN001, ARG002
return self._susp
def _frame(**kwargs) -> pd.DataFrame:
base = {
"symbol": ["600036.SH", "601088.SH"],
"name": ["招商银行", "中国神华"],
"industry": ["银行", "煤炭开采"],
"market": ["主板", "主板"],
"exchange": ["SSE", "SSE"],
"listed_years": [30.0, 17.0],
"is_fresh": [True, True],
"total_mv": [8.6e11, 8.8e11],
"circ_mv": [7.0e11, 7.3e11],
"avg_amount": [1e9, 8e8],
"close": [34.19, 44.37],
"debt_ratio": [0.902, 0.236],
"total_hldr_eqy_exc_min_int": [3.5e11, 4.0e11],
}
base.update(kwargs)
return pd.DataFrame(base)
def test_risk_filter_exempts_banks_from_leverage() -> None:
from hdiv.core.config import RiskFilterConfig
from hdiv.universe.filters.risk import RiskFilter
f = RiskFilter(RiskFilterConfig(max_debt_to_assets=0.80), exempt_leverage=["银行"])
out = f.compute(_frame(), _FakeRepo(), date(2024, 6, 28))
assert bool(out.passed.iloc[0]) is True, "银行必须豁免负债率上限"
assert bool(out.passed.iloc[1]) is True, "低负债公司自然通过"
assert out.values["600036.SH"]["debt_ratio_exempt"] is True
def test_risk_filter_still_rejects_high_leverage_non_exempt() -> None:
from hdiv.core.config import RiskFilterConfig
from hdiv.universe.filters.risk import RiskFilter
f = RiskFilter(RiskFilterConfig(max_debt_to_assets=0.80), exempt_leverage=["银行"])
df = _frame(industry=["房地产", "煤炭开采"])
out = f.compute(df, _FakeRepo(), date(2024, 6, 28))
assert bool(out.passed.iloc[0]) is False
assert "资产负债率" in out.reasons["600036.SH"]
def test_quality_filter_exempts_banks_from_fcf_and_leverage() -> None:
from hdiv.core.config import QualityFilterConfig
from hdiv.universe.filters.quality import FinancialQualityFilter
cfg = QualityFilterConfig(min_ocf_to_profit=0.60, max_debt_to_assets=0.80)
f = FinancialQualityFilter(cfg, exempt_leverage=["银行"], exempt_fcf=["银行"])
df = _frame(roe_avg=[0.1513, 0.1406], ocf_to_profit=[-0.03, 1.80])
out = f.compute(df, _FakeRepo(), date(2024, 6, 28))
assert bool(out.passed.iloc[0]) is True, "银行豁免 FCF 与负债率"
def test_quality_filter_uses_annual_average_not_quarterly() -> None:
"""季报 ROE 3.47% 不该被拿去比年均 8% 的阈值。"""
from hdiv.core.config import QualityFilterConfig
from hdiv.universe.filters.quality import FinancialQualityFilter
cfg = QualityFilterConfig(min_roe_5y_avg=0.08, min_ocf_to_profit=None)
f = FinancialQualityFilter(cfg)
df = _frame(
industry=["白酒", "煤炭开采"],
roe=[0.0347, 0.0380], # 季报累计值(会被 avg 覆盖)
roe_avg=[0.1513, 0.1406], # 年报 5 年平均
)
out = f.compute(df, _FakeRepo(), date(2024, 6, 28))
assert bool(out.passed.iloc[0]) is True, "应使用 roe_avg 而非季报 roe"
assert out.values["600036.SH"]["roe"] == pytest.approx(0.1513)
def test_quality_filter_falls_back_to_latest_when_no_average() -> None:
from hdiv.core.config import QualityFilterConfig
from hdiv.universe.filters.quality import FinancialQualityFilter
cfg = QualityFilterConfig(min_roe_5y_avg=0.08, min_ocf_to_profit=None)
f = FinancialQualityFilter(cfg)
df = _frame(industry=["白酒", "煤炭开采"], roe=[0.20, 0.03]) # 无 roe_avg 列
out = f.compute(df, _FakeRepo(), date(2024, 6, 28))
assert bool(out.passed.iloc[0]) is True
assert bool(out.passed.iloc[1]) is False
# ---------------------------------------------------------------------------
# 分红连续性:一年宽限期
# ---------------------------------------------------------------------------
def _div(symbol: str, end_year: int, ex_year: int, dps: float = 1.0, month: int = 7) -> dict:
return {
"symbol": symbol,
"end_date": date(end_year, 12, 31),
"imp_ann_date": date(ex_year, month - 1 if month > 1 else 12, 1),
"div_proc": "实施",
"cash_div_tax": dps,
"cash_div": dps,
"stk_div": None,
"base_share": 10000.0,
"ex_date": date(ex_year, month, 15),
}
def test_continuity_within_target_year() -> None:
"""FY2023 分红已在 2024-05 除权 → target=2023,连续 5 年。"""
from hdiv.core.config import DividendFilterConfig
from hdiv.universe.filters.dividend import DividendFilter
recs = {2023: 2024, 2022: 2023, 2021: 2022, 2020: 2021, 2019: 2020}
rows = [_div("600036.SH", fy, ex, month=5) for fy, ex in recs.items()]
s = DividendFilter._stats(rows, target_year=2023, asof=date(2024, 6, 28),
cfg=DividendFilterConfig())
assert s["dividend_continuity_years"] == 5
assert s["continuity_grace_used"] is False
def test_continuity_grace_when_ex_date_lags() -> None:
"""神华实测情形:FY2023 分红要 2024-07 才除权,asof=2024-06-28 时不可见。
此时必须用一年宽限期从 FY2022 起算,而不是判定为「中断分红」。
"""
from hdiv.core.config import DividendFilterConfig
from hdiv.universe.filters.dividend import DividendFilter
rows = [_div("601088.SH", fy, fy + 1, month=7) for fy in (2022, 2021, 2020, 2019, 2018)]
s = DividendFilter._stats(rows, target_year=2023, asof=date(2024, 6, 28),
cfg=DividendFilterConfig())
assert s["latest_dividend_year"] == 2022
assert s["continuity_start_year"] == 2022
assert s["continuity_grace_used"] is True
assert s["dividend_continuity_years"] == 5, "不应因为除权晚而误判中断"
def test_continuity_zero_when_genuinely_stopped() -> None:
"""最近可见分红比应考核财年早两年以上 → 视为真的中断。"""
from hdiv.core.config import DividendFilterConfig
from hdiv.universe.filters.dividend import DividendFilter
rows = [_div("000002.SZ", fy, fy + 1) for fy in (2019, 2018, 2017, 2016, 2015)]
s = DividendFilter._stats(rows, target_year=2023, asof=date(2024, 6, 28),
cfg=DividendFilterConfig())
assert s["dividend_continuity_years"] == 0
def test_continuity_breaks_on_gap() -> None:
"""有断档:2022 有、2021 无 → 连续 1 年。"""
from hdiv.core.config import DividendFilterConfig
from hdiv.universe.filters.dividend import DividendFilter
rows = [_div("X.SZ", fy, fy + 1) for fy in (2022, 2020, 2019)]
s = DividendFilter._stats(rows, target_year=2023, asof=date(2024, 6, 28),
cfg=DividendFilterConfig())
assert s["dividend_continuity_years"] == 1
def test_ttm_dps_only_counts_ex_date_in_window() -> None:
from hdiv.core.config import DividendFilterConfig
from hdiv.universe.filters.dividend import DividendFilter
rows = [
_div("X.SZ", 2023, 2024, dps=1.5, month=4), # 窗口内
_div("X.SZ", 2022, 2023, dps=1.2, month=7), # 窗口内(>2023-06-28)
_div("X.SZ", 2021, 2022, dps=1.0, month=7), # 窗口外
]
s = DividendFilter._stats(rows, target_year=2023, asof=date(2024, 6, 28),
cfg=DividendFilterConfig())
assert s["ttm_dps"] == pytest.approx(2.7)
def test_shift_year_handles_leap_day() -> None:
from hdiv.universe.filters.dividend import _shift_year
assert _shift_year(date(2024, 2, 29), -1) == date(2023, 2, 28)
assert _shift_year(date(2024, 6, 28), -1) == date(2023, 6, 28)
def test_target_year_uses_latest_annual_report() -> None:
"""最新年报为 FY2023 时,考核目标年应为 2023。"""
from hdiv.core.config import DividendFilterConfig
from hdiv.universe.filters.dividend import DividendFilter
df = pd.DataFrame(
{
"symbol": ["A.SH", "B.SH"],
"fin_end_date": [date(2024, 3, 31), date(2023, 12, 31)],
}
)
t = DividendFilter._target_years(df, date(2024, 6, 28))
# 能看到 2024Q1 报,说明 FY2023 年报必然已披露 → 目标年 2023
assert t["A.SH"] == 2023, "有 2024Q1 报 → FY2023 年报已出"
assert t["B.SH"] == 2023, "有 FY2023 年报 → 目标是 2023"
df2 = pd.DataFrame({"symbol": ["C.SH"], "fin_end_date": [date(2023, 9, 30)]})
assert DividendFilter._target_years(df2, date(2024, 6, 28))["C.SH"] == 2022, (
"只看到 2023Q3 → FY2023 年报未出,退回到 2022"
)
def test_payout_ratio_uses_base_share() -> None:
"""总现金分红 = 每股分红 × 基准股本(万股→股)。"""
from hdiv.core.config import DividendFilterConfig
from hdiv.universe.filters.dividend import DividendFilter
rows = [
{
"symbol": "X.SH", "end_date": date(2023, 12, 31), "ex_date": date(2024, 6, 1),
"div_proc": "实施", "cash_div_tax": 2.0, "base_share": 10000.0, # 1 亿股
}
]
row = pd.Series({"n_income_attr_p": 4e8, "free_cashflow": 8e8})
out = DividendFilter._payout_and_cover(rows, row, date(2024, 6, 28),
DividendFilterConfig())
assert out["total_cash_dividend"] == pytest.approx(2.0 * 10000.0 * 1e4)
assert out["payout_ratio"] == pytest.approx(0.5)
# 总现金分红 = 2.0 元 × 10000 万股 × 1e4 = 2e8 元;FCF 8e8 → 覆盖 4 倍
assert out["fcf_dividend_cover"] == pytest.approx(4.0)
def test_fcf_fallback_when_tushare_missing() -> None:
from hdiv.core.config import DividendFilterConfig
from hdiv.universe.filters.dividend import DividendFilter
rows = [{
"symbol": "X.SH", "end_date": date(2023, 12, 31), "ex_date": date(2024, 6, 1),
"div_proc": "实施", "cash_div_tax": 1.0, "base_share": 1000.0,
}]
row = pd.Series({
"n_income_attr_p": 5e6, "free_cashflow": None,
"n_cashflow_act": 1e7, "c_pay_dist_dpcp_int_exp": 2e6,
})
out = DividendFilter._payout_and_cover(rows, row, date(2024, 6, 28),
DividendFilterConfig())
assert out["fcf_source"] == "ocf_minus_dist"
assert out["free_cashflow"] == pytest.approx(8e6)
# ---------------------------------------------------------------------------
# 滤网结果索引契约(曾经把 DataFrame 索引当成 symbol 用的真实 bug)
# ---------------------------------------------------------------------------
def test_filter_outcome_index_is_dataframe_index_not_symbol() -> None:
"""滤网的 passed 索引必须与传入 DataFrame 的索引一致。
选择器据此用 ``live.at[i, "symbol"]`` 映射;
若误把索引当 symbol,会导致「全部淘汰」(本项目开发中确实发生过)。
"""
from hdiv.core.config import MarketFilterConfig
from hdiv.universe.filters.market import MarketFilter
df = _frame()
df.index = [10, 20] # 非默认索引
f = MarketFilter(MarketFilterConfig(min_market_cap=1e10))
out = f.compute(df, _FakeRepo(), date(2024, 6, 28))
assert list(out.passed.index) == [10, 20]
assert set(out.passed.values) <= {True, False}
# ---------------------------------------------------------------------------
# 数据缺失策略(安全边际策略的关键取舍)
# ---------------------------------------------------------------------------
def _div_cfg(**kw):
from hdiv.core.config import DividendFilterConfig
base = {
"min_dividend_yield": None,
"min_continuous_years": 0,
"min_dividend_years_in_window": 0,
"max_payout_ratio": None,
"require_positive_fcf": True,
"min_fcf_dividend_cover": None,
}
base.update(kw)
return DividendFilterConfig(**base)
def test_missing_fcf_passes_by_default() -> None:
"""默认宽松:数据缺失放行,避免因未同步而误杀。"""
from hdiv.universe.filters.dividend import DividendFilter
cfg = _div_cfg(on_missing_data="pass")
stats = {"free_cashflow": None, "dividend_continuity_years": 0,
"dividend_years_in_window": 0, "dividend_yield": 0.05}
assert DividendFilter._reject_reason(stats, cfg) is None
def test_missing_fcf_rejected_when_strict() -> None:
"""严格模式:无法验证现金流即淘汰 —— 忠于「安全边际」的策略逻辑。"""
from hdiv.universe.filters.dividend import DividendFilter
cfg = _div_cfg(on_missing_data="reject")
stats = {"free_cashflow": None, "dividend_continuity_years": 0,
"dividend_years_in_window": 0, "dividend_yield": 0.05}
why = DividendFilter._reject_reason(stats, cfg)
assert why is not None and "缺失" in why
def test_negative_fcf_rejected_in_both_modes() -> None:
"""FCF 为负时两种模式都必须淘汰 —— 宽松不等于放行已知风险。"""
from hdiv.universe.filters.dividend import DividendFilter
stats = {"free_cashflow": -1e8, "dividend_continuity_years": 0,
"dividend_years_in_window": 0, "dividend_yield": 0.05}
for mode in ("pass", "reject"):
why = DividendFilter._reject_reason(stats, _div_cfg(on_missing_data=mode))
assert why is not None and "负" in why, f"{mode} 模式必须拒绝负 FCF"
def test_missing_coverage_strict_only() -> None:
from hdiv.universe.filters.dividend import DividendFilter
stats = {"free_cashflow": 1e8, "fcf_dividend_cover": None,
"dividend_continuity_years": 0, "dividend_years_in_window": 0,
"dividend_yield": 0.05}
assert DividendFilter._reject_reason(
stats, _div_cfg(min_fcf_dividend_cover=1.0, on_missing_data="pass")
) is None
assert DividendFilter._reject_reason(
stats, _div_cfg(min_fcf_dividend_cover=1.0, on_missing_data="reject")
) is not None
def test_on_missing_data_invalid_value_rejected() -> None:
from hdiv.core.errors import SchemaValidationError
with pytest.raises(Exception) as ei:
_div_cfg(on_missing_data="maybe")
assert "on_missing_data" in str(ei.value) or "Input should be" in str(ei.value)
del SchemaValidationError
# ---------------------------------------------------------------------------
# 分红支付率必须与分红**同财年**(真实踩到的错误)
# ---------------------------------------------------------------------------
def test_payout_uses_same_fiscal_year_not_latest_quarter() -> None:
"""回归:曾用「FY2023 分红 ÷ 2024Q1 净利润」算出 230.9% 的荒谬支付率。
正确口径下美的集团 FY2023 为:分红 207.8 亿 ÷ 净利 337.2 亿 = 61.63%。
"""
from hdiv.core.config import DividendFilterConfig
from hdiv.universe.filters.dividend import DividendFilter
recs = [{
"symbol": "000333.SZ", "end_date": date(2023, 12, 31),
"ex_date": date(2024, 5, 15), "div_proc": "实施",
"cash_div_tax": 3.0, "base_share": 692675.9241,
}]
# 同财年(FY2023)财务
fy_row = pd.Series({
"n_income_attr_p": 3.372e10, "free_cashflow": 7.16e10,
})
out = DividendFilter._payout_and_cover(
recs, fy_row, date(2024, 6, 28), DividendFilterConfig()
)
assert out["financial_year"] == 2023
assert out["payout_ratio"] == pytest.approx(207.8 / 337.2, abs=0.01)
assert out["payout_basis"] == "same_fiscal_year"
def test_payout_not_computed_without_same_year_row() -> None:
"""缺少同财年财务时必须返回「不可得」,而不是退回到最新季报。"""
from hdiv.core.config import DividendFilterConfig
from hdiv.universe.filters.dividend import DividendFilter
recs = [{
"symbol": "X.SZ", "end_date": date(2023, 12, 31),
"ex_date": date(2024, 5, 15), "div_proc": "实施",
"cash_div_tax": 1.0, "base_share": 10000.0,
}]
out = DividendFilter._payout_and_cover(
recs, None, date(2024, 6, 28), DividendFilterConfig()
)
assert out["payout_ratio"] is None
assert out["fcf_dividend_cover"] is None
assert out["payout_basis"] == "missing_same_year_financials"
def test_total_cash_dividend_uses_base_share() -> None:
"""base_share 是**万股**,漏乘 1e4 会把支付率缩小一万倍。"""
from hdiv.core.config import DividendFilterConfig
from hdiv.universe.filters.dividend import DividendFilter
recs = [{
"symbol": "X.SZ", "end_date": date(2023, 12, 31),
"ex_date": date(2024, 5, 15), "div_proc": "实施",
"cash_div_tax": 2.0, "base_share": 10000.0, # 1 亿股
}]
row = pd.Series({"n_income_attr_p": 4e8, "free_cashflow": 8e8})
out = DividendFilter._payout_and_cover(
recs, row, date(2024, 6, 28), DividendFilterConfig()
)
assert out["total_cash_dividend"] == pytest.approx(2.0 * 10000.0 * 1e4)
assert out["payout_ratio"] == pytest.approx(0.5)
def test_dividend_records_include_base_share() -> None:
"""回归:repo.dividend_records 曾漏选 base_share,
导致 payout_ratio 与 fcf_dividend_cover 在全库范围内静默为 NULL,
进而使 max_payout_ratio / min_fcf_dividend_cover 两个筛选条件从未生效。
"""
import inspect
from hdiv.data.repo import Repo
src = inspect.getsource(Repo.dividend_records)
assert "base_share" in src, "dividend_records 必须选出 base_share"
# ---------------------------------------------------------------------------
# 重跑覆盖同一条记录
# ---------------------------------------------------------------------------
def test_universe_run_id_is_deterministic() -> None:
"""回归:run_id 不得含时间戳,否则同参数重跑会不断累积重复记录。
早期实现把 datetime.now() 编进指纹,同一 asof 最多累积了 11 条内容相同的记录。
现在的语义是「同一份配置 + 同一时点 → 同一个 run_id → 重跑原地覆盖」。
"""
import inspect
from hdiv.universe import selector
src = inspect.getsource(selector.UniverseSelector)
i = src.find("run_id = stable_id(")
assert i != -1, "未找到 run_id 生成处"
# 取到该语句结束的分号行(不能用第一个 ')',那会截断在 config_hash(self.config) 里)
end = src.find("\n )", i)
assert end != -1, "未找到 run_id 语句结尾"
block = src[i:end]
assert "datetime.now" not in block, f"run_id 指纹仍含时间戳:{block}"
for must in ("config_hash", "effective", "self.config.name"):
assert must in block, f"run_id 指纹缺少 {must}:{block}"
@pytest.mark.db
def test_universe_rerun_overwrites_same_record() -> None:
"""同参数重跑不新增记录,且成员行数等于候选数(无重复堆积)。"""
from hdiv.core.config import load_config
from hdiv.data import db
from hdiv.data.sync.base import stable_id
db.load_dotenv_once()
cfg = load_config("datasource")
df = db.read_sql(
"SELECT r.run_id, r.candidate_count, r.asof_date, r.config_hash, r.name, "
" COUNT(m.id) AS member_rows "
"FROM hd_universe_run r LEFT JOIN hd_universe_member m ON m.run_id = r.run_id "
"GROUP BY r.run_id HAVING member_rows > 0 "
"ORDER BY r.created_at DESC LIMIT 5",
cfg=cfg,
)
if df.empty:
pytest.skip("没有筛选记录")
checked = 0
for _, r in df.iterrows():
expect = stable_id("universe", r["name"], str(r["asof_date"]), r["config_hash"])
if r["run_id"] != expect:
continue # 确定化之前的历史记录,跳过
checked += 1
assert int(r["member_rows"]) == int(r["candidate_count"]), (
f"run_id={r['run_id'][:10]} 成员行数 {r['member_rows']} "
f"应等于候选数 {r['candidate_count']}(出现重复堆积)"
)
assert checked > 0, "未找到确定化之后生成的筛选记录,无法验证"