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)
# ── 行业 / 概念板块 ─────────────────────────────────────────────────────
+22 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import argparse
import inspect
import json
import sys
from pathlib import Path
@@ -25,6 +26,8 @@ def main():
p_sync.add_argument("--end", help="结束日期 YYYY-MM-DD", default=None)
p_sync.add_argument("--workers", type=int, default=10, help="并发数")
p_sync.add_argument("--force", action="store_true", help="跳过时间门控(tick_trade 用)")
p_sync.add_argument("--type2", help="stock_node 用,逗号分隔的 mairui type2 列表(如 '2,3')", default=None)
p_sync.add_argument("--backfill", help="longhubang 用,单日 YYYYMMDD", default=None)
sub.add_parser("list", help="列出所有同步任务")
sub.add_parser("status", help="查看整体状态(调度器 / 数据源 / 同步任务)")
@@ -58,7 +61,25 @@ def main():
kwargs["max_workers"] = args.workers
if args.force:
kwargs["force"] = True
result = task.run(trigger_source="cli", **kwargs)
if args.type2:
# 2026-07-07 修复: 不要再 CLI 层 silent-drop 非数字 token,
# 否则用户敲 "concept" 时 CLI 过滤成 [], 任务层 str-branch 校验
# 永远不触发。直接把原始 str 传给 task,让 task 负责校验和报错。
kwargs["type2"] = args.type2
if args.backfill:
kwargs["backfill"] = args.backfill
# 过滤掉 task._run 不接受的 kwargs (2026-07-07 修复):
# 之前 --type2 / --backfill 加在共享 p_sync parser 上,任何 task 都通过
# **kwargs 静默吞掉,用户敲错也不会报错。
try:
declared = set(inspect.signature(task._run).parameters.keys())
except (TypeError, ValueError):
declared = set()
unknown = [k for k in kwargs if k not in declared]
if unknown:
print(f"warning: {args.task_id} 不支持这些参数: {unknown}(已忽略)", file=sys.stderr)
filtered = {k: v for k, v in kwargs.items() if k in declared}
result = task.run(trigger_source="cli", **filtered)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0 if result.get("status") in ("ok", "warning") else 2)
+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()
+257
View File
@@ -0,0 +1,257 @@
"""同步任务:股票-指数/行业/概念 映射(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
+7 -7
View File
@@ -148,20 +148,20 @@ class SyncTickTrade(SyncTask):
if df is None or df.empty:
return {"status": "fail", "error": "no data (mairui 21:00 前可能为空)"}
try:
rows = [
{
rows = []
for _, r in df.iterrows():
# trade_time 来自 source 层,保证是 tz-aware Asia/Shanghai datetime
# (contract 见 app/sources/mairui.py:fetch_tick_trade docstring)
rows.append({
"stock_code": to_hermes(code6),
"trade_date": str(r["trade_date"]),
"trade_time": r["trade_time"].strftime("%Y-%m-%d %H:%M:%S")
if hasattr(r["trade_time"], "strftime") else str(r["trade_time"]),
"trade_time": r["trade_time"],
"price": float(r.get("price") or 0),
"volume": float(r.get("volume") or 0),
"direction_code": int(r.get("direction_code") or 0),
"direction": str(r.get("direction") or ""),
"amount": float(r.get("amount") or 0),
}
for _, r in df.iterrows()
]
})
db_ops.upsert_tick_trade(rows)
return {"status": "ok", "rows": len(rows)}
except Exception as e:
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
#!/bin/bash
# systemd 调度的入口(专跑 stock_node - 股票-节点映射)
#
# 设计:
# - 与 market_sync_moneyflow_run.sh 同构,只跑 stock_node 一个 task
# - 周六 11:30 触发(mairui /hszg 数据 11:00 更新,留 30 min buffer)
# - 单次 ~1100 次 mairui 调用(@10RPS,~2 min)
# - 单独日志到 logs/stock_node_<ts>.log
# - exit code 透传
#
# 并发选择(2026-07-07 调整):
# - workers=8 × 默认 MAIRUI_RPS_LIMIT=10 = 80 RPS
# - 钻石 licence 上限 100 RPS,留 20% buffer 防风控
# - 之前 20 workers × 10 RPS = 200 RPS 撞穿钻石档,mairui 返"请稍后再试"
set -u
PROJECT_ROOT="/home/gao/Development/quant_home/market_sync"
LOG_DIR="$PROJECT_ROOT/logs"
mkdir -p "$LOG_DIR"
TS=$(date +%Y%m%d_%H%M%S)
LOG="$LOG_DIR/stock_node_${TS}.log"
echo "[market-sync-stock-node] start ts=$TS log=$LOG" | tee -a "$LOG"
# 注:不做法定节假日过滤 —— 与其他 3 个 timer 一致的 fail-open 原则。
# 周六 stock_node 即使撞节假日,mairui 仍可能返上一周数据 → task 报 warning,不丢历史。
# ── 跑 stock_node ──
cd "$PROJECT_ROOT"
.venv/bin/python -m app.entrypoints.cli sync stock_node --workers 8 2>&1 | tee -a "$LOG"
EXIT_CODE=${PIPESTATUS[0]}
echo "[market-sync-stock-node] done exit_code=$EXIT_CODE" | tee -a "$LOG"
exit $EXIT_CODE
@@ -0,0 +1,33 @@
[Unit]
Description=Market data 股票-节点映射 sync (mairui /hszg, weekly Sat 11:30)
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_stock_node_run.sh
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/home/gao/Development/quant_home/market_sync
User=gao
Group=gao
# 入口脚本:单独跑 stock_node 一个 task
# mairui /hszg 数据每周六 11:00 更新 → 11:30 触发,留 30 min buffer
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_stock_node_run.sh
# 硬上限 2h(1100+ 调用 + 写库 + retry buffer)
TimeoutStartSec=7200
# 不要 Restart=(oneshot 失败就让 OnFailure= 发通知,别自动重跑——
# 重跑会撞 mairui 配额窗口)
# Restart=no 是 oneshot 默认值
# 日志走 journald(journalctl -u market-sync-stock-node.service -f)
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-stock-node
# 环境(不读 /etc/environment,只带这几个;.env 由 cli 内部 load_dotenv)
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Schedule stock_node sync — Sat 11:30 Asia/Shanghai
# market-sync-stock-node.service 是这个 timer 的执行单元
# mairui /hszg 数据每周六 11:00 更新 → 11:30 触发(30 min buffer)
[Timer]
# 周六 11:30(mairui 11:00 更新后)
OnCalendar=Sat *-*-* 11:30:00 Asia/Shanghai
# 系统关机 / 错过执行时,下次开机补跑一次(避免漏数据)
Persistent=true
# 单位(service)的精确名称
Unit=market-sync-stock-node.service
# 不要 AccuracySec(默认 1min 漂移够用,避免 11:30:00 整点打堆)
[Install]
WantedBy=timers.target
+4 -2
View File
@@ -7,14 +7,14 @@ from __future__ import annotations
def test_orm_metadata_registers_all_tables():
"""ORMBase.metadata 应该注册 18 张 PG 表(项目主业务表)。"""
"""ORMBase.metadata 应该注册 22 张 PG 表(项目主业务表)。"""
from app.core.db import models # 触发全部模型 import
from app.core.db.orm import ORMBase
# 注意:SA 2.x 的 MetaData.tables 既能按 bare name 也能按 schema-qualified name 索引
# keyed 容器),所以用 values() 拿 Table 对象再用 .name 拿 bare 名
bare_names = {t.name for t in ORMBase.metadata.tables.values()}
assert len(bare_names) == 18, f"期望 18 张 ORM 表,实际 {len(bare_names)}: {bare_names}"
assert len(bare_names) == 22, f"期望 22 张 ORM 表,实际 {len(bare_names)}: {bare_names}"
expected = {
"config", "dataset_registry", "stocks", "indices",
"kline_stock", "kline_index", "kline_5min", "moneyflow", "share",
@@ -22,6 +22,8 @@ def test_orm_metadata_registers_all_tables():
"sector_indices", "sector_features_daily", "market_regime_daily",
"tick_trade",
"longhubang_daily", "longhubang_seat", # 2026-07-01 龙虎榜
"kline_stock_ma_daily", # 2026-07-04 mairui_ma_daily
"node_categories", "nodes", "stock_node_map", # 2026-07-02 股票-节点映射
}
assert bare_names == expected, f"ORM 表名集合与期望不符: {bare_names ^ expected}"
+9 -5
View File
@@ -35,10 +35,10 @@ def test_tasks_registry():
from app.tasks import TASKS, get_task
assert isinstance(TASKS, dict)
# 注:每次新增 task 都要更新这里的数字
# 当前 11 个:stock_basic, kline_daily, kline_index, kline_5min, tick_trade,
# 当前 13 个:stock_basic, kline_daily, kline_index, kline_5min, tick_trade,
# moneyflow, industry_sector, sector_features, share_snapshot, market_regime,
# longhubang
assert len(TASKS) == 11
# longhubang, mairui_ma_daily, stock_node
assert len(TASKS) == 13
# get_task 应该返回实例
task = get_task("kline_daily")
assert task.dataset_id == "kline_daily"
@@ -46,10 +46,10 @@ def test_tasks_registry():
def test_sync_definitions():
from app.core.sync.registry import SYNC_DEFINITIONS
assert len(SYNC_DEFINITIONS) == 11
assert len(SYNC_DEFINITIONS) == 13
# 所有 dataset_id 应唯一
ids = [d["dataset_id"] for d in SYNC_DEFINITIONS]
assert len(set(ids)) == 11
assert len(set(ids)) == 13
# tick_trade 应在其中且 sort_order=45
tt = next(d for d in SYNC_DEFINITIONS if d["dataset_id"] == "tick_trade")
assert tt["sort_order"] == 45
@@ -58,6 +58,10 @@ def test_sync_definitions():
lh = next(d for d in SYNC_DEFINITIONS if d["dataset_id"] == "longhubang")
assert lh["sort_order"] == 85
assert "stock_basic" in lh["dependency_ids"]
# stock_node 应在其中且 sort_order=21
sn = next(d for d in SYNC_DEFINITIONS if d["dataset_id"] == "stock_node")
assert sn["sort_order"] == 21
assert "stock_basic" in sn["dependency_ids"]
def test_build_default_registry():