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
This commit is contained in:
gao
2026-07-07 16:04:47 +08:00
parent e8e35b9716
commit a8ea132924
11 changed files with 526 additions and 44 deletions
+35 -6
View File
@@ -20,31 +20,51 @@ def to_code6(code: str) -> str:
def code6_to_exchange(code6: str) -> str:
"""6 位代码 → 交易所前缀"""
if code6.startswith(("5", "6", "9")):
"""6 位代码 → 交易所前缀(依赖 _strip_hermes 在外层归一)。
仅处理 6 位纯数字。hermes 格式 'SH600519' 由 _strip_hermes 在调用前
剥前缀,本函数不再重复判断(2026-07-07 简化,删掉 3 个死分支)。
"""
c = str(code6).strip().upper()
if c.startswith(("5", "6", "9")):
return "SH"
if code6.startswith(("4", "8")):
if c.startswith(("4", "8")):
return "BJ"
return "SZ"
def _strip_hermes(code6: str) -> str:
"""如果是 hermes 格式,剥掉 SH/SZ/BJ 前缀,变回 6 位纯数字。
这样 4 个 code6_to_* 函数就能保持 6 位 纯数字的内部假设。
"""
c = str(code6).strip().upper()
if len(c) >= 8 and c[:2] in ("SH", "SZ", "BJ") and c[2:].isdigit():
return c[2:]
return c
def code6_to_sina(code6: str) -> str:
"""新浪财经 symbol'sh600036' / 'sz000001'"""
code6 = _strip_hermes(code6)
return f"{code6_to_exchange(code6).lower()}{code6}"
def code6_to_baostock(code6: str) -> str:
"""Baostock 风格:'sh.600000'"""
code6 = _strip_hermes(code6)
return f"{code6_to_exchange(code6).lower()}.{code6}"
def code6_to_xueqiu(code6: str) -> str:
"""雪球 symbol'SH600036'"""
code6 = _strip_hermes(code6)
return f"{code6_to_exchange(code6)}{code6}"
def code6_to_mairui(code6: str) -> str:
"""麦蕊智数 symbol'600036.SH'"""
code6 = _strip_hermes(code6)
return f"{code6}.{code6_to_exchange(code6)}"
@@ -126,9 +146,18 @@ def normalize_5min(df: pd.DataFrame) -> pd.DataFrame:
def is_a_share_code(code6: str) -> bool:
"""判断 6 位代码是否是主板/创业板/科创板(排除北证 8 字头、可转债等)。"""
if not code6 or len(code6) != 6 or not code6.isdigit():
"""判断是否是主板/创业板/科创板(排除北证 8 字头、可转债等)。
兼容 hermes 格式 (2026-07-07 修复): 'SH600519''600519' 等价处理。
这样 iter_stock_codes 将来改为 yield hermes 时,
5 个 task (moneyflow/share_snapshot/kline_5min/tick_trade/kline_daily)
的 is_a_share_code 过滤不会静默返 0。
"""
c = str(code6).strip().upper()
if len(c) >= 8 and c[:2] in ("SH", "SZ", "BJ") and c[2:].isdigit():
c = c[2:]
if not c or len(c) != 6 or not c.isdigit():
return False
if code6.startswith(("4", "8")):
if c.startswith(("4", "8")):
return False
return True
+35 -19
View File
@@ -609,9 +609,17 @@ def upsert_longhubang_seat(rows: list[dict[str, Any]]) -> int:
def replace_all_node_categories(rows: list[dict[str, Any]]) -> None:
"""rows: category_key, display_name, market, category_type, node_count"""
"""rows: category_key, display_name, market, category_type, node_count
Scope-aware (2026-07-07 fix): 只 delete 与本批 category_key 集合冲突的行,
其它 type2 类别不受影响。这样 --type2 0 跑只刷 type2=0 的行,
不会把另外 6 个 category 误删。
"""
with get_session() as s:
s.execute(delete(NodeCategory))
keys = [r.get("category_key", "") for r in rows if r.get("category_key")]
if keys:
# 只删本批会覆盖的 PK 行(避免 --type2 子集跑时误删其他类别)
s.execute(delete(NodeCategory).where(NodeCategory.category_key.in_(keys)))
if rows:
values = [
{
@@ -658,24 +666,32 @@ def replace_all_nodes(rows: list[dict[str, Any]]) -> None:
def replace_all_stock_node_map(rows: list[dict[str, Any]]) -> None:
"""rows: stock_code (hermes 格式), node_code"""
if not rows:
return
"""rows: stock_code (hermes 格式), node_code
Scope-aware (2026-07-07 fix): 先 delete 本批 node_code 集合映射再 insert,
即使 rows 为空也保证语义与 replace_all_node_categories 对称 —— 避免
"categories 新但 mappings 旧" 的部分失败不一致状态。
"""
with get_session() as s:
values = [
{
"stock_code": str(r.get("stock_code", "")).strip(),
"node_code": r.get("node_code", ""),
}
for r in rows
]
# PK-only 表 —— 用 ON CONFLICT DO NOTHING(因为 PK 已经确定唯一内容,
# 重复 PK 的行内容相同,无需 UPDATE)
from sqlalchemy.dialects.postgresql import insert as pg_insert
stmt = pg_insert(StockNodeMap).values(values).on_conflict_do_nothing(
index_elements=["stock_code", "node_code"]
)
s.execute(stmt)
node_codes = {r.get("node_code", "") for r in rows if r.get("node_code")}
if node_codes:
# 只删本批会覆盖的 node 映射,避免空 rows 时留下 stale
s.execute(delete(StockNodeMap).where(StockNodeMap.node_code.in_(node_codes)))
if rows:
values = [
{
"stock_code": str(r.get("stock_code", "")).strip(),
"node_code": r.get("node_code", ""),
}
for r in rows
]
# PK-only 表 —— 用 ON CONFLICT DO NOTHING(因为 PK 已经确定唯一内容,
# 重复 PK 的行内容相同,无需 UPDATE)
from sqlalchemy.dialects.postgresql import insert as pg_insert
stmt = pg_insert(StockNodeMap).values(values).on_conflict_do_nothing(
index_elements=["stock_code", "node_code"]
)
s.execute(stmt)
# ── 行业 / 概念板块 ─────────────────────────────────────────────────────