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
+73 -4
View File
@@ -32,7 +32,7 @@ from app.core.datasource.utils import (
class MairuiSource(DataSource):
key = "datasource_mairui"
name = "麦蕊智数(mairui.club"
provides = ["kline_daily", "kline_5min", "index_daily", "stock_basic", "moneyflow", "tick_trade"]
provides = ["kline_daily", "kline_5min", "index_daily", "stock_basic", "moneyflow", "tick_trade", "stock_node"]
requires_credential = True
credential_key = "MAIRUI_LICENCE"
@@ -432,10 +432,19 @@ class MairuiSource(DataSource):
ts number 交易方向 0=中性盘 / 1=买入 / 2=卖出
派生字段:
trade_time = d + 'T' + tdatetime64[ns, UTC],无 tz 直接当本地时区
trade_time = d + 'T' + ttz=Asia/Shanghaimairui 返回北京时间 UTC+8
Source 层契约(2026-07-07 强化): 保证返回的 trade_time
一定是 tz-aware datetime,Task 层无需再 strftime 转换。
SQLAlchemy 存到 TIMESTAMPTZ 时自动转 UTC。
direction = 0/1/2 → 'neutral'/'buy'/'sell'
amount = price * volume(元)
stock_code = 外部传入的 6 位 code(保持与其它表一致)
历史 bug2026-07-02 修复):之前 trade_time 是 naive datetime,被 PG 当 UTC 存,
导致查询时差 8 小时(如 SH600519 第一条 tick 实际是 09:15:08 BJT = 01:15:08 UTC,
之前显示为 09:15:08 UTC = 17:15:08 BJT,跟实际交易时段不符)。
2026-07-07 再加固: 若 mairui 某行 d/t 已带 tz-offset,不做 tz_localize(避免
"Already tz-aware" 报错),保持原 tz。
"""
lic = self._get_licence()
if not lic:
@@ -453,11 +462,14 @@ class MairuiSource(DataSource):
t = str(item.get("t", "") or "").strip()
if not d or not t:
continue
# mairui `t` 形如 "14:53:21",拼成 ISO 字符串让 pandas 解析
# mairui `t` 形如 "14:53:21" —— 是北京时间(UTC+8)
iso = f"{d}T{t}"
ts_val = pd.to_datetime(iso, errors="coerce")
if pd.isna(ts_val):
continue
# Source 层保证 tz-aware: 已有 tz 就保留,没有就标 Asia/Shanghai。
if ts_val.tzinfo is None:
ts_val = ts_val.tz_localize("Asia/Shanghai")
price = float(item.get("p") or 0)
volume = float(item.get("v") or 0)
if price <= 0 or volume <= 0:
@@ -479,4 +491,61 @@ class MairuiSource(DataSource):
return pd.DataFrame()
df = pd.DataFrame(records)
# mairui 按时间倒序,统一升序便于入库
return df.sort_values("trade_time").reset_index(drop=True)
return df.sort_values("trade_time").reset_index(drop=True)
# ── 指数/行业/概念 树 (mairui /hszg) ───────────────────────────
def fetch_node_tree(self) -> pd.DataFrame:
"""mairui /hszg/list/{licence} 指数/行业/概念树。
API: GET https://a.mairuiapi.com/hszg/list/{licence}
文档: https://mairui.club/hsdata → 指数/行业/概念 → /hszg/list
更新: 每周六 03:05。
返回 DataFrame 列:
name, code, type1, type2, level, pcode, pname, isleaf
1464 节点覆盖:A 股(1131) + 港股(31) + 基金/债券/美股/外汇/期货/黄金 等。
type1 标识市场(0=A股),type2 标识子类(0=申万一级, 2=热门概念, 3=概念板块,
5=证监会行业, 7=指数成分 等),isleaf=1 是可直接喂给 /hszg/gg 的叶子节点。
"""
lic = self._get_licence()
if not lic:
return pd.DataFrame()
path = f"/hszg/list/{lic}"
data = self._fetch(path, {})
return pd.DataFrame(data) if isinstance(data, list) else pd.DataFrame()
def fetch_stock_nodes(self, code6: str) -> pd.DataFrame:
"""mairui /hszg/zg/{code6}/{licence} 股票→相关节点。
API: GET https://a.mairuiapi.com/hszg/zg/{code6}/{licence}
文档: https://mairui.club/hsdata → 指数/行业/概念 → /hszg/zg
更新: 每周六 11:00。
实测: SH600000 约 30 行(code + name, code 喂给 /hszg/gg 拿成分股)。
返回 DataFrame 列: code, name
"""
lic = self._get_licence()
if not lic:
return pd.DataFrame()
path = f"/hszg/zg/{code6}/{lic}"
data = self._fetch(path, {})
return pd.DataFrame(data) if isinstance(data, list) else pd.DataFrame()
def fetch_node_stocks(self, node_code: str) -> pd.DataFrame:
"""mairui /hszg/gg/{code}/{licence} 节点→成分股。
API: GET https://a.mairuiapi.com/hszg/gg/{code}/{licence}
文档: https://mairui.club/hsdata → 指数/行业/概念 → /hszg/gg
更新: 每周六 11:00。
实测: sw_sysh(申万银行)约 48 行,概念节点约 30 行,沪深300 约 300 行。
返回 DataFrame 列: dm(6 位代码), mc(名称), jys(交易所 sh/sz/bj)
任务层需 dm → hermes 转换。
"""
lic = self._get_licence()
if not lic:
return pd.DataFrame()
path = f"/hszg/gg/{node_code}/{lic}"
data = self._fetch(path, {})
return pd.DataFrame(data) if isinstance(data, list) else pd.DataFrame()