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:
+73
-4
@@ -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' + t(datetime64[ns, UTC],无 tz 直接当本地时区)
|
||||
trade_time = d + 'T' + t,tz=Asia/Shanghai(mairui 返回北京时间 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(保持与其它表一致)
|
||||
|
||||
历史 bug(2026-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()
|
||||
|
||||
Reference in New Issue
Block a user