Files
gao a8ea132924 fix(stock_node+utils+cli): review findings P0/P1/P2 一轮修复
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
2026-07-07 16:04:47 +08:00

257 lines
11 KiB
Python
Raw Permalink 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.
"""同步任务:股票-指数/行业/概念 映射(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