a8ea132924
medium effort code-review 暴露 8 个 finding,按 P0/P1/P2 优先级修复: P0 (数据正确性 / 跑得起来): #1 replace_all_node_categories 改 scope-aware delete 之前无条件 delete(NodeCategory) + 插入 runtime filter 后的子集, 导致 --type2 0 跑把其它 6 个 category 误删。改为只 delete 本批 category_key 集合,其它 type2 不动。 #2 replace_all_stock_node_map 对称化 之前空 rows 时 early-return 留下 stale,导致「categories 新 但 mappings 旧」不一致。改为 scope-aware delete 同 #1。 #3 stock_node systemd --workers 20 → 8 20 workers × 默认 10 RPS = 200 RPS,撞穿 mairui 钻石档 100 RPS 上限,触发风控。改为 8 × 10 = 80 RPS,留 20% buffer。 P1 (静默错): #4 is_a_share_code 接 hermes 格式 之前硬性要求 len(c)==6,iter_stock_codes 改返 hermes 时 5 个 task (moneyflow/share_snapshot/kline_5min/tick_trade/kline_daily) 会静默过滤成空 list。剥前缀再判断,兼容 'SH600519'。 #5 mairui tz 契约钉在 source 层 tz_localize 改为「已带 tz 就保留,没有再标 Asia/Shanghai」, 避免 mairui 改格式时抛 TypeError。task_tick_trade 删掉 strftime fallback + 冗长注释(契约已在 source 层 docstring)。 #6 CLI inspect.signature 过滤 kwargs --type2 / --backfill 加在共享 p_sync parser 上,任何 task 通过 **kwargs 静默吞掉。改为 dispatcher 按 task._run 签名过滤, 不支持的参数打 warning。 P2 (维护): #7 task_stock_node --type2 输入校验 之前空字符串 / 「concept」 / 99 都 silently collapse 成空 set, 走「无匹配叶子节点」warning 分支(被 daily_check 当正常)。 CLI 不再 silent-drop,任务层加 strict 校验,typo 返 status=error。 #8 code6_to_exchange 删 3 个死分支 4 个 code6_to_* wrapper 都先 _strip_hermes,3 个 hermes if 分支 走不到。删除后 function 简化,所有 caller 行为不变。 附带: - tests/test_smoke.py: 11 → 13 (含 mairui_ma_daily + stock_node) - tests/test_schema_models.py: 21 → 22 (含 kline_stock_ma_daily + node_categories/nodes/stock_node_map) - bin/market_sync_stock_node_run.sh 注释补充 RPS 计算 验证: - pytest tests/ 11 passed - is_a_share_code 8 个 hermes 边界 case 全过 - cli sync kline_daily --type2 2,3 → warning 已打印 - cli sync stock_node --type2 concept → status=error - cli sync stock_node --type2 99 → status=error - cli sync stock_node --type2 0 → 仅 0:0 行被刷新,其它 6 类不动 - fetch_tick_trade('600519') 返回 tz=Asia/Shanghai +08:00
257 lines
11 KiB
Python
257 lines
11 KiB
Python
"""同步任务:股票-指数/行业/概念 映射(mairui `/hszg` 三接口)。
|
||
|
||
数据源:
|
||
- GET /hszg/list/{licence} 节点树(1464 节点,A 股+港股+基金...)
|
||
- GET /hszg/gg/{node_code}/{licence} 节点→成分股
|
||
|
||
设计要点:
|
||
1) 每周六 11:30 触发(mairui 11:00 更新后留 30 min buffer)
|
||
2) 覆盖范围:A 股(type1=0) 白名单 type2: 0/1/2/3/4/6/7
|
||
- 0 = 申万一级行业 (31)
|
||
- 1 = 申万二级行业 (131)
|
||
- 2 = 热门概念 (698)
|
||
- 3 = 概念板块 (214)
|
||
- 4 = 地域板块 (31)
|
||
- 6 = 板块分类 沪/深/北 (16)
|
||
- 7 = 指数成分 (43)
|
||
跳过 type2 = 5/8/9/10(证监会行业与 baostock 重复;风险警示/大盘指数/次新股 是单节点)
|
||
3) 用 /hszg/gg 反查路径(1464 调用)比 /hszg/zg 正向(5206 调用)快 5 倍
|
||
4) ThreadPoolExecutor=5 保守起步(钻石 100 RPS 留 buffer)
|
||
5) 单节点失败 warn 跳过,不中断整体
|
||
6) 全量替换写库(set 语义,idempotent)
|
||
7) stock_code 统一 hermes 格式(SH600519),与项目 2026-07-01 约定一致
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from collections import Counter
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
from typing import Any
|
||
|
||
from app.core.db import ops as db_ops
|
||
from app.core.datasource.base import registry as ds_registry
|
||
from app.core.datasource.registry import is_source_ready
|
||
from app.core.datasource.utils import to_hermes
|
||
from app.core.sync.base import SyncTask
|
||
from app.core.sync.registry import mark_sync_blocked
|
||
from app.core.utils.logging import get_logger
|
||
|
||
logger = get_logger("sync.stock_node")
|
||
|
||
|
||
# type1=0 (A 股) 子类映射
|
||
TYPE2_TO_CATEGORY = {
|
||
0: ("A股-申万行业", "industry"),
|
||
1: ("A股-申万二级", "industry_sub"),
|
||
2: ("A股-热门概念", "concept"),
|
||
3: ("A股-概念板块", "concept"),
|
||
4: ("A股-地域板块", "region"),
|
||
5: ("A股-证监会行业", "industry_regulator"), # 跳过,与 baostock 重复
|
||
6: ("A股-分类", "class"),
|
||
7: ("A股-指数成分", "index"),
|
||
8: ("A股-风险警示", "warning"),
|
||
9: ("A股-大盘指数", "index_main"),
|
||
10: ("A股-次新股", "subnew"),
|
||
}
|
||
|
||
# 默认同步的 type2 集合
|
||
INCLUDED_TYPE2 = {0, 1, 2, 3, 4, 6, 7}
|
||
|
||
|
||
class SyncStockNode(SyncTask):
|
||
"""股票-节点 N×M 映射(指数/行业/概念 统一入口)。
|
||
|
||
CLI 用法:
|
||
python -m app.entrypoints.cli sync stock_node # 默认全跑
|
||
python -m app.entrypoints.cli sync stock_node --type2 2,3 # 只跑概念
|
||
python -m app.entrypoints.cli sync stock_node --max-workers 10 # 提高并发
|
||
"""
|
||
dataset_id = "stock_node"
|
||
|
||
def _run(
|
||
self,
|
||
*,
|
||
trigger_source: str = "manual",
|
||
type2: list[int] | str | None = None,
|
||
max_workers: int = 5,
|
||
**kwargs,
|
||
) -> dict[str, Any]:
|
||
mr = ds_registry.get("datasource_mairui")
|
||
if mr is None:
|
||
return {"status": "error", "message": "mairui 数据源未注册"}
|
||
ok, reason = is_source_ready(mr.key)
|
||
if not ok:
|
||
mark_sync_blocked(self.dataset_id, message=f"mairui 未就绪: {reason}")
|
||
return {"status": "blocked", "message": f"mairui 未就绪: {reason}"}
|
||
|
||
# ── 解析 type2 过滤 ──
|
||
if type2 is None:
|
||
included = INCLUDED_TYPE2
|
||
elif isinstance(type2, str):
|
||
raw = [t.strip() for t in type2.split(",") if t.strip()]
|
||
if not raw:
|
||
return {
|
||
"status": "error",
|
||
"message": "--type2 不能为空字符串(要限制子集请用 '0,2,3';要全量不要传 --type2)",
|
||
}
|
||
bad = [t for t in raw if not t.isdigit()]
|
||
if bad:
|
||
valid = ", ".join(f"{k}={v[1]}" for k, v in TYPE2_TO_CATEGORY.items())
|
||
return {
|
||
"status": "error",
|
||
"message": f"--type2 含非法 token {bad};应为 0-10 整数(如 '2,3');有效映射: {valid}",
|
||
}
|
||
included = {int(t) for t in raw}
|
||
unknown = {t for t in included if t not in TYPE2_TO_CATEGORY}
|
||
if unknown:
|
||
valid = ", ".join(f"{k}={v[1]}" for k, v in TYPE2_TO_CATEGORY.items())
|
||
return {
|
||
"status": "error",
|
||
"message": f"--type2 含未知 mairui type2 {sorted(unknown)};有效范围 0-10;映射: {valid}",
|
||
}
|
||
else:
|
||
# 程序化调用: 接受 list[int] | set[int] | tuple,但仍校验范围
|
||
# (2026-07-07 防御: 也覆盖 programmatic caller 传非法值的情况)
|
||
included = set(type2)
|
||
bad = [t for t in included if not isinstance(t, int) or t not in TYPE2_TO_CATEGORY]
|
||
if bad:
|
||
valid = ", ".join(f"{k}={v[1]}" for k, v in TYPE2_TO_CATEGORY.items())
|
||
return {
|
||
"status": "error",
|
||
"message": f"type2 含非法值 {bad};应为 0-10 整数;有效映射: {valid}",
|
||
}
|
||
logger.info(f"[stock_node] 同步 type2: {sorted(included)}")
|
||
|
||
# ── Phase 1: 拉节点树(1 次调用) ──
|
||
t0 = time.time()
|
||
df_tree = mr.fetch_node_tree()
|
||
if df_tree is None or df_tree.empty:
|
||
return {"status": "error", "message": "mairui /hszg/list 返回空"}
|
||
|
||
# type1=0 (A 股) + 白名单 type2 + isleaf=1
|
||
# mairui 的 type2 字段是 float(如 2.0);部分叶子 type2 为 NaN,先 dropna
|
||
df_a = df_tree[df_tree["type1"] == 0].copy()
|
||
df_a = df_a.dropna(subset=["type2"])
|
||
df_a["type2_int"] = df_a["type2"].astype(int)
|
||
df_leaves = df_a[
|
||
(df_a["type2_int"].isin(included))
|
||
& (df_a["isleaf"] == 1)
|
||
].copy()
|
||
if df_leaves.empty:
|
||
return {"status": "warning", "message": f"无匹配叶子节点(type2={sorted(included)})"}
|
||
|
||
df_leaves["type2_int"] = df_leaves["type2"].astype(int)
|
||
df_leaves["category_key"] = "0:" + df_leaves["type2_int"].astype(str)
|
||
df_leaves["category_type"] = df_leaves["type2_int"].map(
|
||
lambda t: TYPE2_TO_CATEGORY.get(int(t), ("?", "other"))[1]
|
||
)
|
||
df_leaves["parent_code"] = df_leaves["pcode"]
|
||
df_leaves["parent_name"] = df_leaves["pname"]
|
||
df_leaves["is_leaf"] = 1
|
||
|
||
# 写 node_categories(7 行)
|
||
cat_counter = Counter(df_leaves["category_key"])
|
||
category_rows = []
|
||
for cat_key, count in cat_counter.items():
|
||
t2 = int(cat_key.split(":")[1])
|
||
display_name, cat_type = TYPE2_TO_CATEGORY.get(t2, (cat_key, "other"))
|
||
category_rows.append({
|
||
"category_key": cat_key,
|
||
"display_name": display_name,
|
||
"market": "A 股",
|
||
"category_type": cat_type,
|
||
"node_count": count,
|
||
})
|
||
db_ops.replace_all_node_categories(category_rows)
|
||
logger.info(f"[stock_node] node_categories: {len(category_rows)} 行")
|
||
|
||
# 写 nodes(约 1100 行) —— dedup 按 node_code,因为 _bulk_upsert_orm 同 chunk 内
|
||
# 不能有重复 PK(PG ON CONFLICT 同 batch 第二次出现会报错)
|
||
node_rows = [
|
||
{
|
||
"node_code": str(r["code"]),
|
||
"node_name": str(r["name"]),
|
||
"category_key": r["category_key"],
|
||
"parent_code": str(r["pcode"]) if r["pcode"] else None,
|
||
"parent_name": str(r["pname"]) if r["pname"] else None,
|
||
"level": int(r["level"]),
|
||
"is_leaf": 1,
|
||
"mairui_type1": int(r["type1"]),
|
||
"mairui_type2": int(r["type2_int"]),
|
||
}
|
||
for _, r in df_leaves.iterrows()
|
||
]
|
||
# 去重 node_code(罕见:同一节点可能因 pname 差异重复出现,保留 first)
|
||
seen = set()
|
||
node_rows_dedup = []
|
||
for nr in node_rows:
|
||
if nr["node_code"] in seen:
|
||
continue
|
||
seen.add(nr["node_code"])
|
||
node_rows_dedup.append(nr)
|
||
if len(node_rows) != len(node_rows_dedup):
|
||
logger.info(f"[stock_node] nodes dedup: {len(node_rows)} → {len(node_rows_dedup)}")
|
||
db_ops.replace_all_nodes(node_rows_dedup)
|
||
logger.info(f"[stock_node] nodes(leaves): {len(node_rows)} 行")
|
||
|
||
# ── Phase 2: 拉每节点的成分股(gg 路径) ──
|
||
leaf_codes = df_leaves["code"].tolist()
|
||
all_map_rows: list[dict] = []
|
||
ok_cnt = fail_cnt = 0
|
||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||
futures = {
|
||
pool.submit(self._scrape_one, mr, code): code for code in leaf_codes
|
||
}
|
||
for i, fut in enumerate(as_completed(futures), 1):
|
||
node_code = futures[fut]
|
||
try:
|
||
rows = fut.result()
|
||
if rows:
|
||
all_map_rows.extend(rows)
|
||
ok_cnt += 1
|
||
else:
|
||
fail_cnt += 1
|
||
except Exception as e:
|
||
fail_cnt += 1
|
||
logger.warning(f"[stock_node {node_code}] {e}")
|
||
if i % 100 == 0 or i == len(leaf_codes):
|
||
self._progress(
|
||
message=f"stock_node {i}/{len(leaf_codes)} ok={ok_cnt} fail={fail_cnt} mappings={len(all_map_rows)}",
|
||
current=i, total=len(leaf_codes), current_step=node_code,
|
||
)
|
||
|
||
db_ops.replace_all_stock_node_map(all_map_rows)
|
||
elapsed = round(time.time() - t0, 1)
|
||
msg = (
|
||
f"节点映射 {len(node_rows)}节点 ok={ok_cnt} fail={fail_cnt} "
|
||
f"共{len(all_map_rows)}映射 {elapsed}s"
|
||
)
|
||
return {
|
||
"status": "ok" if fail_cnt == 0 else "warning",
|
||
"message": msg,
|
||
"nodes": len(node_rows),
|
||
"categories": len(category_rows),
|
||
"ok": ok_cnt, "fail": fail_cnt,
|
||
"mappings": len(all_map_rows),
|
||
"elapsed_sec": elapsed,
|
||
}
|
||
|
||
def _scrape_one(self, mr, node_code: str) -> list[dict]:
|
||
"""拉单个节点的成分股 → N 条 (stock_code, node_code) 行。
|
||
|
||
stock_code 用 hermes 格式(SH600519),与项目其他表一致。
|
||
单只股票可能重复(节点 X、节点 Y 都属于该股)→ 不去重,反正 PK 幂等。
|
||
"""
|
||
df = mr.fetch_node_stocks(node_code)
|
||
if df is None or df.empty:
|
||
return []
|
||
rows = []
|
||
for _, r in df.iterrows():
|
||
dm = str(r.get("dm", "") or "").strip()
|
||
if len(dm) != 6 or not dm.isdigit():
|
||
continue
|
||
rows.append({
|
||
"stock_code": to_hermes(dm),
|
||
"node_code": str(node_code),
|
||
})
|
||
return rows |