feat(ak): 申万一级行业成份快照域 sw_industry——sw_index_first_info(31)+index_component_sw逐行业(成份+最新权重+计入日期,~5214行/夜37s实测);快照语义对齐ths_industry(§15.2);严格整体语义=任一行业失败整体failed次夜重跑;单unit 32连发内部0.8s逐发间隔防封;锚点600519∈801120食品饮料(权重39.8866/计入2021-12-13)实测入测试;TDD 6新测试336全绿 [vps]
CI/CD / test (push) Successful in 33s
CI/CD / nas-deploy (push) Successful in 0s
CI/CD / nas-verify (push) Successful in 17s

This commit is contained in:
2026-09-12 09:08:41 +08:00
parent 8f3ce48d1e
commit b24f72da1f
3 changed files with 143 additions and 1 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ $ts = Get-Date -Format 'yyyyMMdd_HHmmss'
$logDir = 'C:\sanguo_vnpy_v2\data\migration_logs'
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
$log = Join-Path $logDir "ak_events_$ts.txt"
C:\Python310\python.exe -X utf8 C:\sanguo_vnpy_v2\scripts\data_platform\akshare_static_download.py --types dragon_tiger,block_trade,restricted,zt_pool,zt_pool_zbgc,zt_pool_dtgc,fund_flow_industry,fund_flow_concept,ths_industry,ths_concept,xueqiu_hot,sina_sector,hot_rank --start $today --end $today *>> $log
C:\Python310\python.exe -X utf8 C:\sanguo_vnpy_v2\scripts\data_platform\akshare_static_download.py --types dragon_tiger,block_trade,restricted,zt_pool,zt_pool_zbgc,zt_pool_dtgc,fund_flow_industry,fund_flow_concept,ths_industry,ths_concept,xueqiu_hot,sina_sector,hot_rank,sw_industry --start $today --end $today *>> $log
$rc1 = $LASTEXITCODE
C:\Python310\python.exe -X utf8 C:\sanguo_vnpy_v2\scripts\data_platform\akshare_static_download.py --types margin_sse --start $yesterday --end $today *>> $log
$rc2 = $LASTEXITCODE
@@ -186,6 +186,11 @@ PER_DATE_TYPES = (
"hot_rank", # 东财 stock_hot_rank_em() 人气榜 ~100 行
"xueqiu_hot", # 雪球 stock_hot_follow_xq('最热门') 关注热度 ~5633 行
"sina_sector", # 新浪 stock_sector_spot() 行业 49 行 (同花顺 90 行的对账源)
# 申万一级行业成份快照 (融合 spec §20.2, 2026-09-12 四域扩展):
# sw_index_first_info(31) + index_component_sw×31 = 成份+最新权重+计入日期
# (~5,400 行/夜); 快照语义同上 (无历史, date 仅命名); 变更史消费法=
# 逐日快照 diff + 计入日期列。锚点: 600519 ∈ 801120 食品饮料。
"sw_industry",
)
# 模式 C: per-period 类型 (2 类)
PER_PERIOD_TYPES = (
@@ -1040,6 +1045,49 @@ def fetch_sina_sector(date: str) -> Optional[pd.DataFrame]:
return df
# 申万域锚点 (2026-09-12 实测, spec §20.5): 600519 贵州茅台 ∈ 801120 食品饮料
SW_ANCHOR_STOCK = "600519"
SW_ANCHOR_INDUSTRY_CODE = "801120"
# 单 unit 内 31 行业连发 → 内部逐发间隔 (防封纪律, 对齐 AK_INTERVAL)
SW_INDUSTRY_INTERVAL = 0.8
def fetch_sw_industry(date: str) -> Optional[pd.DataFrame]:
"""sw_index_first_info + index_component_sw×31 — 申万一级行业成份快照。
融合 spec §20.2: 行=股票×一级行业 (~5,400), 列含 最新权重+计入日期
(计入日期自带部分变更史, 2021-12-13 大搬迁类事件可考)。快照域语义
(§15.2): 端点无 date 参数无历史回补, date 仅命名文件。
严格整体语义: 任一行业成分失败/为空 → 整体 None (failed), 次夜全量
重跑 (32 发便宜, 不写残缺快照)。
单 fetch 总时长超 AK_TIMEOUT, 故逐行业独立包 call_ak_with_retry
(每行业 30s 预算) 而非整体包装。"""
idx_df, _st = call_ak_with_retry(
ak.sw_index_first_info, f"sw_industry/{date}/first_info",
)
if idx_df is None or idx_df.empty:
return None
frames: List[pd.DataFrame] = []
for _, row in idx_df.iterrows():
code_si = str(row["行业代码"])
sym = code_si.split(".")[0] # index_component_sw 吃去后缀码
time.sleep(SW_INDUSTRY_INTERVAL)
cons, _st2 = call_ak_with_retry(
ak.index_component_sw,
f"sw_industry/{date}/{sym}", symbol=sym,
)
if cons is None or cons.empty:
logger.warning(
"[sw_industry] %s(%s) 成分失败/为空 → 整体 failed",
sym, row["行业名称"])
return None
cons = cons.copy()
cons["行业代码"] = code_si
cons["行业名称"] = row["行业名称"]
frames.append(cons)
return pd.concat(frames, ignore_index=True)
def fetch_gdhs(period: str) -> Optional[pd.DataFrame]:
"""stock_zh_a_gdhs(symbol=period) — 股东户数全市场按期单次 (~5342 行)。"""
df, _status = call_ak_with_retry(
@@ -1543,6 +1591,7 @@ def run_type_dispatch(
"hot_rank": fetch_hot_rank,
"xueqiu_hot": fetch_xueqiu_hot,
"sina_sector": fetch_sina_sector,
"sw_industry": fetch_sw_industry,
}
units = build_per_date_units(t, fetch_map[t], args)
return run_one_type(t, units, args)
@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
"""申万一级行业成份快照域 (sw_industry) 注册与 fetch 语义测试。
融合 spec §20.2 (2026-09-12 四域扩展):
- sw_index_first_info (31 一级, 行业代码带 .SI 后缀) +
index_component_sw(symbol=去后缀码)×31 → 成份+最新权重+计入日期
- 快照域语义 (§15.2): 无 date 参数无历史, date 仅命名文件
- 严格整体语义: 任一行业成分失败 → 整体 failed (None), 次夜全量重跑
- 单 unit 内 32 连发 → 内部逐发间隔 (防封纪律), 测试中 monkeypatch sleep
"""
import pandas as pd
import akshare_static_download as mod # noqa: E402 (conftest 已加 sys.path)
def _idx():
return pd.DataFrame({
"行业代码": ["801010.SI", "801120.SI"],
"行业名称": ["农林牧渔", "食品饮料"],
"成份个数": [2, 1],
})
def _cons(sym, n):
return pd.DataFrame({
"序号": list(range(1, n + 1)),
"证券代码": [f"60{i:04d}" for i in range(n)],
"证券名称": [f"股{i}" for i in range(n)],
"最新权重": [1.0] * n,
"计入日期": ["2021-12-13"] * n,
})
class TestSwIndustryRegistered:
def test_in_per_date_types(self):
assert "sw_industry" in mod.PER_DATE_TYPES
def test_anchor_moutai_food_beverage(self):
"""600519 ∈ 801120 食品饮料 (2026-09-12 实测锚点, spec §20.5)。"""
assert mod.SW_ANCHOR_STOCK == "600519"
assert mod.SW_ANCHOR_INDUSTRY_CODE == "801120"
class TestFetchSwIndustry:
def test_concat_all_industries(self, monkeypatch):
monkeypatch.setattr(mod.time, "sleep", lambda s: None)
monkeypatch.setattr(mod.ak, "sw_index_first_info", _idx)
monkeypatch.setattr(
mod.ak, "index_component_sw",
lambda symbol: _cons(symbol, 1 if symbol == "801120" else 2))
df = mod.fetch_sw_industry("20260911")
assert len(df) == 3
assert set(df["行业代码"]) == {"801010.SI", "801120.SI"}
# 证券代码不带后缀 (直接入库形态)
assert "600000" in set(df["证券代码"].astype(str))
assert "计入日期" in df.columns
def test_any_industry_failure_fails_whole(self, monkeypatch):
"""任一行业成分失败 → 整体 None (failed, 不写残缺快照)。"""
monkeypatch.setattr(mod.time, "sleep", lambda s: None)
monkeypatch.setattr(mod.ak, "sw_index_first_info", _idx)
def boom(symbol):
if symbol == "801120":
raise ValueError("akshare 挂死")
return _cons(symbol, 2)
monkeypatch.setattr(mod.ak, "index_component_sw", boom)
df = mod.fetch_sw_industry("20260911")
assert df is None
def test_first_info_failure_returns_none(self, monkeypatch):
monkeypatch.setattr(mod.time, "sleep", lambda s: None)
def boom():
raise ValueError("akshare 挂死")
monkeypatch.setattr(mod.ak, "sw_index_first_info", boom)
assert mod.fetch_sw_industry("20260911") is None
def test_internal_throttle_between_calls(self, monkeypatch):
"""单 unit 32 连发必须有内部间隔 (防封纪律)。"""
sleeps = []
monkeypatch.setattr(mod.time, "sleep", lambda s: sleeps.append(s))
monkeypatch.setattr(mod.ak, "sw_index_first_info", _idx)
monkeypatch.setattr(
mod.ak, "index_component_sw",
lambda symbol: _cons(symbol, 1))
mod.fetch_sw_industry("20260911")
assert len(sleeps) >= 2 # 31 行业至少逐发间隔
assert all(s >= 0.8 for s in sleeps)