a8ea132924
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
157 lines
6.8 KiB
Python
157 lines
6.8 KiB
Python
"""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("已重新 seed:datasources + sync_registry + schedules")
|
||
return
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|