Files
market_sync/app/entrypoints/cli.py
T
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

157 lines
6.8 KiB
Python
Raw 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.
"""CLI 入口:手动触发 / 状态查询。"""
from __future__ import annotations
import argparse
import inspect
import json
import sys
from pathlib import Path
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
def main():
parser = argparse.ArgumentParser(
description="market_data_sync CLI",
)
sub = parser.add_subparsers(dest="cmd", required=True)
# sync <task_id>
p_sync = sub.add_parser("sync", help="手动执行一次同步任务")
p_sync.add_argument("task_id", help="dataset_id, 例如 kline_daily")
p_sync.add_argument("--codes", help="指定股票代码,逗号分隔", default=None)
p_sync.add_argument("--start", help="起始日期 YYYY-MM-DD", default=None)
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="查看整体状态(调度器 / 数据源 / 同步任务)")
sub.add_parser("datasources", help="查看数据源健康度")
sub.add_parser("schedule", help="查看计划任务")
sub.add_parser("reseed", help="重新 seed 默认计划任务到 config 表")
args = parser.parse_args()
if args.cmd == "sync":
from app.core.utils.logging import setup_logging
setup_logging()
from app.tasks import get_task
from app.core.datasource.registry import build_default_registry
from app.core.sync.registry import seed_sync_registry
build_default_registry()
seed_sync_registry() # 确保 dataset_registry 有这条任务定义
try:
task = get_task(args.task_id)
except KeyError as e:
print(f"错误: {e}")
sys.exit(1)
kwargs = {}
if args.codes:
kwargs["codes"] = [c.strip() for c in args.codes.split(",") if c.strip()]
if args.start:
kwargs["start"] = args.start
if args.end:
kwargs["end"] = args.end
if args.workers:
kwargs["max_workers"] = args.workers
if args.force:
kwargs["force"] = True
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)
if args.cmd == "list":
from app.tasks import TASKS
print("可用同步任务:")
for tid, cls in TASKS.items():
doc = (cls.__doc__ or cls.__name__).split("\n")[0]
print(f" {tid:25s} {doc}")
return
if args.cmd == "status":
from app.core.utils.logging import setup_logging
setup_logging()
from app.core.scheduler import get_scheduler_status
from app.core.datasource.registry import build_default_registry, get_health_status
from app.core.sync import get_registry_status
build_default_registry()
print("\n=== 调度器 ===")
s = get_scheduler_status()
print(json.dumps(s, ensure_ascii=False, indent=2, default=str))
print("\n=== 数据源健康度 ===")
for r in get_health_status():
mark = "" if r.get("success") else ""
print(f" {mark} {r.get('name', r.get('key')):30s} {r.get('message', '')}")
print("\n=== 同步任务状态 ===")
for r in get_registry_status():
print(f" {r['dataset_id']:25s} status={r.get('status'):10s} "
f"lastRun={r.get('last_success_at') or r.get('last_failure_at') or '-':20s} "
f"msg={r.get('message', '')[:60]}")
return
if args.cmd == "datasources":
from app.core.utils.logging import setup_logging
setup_logging()
from app.core.datasource.registry import build_default_registry, get_health_status, run_health_check
build_default_registry()
print("正在跑连通性测试...")
run_health_check()
for r in get_health_status():
mark = "" if r.get("success") else ""
print(f" {mark} {r.get('name', r.get('key')):30s} provides={r.get('provides', [])} {r.get('message', '')}")
return
if args.cmd == "schedule":
from app.core.utils.logging import setup_logging
setup_logging()
from app.core.scheduler import get_scheduler_status, seed_schedule_configs
seed_schedule_configs()
s = get_scheduler_status()
print(f"调度器运行中: {s['running']}")
for j in s["jobs"]:
enabled = "" if j.get("enabled") else ""
trigger = j.get("time") or f"every {j.get('intervalSeconds')}s"
print(f" {enabled} {j.get('name', j['key']):30s} {trigger:15s} "
f"condition={j.get('condition', 'always'):12s} "
f"last={j.get('lastStatus', '-')}")
return
if args.cmd == "reseed":
from app.core.utils.logging import setup_logging
setup_logging()
from app.core.scheduler.scheduler import seed_schedule_configs
from app.core.sync.registry import seed_sync_registry
from app.core.datasource.registry import seed_datasource_configs
seed_datasource_configs()
seed_sync_registry()
seed_schedule_configs()
print("已重新 seeddatasources + sync_registry + schedules")
return
if __name__ == "__main__":
main()