16 Commits

Author SHA1 Message Date
gao daef609e8b feat: QMT Bridge 设为主数据源,新增指数日K支持及数据源文档
- QmtBridgeSource: provides 增加 index_daily,新增 fetch_index_daily 方法
- task_kline_index: 优先级改为 qmt_bridge → mairui → sina 三级降级
- task_kline_5min: 优先用 qmt_bridge,不可用时降级 mairui
- task_kline_daily: 优先级加入 qmt_bridge(首位)
- config: 新增 qmt_bridge_url 配置项
- registry: qmt_bridge 注册信息同步更新
- docs: 新增 DATASETS_AND_SOURCES.md,完整说明数据集与数据源依赖关系
- AGENTS.md: 同步更新指数数据源描述
2026-07-23 23:39:59 +08:00
gao 4516f6d75a udpate 2026-07-21 22:09:40 +08:00
gao 8f016f25df chore: baseline — ORM migration + systemd deploy + MCP server + daily check
- ORM migration: models/ops 重构
- deploy: 标准化 systemd timer/service 部署体系
- MCP server: 5 个只读工具(任务/状态/数据集)
- daily check: 数据一致性巡检
- watch: task_watch 后台监控
- kline_daily: 麦蕊优先,时间门禁15:00
- 删除: task_industry_sector, task_sector_features
2026-07-21 16:37:00 +08:00
gao 91e83a3b0b feat(mcp): MCP server (5 tools) — 任务/状态/数据集/详情/今日汇总
新增 app/mcp_server.py,让 LLM (Claude Desktop / Claude Code) 通过
MCP stdio 协议查询 market_sync 的同步情况:

工具列表:
  1. list_sync_tasks        - 列出 13 个 sync task + 当前状态 + 7d 运行统计
  2. get_task_status        - 单个 task 的状态机 + 最近 N 天所有 run
  3. list_datasets          - 17 张 PG 表的行数 + 日期范围
  4. get_dataset_info       - 单表的 schema/索引/PK + 3 行抽样(白名单防注入)
  5. get_today_sync_summary - 今日 KPI + 每个 task 的最新 run

传输: stdio (本地启动 Claude Desktop 直连)
启动: python -m app.mcp_server
依赖: mcp[cli]>=1.0.0 (已加入 requirements.txt)

复用层: app.core.db.ops + app.core.sync.registry 的 helper,
不绕过 ORM session (与 dashboard API 一致)。

验证:
  - initialize 握手 OK
  - tools/list 返回 5 个工具的完整 schema
  - 各 tool 在 stdio 协议下正常返回 JSON
2026-07-07 17:59:05 +08:00
gao 5cb64f181a feat(api): dashboard 看板 + config 配置页
Dashboard (M3):
  - 路由 /api/dashboard/{daily,history,today,datasets,stats}/
  - daily_summary: 按日期聚合 ok/warning/error/blocked
  - history: 同步历史详情(按 task / 日期过滤)
  - today: 今日 KPI + 任务列表
  - datasets: dataset_registry + 最近 7d run 合并
  - stats: 各表行数 + 日期范围(17 张表)

  HTML SPA: app/api/static/dashboard.html
    - 5 张状态卡片 + 7 天趋势表 + 任务状态表 + 数据规模表 + 历史详情
    - 自动 30s 刷新, 纯 vanilla JS (无框架依赖)

Config (M5):
  - 路由 /api/config/{GET list, GET key, PUT upsert, DELETE}/
  - 凭证类自动脱敏 (MAIRUI_LICENCE / XUEQIU_TOKEN / MAIRUI_RPS_LIMIT)
  - POST /api/config/reload: 把 .env / 环境变量已知项回填到 config 表
  - 分类过滤 + key 搜索 + 凭证提示

  HTML SPA: app/api/static/config.html
    - 列表 + 编辑弹窗 + 删除确认
    - 凭证类编辑时强制重新输入完整值(避免显示脱敏误以为是新值)
    - 一键从环境变量回填

FastAPI 主入口挂载:
  - include_router(dashboard, config)
  - mount('/static', StaticFiles) 提供 HTML
2026-07-07 17:54:05 +08:00
gao d217553d79 feat(sync): sync_history 表 + base.py 自动记录每次同步
新增 23 张表 sync_history:append-only,每次 task.run() 完成追加一行。

字段:
  dataset_id / run_date / status / trigger_source / started_at / finished_at /
  elapsed_sec / rows_written / message / error / stats (JSONB) / triggered_by
索引:
  (dataset_id, started_at) / (started_at) / (status)

vs dataset_registry 的区别:
  - dataset_registry 只保留「最后一次状态」便于快查
  - sync_history 保留完整历史,支持看板按天/按 task 维度聚合 + 失败回溯

自动落库:
  - SyncTask.run() 成功后调 _record_history()
  - 异常路径也写(避免排查时漏掉崩溃的 run)
  - 落库失败仅 warning 不阻塞主流程

新增 db_ops helpers:
  - insert_sync_history()
  - list_sync_history(dataset_id?, days, limit) 看板/API 用
  - daily_sync_summary(days) 按日期聚合 ok/warning/error/blocked

_ensure_schema 改造:
  从 no-op 改为用 superuser 连接跑 create_all(),SA 2.x IF NOT EXISTS 幂等。
  这样新增 model 不用手动跑 pg_bootstrap。生产首次部署仍建议跑 pg_bootstrap
  (它还要建 role / schema)。

测试: tests/test_schema_models.py 22 → 23 张表
2026-07-07 16:55:51 +08:00
gao fbc3cc84f0 feat(service): dockerize — Dockerfile + docker-compose + 统一入口
把项目打包成可部署的数据同步服务:

- Dockerfile (multi-stage builder/runtime, slim base, 含 healthcheck)
- docker-compose.yml (postgres:16-alpine + market_sync, 持久化 pgdata + logs)
- .dockerignore (减 build context)
- bin/service_run.sh (单进程跑 uvicorn + scheduler 线程)
- app/entrypoints/worker.py 抽 start_scheduler_thread() (幂等)

部署:
  docker compose up -d
  # 然后访问 http://localhost:8100/docs 看 FastAPI

设计选择:
  - 单容器一服务(uvicorn 主 + scheduler daemon thread),不用 supervisord
  - 原 systemd timer 由进程内 scheduler 取代(配置已存在于 config 表)
  - PG 用 docker volume 持久化;同步日志单独 volume
2026-07-07 16:30:56 +08:00
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
gao e8e35b9716 fix(mairui): 修正 _BASE_URL 子域名(api → a)
mairui 把资金流接口 transaction 路由到 a.mairuiapi.com 子域名,
主域名 api.mairuiapi.com 调用 transaction 返回 HTTP 410 endpoint_unavailable。
其他接口(kline/5min/tick)在两个子域名均正常。

症状:moneyflow 同步 5205 全失败,0 行写入;manual --force 重跑同样 0 行。
原因:项目 _BASE_URL 写成主域名,transaction 接口请求被服务端永久拒绝。

修复:改为文档示例用的 a.mairuiapi.com 子域名。
验证:health_check 通过;moneyflow --force 5204 成 1 败,102731 行入库。
2026-07-07 10:56:28 +08:00
gao f7a2cbe5a8 fix(daily_check): 期望窗口未到不应判 missed
work #07 (2026-07-03):daily_check 在日内运行时(如 09:00/12:00/15:00)
误把'还没到时间'的任务标 missed。SCHEDULE 里有 window_end 但 _classify_task
没用到——直接判 last_success vs today_start。

修复:判定 missed 之前先比较 now vs window_end;未到则返回 not_yet
(新状态,不进 alerts),已过但今日未成功走原 missed 路径。解析失败
回落旧逻辑不引入风险。

验证 4 个用例:12:00 kline_daily→not_yet, 18:00 kline_daily→missed,
18:00 longhubang→not_yet, 22:30 longhubang→missed。全部正确。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 18:25:50 +08:00
gao b351bd7595 feat: 新增 mairui 历史分时 MA 日 K 级别 sync task
work #06 (2026-07-03):user 请求加 mairui /hsdata 历史分时 MA 同步(日 K 级别)。

mairui 端点探测:/d/ma, /d/ma5/10/20, /15/ma, /30/ma, /60/ma 端点结构存在
但当前免费 licence 返 数据不存在;基础 K 线 (/d/n, /15/n, /30/n, /60/n) 正常。

策略:本地从 kline_stock 计算(pandas per-stock rolling),写新表
kline_stock_ma_daily,source=local_kline_proxy 标识本地派生。
mairui URL 留作未来升级 licence 后切 API 用。

变更:
- app/core/db/models.py: KlineStockMADaily ORM model
- app/core/db/ops.py: upsert_kline_stock_ma_daily_rows (批量 5000/批)
- app/tasks/task_mairui_ma_daily.py: SyncMairuiMADaily (全量重算)
- app/tasks/__init__.py: 注册到 TASKS dict
- app/core/sync/registry.py: SYNC_DEFINITION (sort_order=90, dep=kline_daily)
- app/core/scheduler/scheduler.py: schedule_mairui_ma_daily @ 16:30 + register_sync_jobs
- bin/daily_sync_check.py: SCHEDULE entry (window_end=17:00)

烟测:11,684,592 行, 5510 只, 1370s (23min),MA5/10/20/60 全部计算。
SH600519 样本:ma5=1194.18 ma10=1191.03 ma20=1211.94 ma60=1296.20 (2026-07-03)

未来优化(不在本 work):
- 增量模式(每日只算最近 1-2 天)→ 23min → 30s
- 升级 mairui licence 切到 /d/maN API
- 加 EMA / BOLL / KDJ 等其他指标

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 17:36:49 +08:00
gao 46ddf8e171 feat: bin/systemd_deploy.sh 一键部署脚本
work #05 (2026-07-03):Stop hook 反馈 work #04 结尾'目标达成'是错的
预测,不是现状。timer/service 没真的跑 = 数据没真的进 PG。

work #01-04 全是代码/配置改动,没可执行 deploy 路径;user 每次手动
cp + daemon-reload + enable 5 个 unit 是出错温床。

修复:写 bin/systemd_deploy.sh,必须 sudo 跑,幂等,显示 plan → 等
用户确认 → 执行(拷贝 + 删孤儿 + reload + enable 新 timer + 验证)。

下一步:user 跑 sudo bash bin/systemd_deploy.sh 真把 unit 部署到
/etc,daily_check drift 才能归零,sync 目标才进入可观察达成路径。

重要:脚本就绪 ≠ 目标达成;脚本是必要非充分条件。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 11:44:33 +08:00
gao cf45f98a8a feat(daily_check): systemd unit drift 自动检测
work #04 (2026-07-03):user 重申 sync 目标强调 timer/service/依赖/编码格式
全链路审计,发现 5 处 systemd unit drift:work #01 (4h timeout) 和 work #02
(morning.timer) 代码改了但没 cp 到 /etc/systemd/system/,等于没修。

根因:deployment 与 repo 各自演进无任何告警机制。

修复:在 daily_sync_check 加 _check_systemd_drift() 段,对比
bin/systemd/ 与 /etc/systemd/system/ 下所有 market-sync* unit:
- missing_in_etc (未部署) → status=not_deployed
- drifted (两边有但内容不一致, SHA256 比对) → status=drifted
- orphan_in_etc (/etc 有 repo 没维护, 捕获僵尸 unit) → status=orphan

drift 合入 alerts 让 webhook 也带;report schema_version 1→2。

立即验证:跑 daily_check 抓到 5 处 drift(4 未部署 + 1 内容不一致),
status=warning 让 webhook 自动发出——以后任何 deployment drift 都逃不掉。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 11:37:21 +08:00
gao b1ab10b952 chore: 清理 dataset_registry 中 6 个 MySQL/SQLite-era 旧 task 行
work #03 (2026-07-03):daily_check 报告 18 行里有 6 个 disabled 旧 task
(stock_info/kline/hs300/industry/sector/scoring),占 1/3 噪音,干扰
对真正 missed 任务的注意力。

根因:PG 迁移(commit 7a985dd)时把它们 enabled=0 标记禁用但没删行。
旧 sync_script 全部指向 dashboard/api/services/sync/*.py(sibling 项目
已下线),storage_uri 指向 dashboard/data_uat/ 下的 SQLite/Parquet。

变更:
- bin/archive/clean_legacy_registry_rows.py: 一次性清理脚本,保守策略
  (enabled=0 + 旧 storage_layer 或非 app.tasks. 路径),默认 dry-run,
  显式 --apply 才 DELETE
- 实际删除 6 行:stock_info / kline / hs300 / industry / sector / scoring
- docs/works/2026-07-03-03-clean-legacy-registry.md: 完整 work 记录
  (含保守原则说明:enabled=0 但 storage_layer='pg' 的行不在清理范围)

验证:daily_check counts 从 18 行 → 12 行,disabled 段清零。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 10:54:06 +08:00
gao d140fee9e7 fix: 早盘前 09:00 stock_basic + industry_sector 没触发
work #02 (2026-07-03):daily_check 复跑仍 10 missed,细看时间窗口发现
新问题——09:00 / 09:30 该跑的 2 个早盘前 task today 没触发。

根因:worker.py 进程内 scheduler 是预期的主调度(含 09:00 / 09:30),
但 worker.py 没人拉起;systemd timers 又只覆盖 15:30 / 21:xx,缺早盘档。
昨 18h 删 monitor 同时没补 worker.py → 早盘缺口暴露。

变更:
- bin/market_sync_morning_run.sh: 轻量 morning wrapper,只跑 stock_basic
  + industry_sector 串行(~25min),不复用 runall_once.py 的 8 task
- bin/systemd/market-sync-morning.{service,timer}: Mon..Fri 09:00 daily
  触发,TimeoutStartSec=3600 (1h)
- docs/works/2026-07-03-02-morning-no-trigger.md: 完整 work 记录

不在本 work 范围(更大重构):把 worker.py 拉起作为 long-running,
与 systemd timer 收敛到单一调度器——见 dual-scheduler-overlap memory。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 10:50:25 +08:00
gao 295177027f fix: market-sync 2h timeout 截断 + 加 daily-check 巡检
work #01 (2026-07-03):昨天 15:30 runall 中 kline_5min 全量 69min 把 2h
窗口吃满,share_snapshot / market_regime 被 systemd SIGTERM 截断;
今天又叠加 worker.py 未长驻,导致 10 个 task missed。

变更:
- bin/systemd/market-sync.service: TimeoutStartSec 7200 → 14400 (4h)
- bin/daily_sync_check.py: 新增每日巡检,读 PG dataset_registry,
  对比 SCHEDULE 判定 ok/missed/failed/stuck/never_run,写 JSON 报告,
  失败时 POST HMAC webhook
- bin/systemd/market-sync-daily-check.{service,timer}: 23:00 daily 触发
  daily_check(替代 session-only 的 /loop 方案)
- config/daily_check_secrets.env.template: webhook 配置模板
- docs/works/2026-07-03-01-market-sync-timeout.md: 完整 work 记录
  (根因 + 中期/长期改进建议)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 10:48:20 +08:00
120 changed files with 8271 additions and 832 deletions
+31
View File
@@ -0,0 +1,31 @@
# .dockerignore — 减小 build context
.git
.github
.vscode
.idea
.claude
.venv
venv
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
.mypy_cache
.ruff_cache
.coverage
htmlcov/
*.log
logs/
.DS_Store
*.swp
*.swo
node_modules
frontend/node_modules
tests/
docs/
*.md
!README.md
# 已通过 requirements.txt 装好,不重复打包
**/*.tar.gz
**/*.whl
+302
View File
@@ -0,0 +1,302 @@
# Agent 工作记忆
## 活跃监视
task_watch 在后台运行(PID 见 `logs/task_watch.log`),持续跟踪运行中的 task
- 查询 `dataset_registry` 中 status='running' 的任务
- 解析 config 表 lastMessage 中的耗时数据作为历史基准
- 自适应检查间隔(剩余时间 × 0.15,clamp 15s-300s,无历史默认 60s
## 待推进的优化(按优先级)
### P0 - 高优
- **task_watch 耗时记录入库**:当前 watch 只读历史不做记录。任务完成后应将 `dataset_registry.message` 中的 elapsed_sec 写入 config 表(或新建 `run_history` 表),供下次估算 ETA 使用。
- **task_mairui_indicators 外层超时**:与 kline_daily/kline_5min 对齐,给 `as_completed` 加上外层 timeout 兜底(`app/tasks/task_mairui_indicators.py:227`)。
### P1 - 中优
- **节假日自动更新**`trading_calendar_holidays` 当前硬编码在 config 表。建议接入 https://github.com/shengshiyan/dataconf 或类似日历 API 自动拉取每年交易日历。
- **`daily_sync_check` 失败告警**:昨日巡检因 `FAILURE_WEBHOOK_URL/SECRET` 未配置而 exit 1(期望的失败)。需确定是否要配 webhook,或改为非致命 warning。
- **`kline_5min` mairui 偶发全 worker 挂起**:今天 recovery 4734 只失败发生在 mairui 单次 120s 全 hang。`_fetch` 已有 20s timeout + 重试,但仍抗不住 mairui 服务端全挂。建议监控 mairui 健康状态 + 失败股票重入队列。
### P2 - 低优
- **MySQL 残留配置**`.env` 仍包含 `DB_BACKEND=mysql``MYSQL_*` 变量,config.py 已只支持 PG。可清理。
- **`stock_node` 上次 warning**:上次运行 7/10 返回 warning(41/1164 节点失败),需关注是否偶发。
- **`kline_5min` 大量 fail**:今日 recovery 4734 只失败,需单独排查 mairui 数据源。
## 设计决策记录
- **systemd timer 为主,进程内 scheduler 为辅**:宿主机直接部署用 systemd modeDocker 部署用 docker mode。二者通过 `RUNTIME_MODE` 隔离。
- **After= 只依赖 network-online.target**service 间不设 After= 依赖链,避免一个任务阻塞后续所有任务。
- **fail-open 策略**:节假日不过滤,让 task 在非交易日返回空数据报 warning 但不阻塞。周末由 systemd OnCalendar=Mon..Fri 保护。
## 数据集来源与依赖关系
> 数据来源与依赖关系是调度正确性的基础。原始源依赖外部 API;衍生源只读本地 PG,依赖上游原始源。
### 数据集清单
| dataset_id | 目标表 | 类型 | 来源 | 说明 |
|---|---|---|---|---|
| `stock_basic` | `market_data.stocks` | 原始源 | 麦蕊 `/hslt/list`(主)+ Baostock `query_stock_basic`(备) | 全市场 A 股代码/名称/交易所/上市状态;`stock_basic` 自身可附带雪球股本快照 |
| `kline_daily` | `market_data.kline_stock` | 原始源 | 雪球 `kline`(主)→ 麦蕊 `hsstock/history/*/d/n` → 新浪 `getKLineData`(备) | 全市场日 K OHLCV;支持 per-stock fallback |
| `kline_index` | `market_data.kline_index` + `indices` | 原始源 | QMT Bridge(主)→ 麦蕊 `hsindex/history` → 新浪指数日 K(备) | 上证/深证/创业板/沪深300/中证500/中证1000 |
| `kline_5min` | `market_data.kline_5min` | 原始源 | 麦蕊 `hsstock/history/*/5/n` | 全市场 5 分钟 K;历史深度 2023-06-14 起 |
| `tick_trade` | `market_data.tick_trade` | 原始源 | 麦蕊 `hsrl/zbjy` | 当天逐笔成交;每日 21:00 后发布 |
| `moneyflow` | `market_data.moneyflow` | 原始源 | 麦蕊 `hsstock/history/transaction` | 主力/大/中/小单净额;每日 21:30 后发布 |
| `longhubang` | `market_data.longhubang_daily` + `longhubang_seat` | 原始源 | akshare `stock_lhb_detail_em` / `stock_lhb_stock_detail_em`(东方财富封装) | 龙虎榜聚合层 + 席位层;每日 22:00 触发 |
| `stock_node` | `market_data.node_categories` + `nodes` + `stock_node_map` | 原始源 | 麦蕊 `/hszg/{list,gg}` | 股票-指数/行业/概念映射;每周六 11:30 |
| `mairui_indicators` | `market_data.kline_stock_macd_daily` + `kdj_daily` + `boll_daily` | 原始源 | 麦蕊 `hsstock/history/{macd,kdj,boll}` | 日 K MACD/KDJ/BOLL;增量拉取 |
| `share_snapshot` | `market_data.stocks` + `share` | 原始源 | 雪球 `quote_detail` | 最新总股本/流通股本;需 `XUEQIU_TOKEN` |
| `market_regime` | `market_data.market_regime_daily` | 衍生源 | 本地计算(`kline_stock` | 涨跌家数/ advance_ratio / 恐慌标记 |
| `mairui_ma_daily` | `market_data.kline_stock_ma_daily` | 衍生源 | 本地计算(`kline_stock.close` | MA5/10/20/60;未来可切麦蕊 `/d/maN`(需付费 licence |
### 依赖关系
```
stock_basic ─┬─► kline_daily ─┬─► market_regime
│ └─► mairui_ma_daily
├─► kline_5min
├─► tick_trade
├─► moneyflow
├─► longhubang
├─► stock_node
└─► share_snapshot
kline_index (独立,无上游依赖)
```
- **根任务**`stock_basic` 是大多数任务的根,提供股票代码列表。`kline_index` 独立运行。
- **关键链**`kline_daily` 是 2 个衍生源(`market_regime``mairui_ma_daily`)的实际数据基础。
- **注意**`mairui_ma_daily``app/core/sync/registry.py` 中声明依赖 `kline_daily`,但只有**调度顺序**约束;若 `kline_daily` 运行后数据又被回填(如 2026-07-15 场景),`mairui_ma_daily` 不会自动重算,需手动触发或等下次调度。
### 默认调度时间(工作日)
| 时间 | dataset_id | 备注 |
|---|---|---|
| 09:00 | `stock_basic` | 开盘前刷新股票列表 |
| 15:30 | `kline_index` | 指数日 K |
| 15:40 | `kline_daily` | 个股日 K |
| 16:00 | `kline_5min` | 5 分钟 K |
| 15:15 | `market_regime` | 市场情绪 |
| 16:30 | `mairui_ma_daily` | 日 K MA(本地计算) |
| 16:40 | `mairui_indicators` | MACD/KDJ/BOLL |
| 21:35 | `moneyflow` | 资金流(等 21:30 发布) |
| 22:00 | `longhubang` | 龙虎榜(等 19:00-21:00 出齐) |
| 周六 11:30 | `stock_node` | 节点映射 |
| 周六 11:30 | `share_snapshot` | 股本快照(雪球,周度) |
### systemd timer 与数据依赖对齐审核
总体:**基本对齐**,但存在 3 个需要注意的细节。
1. **`mairui_indicators.service``After=` 多余依赖** ✅ 已修复
- 修复前:`After=network-online.target market-sync.service market-sync-mairui-ma-daily.service`
- 修复后:`After=network-online.target market-sync.service`
- 文件:`deploy/systemd/units/market-sync-mairui-indicators.service``bin/systemd/market-sync-mairui-indicators.service`
- 待部署:/etc/systemd/system/ 仍保留旧版,需 `sudo bash deploy/systemd/deploy.sh`
2. **`stock_node.service` 未声明对 `stock_basic` 的依赖** ✅ 已修复
- 修复前:`After=network-online.target`
- 修复后:`After=network-online.target market-sync-morning.service`
- 文件:`deploy/systemd/units/market-sync-stock-node.service``bin/systemd/market-sync-stock-node.service`
- 待部署:/etc/systemd/system/ 仍保留旧版,需 `sudo bash deploy/systemd/deploy.sh`
3. **`After=` 只保证启动顺序,不保证上游成功**
- systemd 的 `After=` 不会让下游等待上游**成功完成**,仅控制 timer 触发后的排队顺序。
- 如果 `market-sync.service` 在 15:30 启动后因 `kline_daily` 卡住而 3h 超时失败,`mairui_ma_daily` 仍会在 16:30 启动(因为 timer 已触发)。
- 结果:`mairui_ma_daily` 可能基于不完整的 `kline_stock` 计算,产生类似 2026-07-15 的 3,404 行缺失。
### 数据回填级联规则
> **核心原则**:上游数据被回填或修复后,下游依赖它的衍生数据集必须手动/自动重跑,否则会出现“上游新、下游旧”的不一致。
| 上游任务/表 | 触发条件 | 必须重跑的下游任务 |
|---|---|---|
| `kline_daily` / `kline_stock` | 回填历史 K 线、修复错误日线、补充漏掉的股票/日期 | `market_regime``mairui_ma_daily` |
| `stock_basic` / `stocks` | 新上市/退市股票、代码变更、上市状态修正 | `kline_daily``kline_5min``tick_trade``moneyflow``longhubang``stock_node``share_snapshot` |
| `kline_stock`(任意修复) | close/volume 等核心字段修正 | `market_regime``mairui_ma_daily` |
**操作建议:**
- 单次少量回填(如几只股票、几天):用 CLI 参数指定 `codes` / `start` / `end`,然后按上表手动触发下游。
- 大量回填(如全市场、多月/多年历史):先跑上游,再按依赖链顺序跑下游;必要时禁用当日独立 timer,避免与 runall 并发。
- 每日巡检(`bin/daily_sync_check.py`)已增加数据一致性检查:对比 `kline_stock``kline_stock_ma_daily``market_regime_daily` 的最新日期与缺失行数,发现缺口即告警。
## 历史问题与解决思路
> 记录真实故障案例、排查路径和修复方法,供后续复察或参考。
### 案例 12026-07-15 `kline_stock_ma_daily` 缺失 3,404 行
**现象**
- `kline_stock` 11,728,408 行,`kline_stock_ma_daily` 只有 11,725,004 行,相差 3,404 行。
- 差异集中在 2026-07-143,400 只股票)+ SH688287 的 4 天(2026-05-29/06-04/06-05/06-08)。
- `dataset_registry``mairui_ma_daily` 状态为 `success``last_success_at=2026-07-14 17:11:48 UTC`
**根因**
- `mairui_ma_daily` 在 2026-07-14 17:11 完成时,`kline_stock` 的 2026-07-14 数据还不完整(少 3,400 只)。
- `kline_daily` 在 2026-07-15 10:04 又跑了一次,补充了这 3,400 只 2026-07-14 的 K 线。
- `mairui_ma_daily` 没有自动感知上游变化,导致 MA 表缺了这 3,404 行。
**排查命令**
```bash
# 1. 对比行数
SELECT COUNT(*) FROM market_data.kline_stock;
SELECT COUNT(*) FROM market_data.kline_stock_ma_daily;
# 2. 查缺失日期分布
SELECT trade_date, COUNT(*) FROM (
SELECT stock_code, trade_date FROM market_data.kline_stock
EXCEPT
SELECT stock_code, trade_date FROM market_data.kline_stock_ma_daily
) t GROUP BY trade_date ORDER BY trade_date;
# 3. 查 dataset_registry 时间线
SELECT dataset_id, last_success_at, finished_at, message
FROM market_data.dataset_registry
WHERE dataset_id IN ('kline_daily', 'mairui_ma_daily');
```
**修复方法**
```bash
# 手动重跑 mairui_ma_daily(全量重算 ~22min
bin/market_sync_mairui_ma_daily_run.sh
```
**预防/改进**
-`bin/daily_sync_check.py` 中增加数据一致性检查:每日 23:00 对比 `kline_stock``kline_stock_ma_daily` 的覆盖差异。
- 上游回填历史数据后,按「数据回填级联规则」手动触发下游重跑。
---
### 案例 2systemd `After=` 依赖设计缺陷
**现象**
- `market-sync-mairui-indicators.service` 写了 `After=market-sync-mairui-ma-daily.service`
- `market-sync-stock-node.service` 没有声明对 `stock_basic` 的依赖。
**根因**
- 早期设计时对数据依赖关系梳理不够,把无依赖的任务串起来,或遗漏了必要的依赖声明。
- `systemd``After=` 只控制**启动顺序**,不检查上游任务是否**成功完成**。
**修复方法**
1. `mairui_indicators.service`:移除 `market-sync-mairui-ma-daily.service`,改为 `After=network-online.target market-sync.service`
2. `stock_node.service`:增加 `After=market-sync-morning.service`,确保 `stock_basic` 已更新。
3. 同步修改 `deploy/systemd/units/``bin/systemd/` 下的对应文件,并执行:
```bash
sudo bash deploy/systemd/deploy.sh
```
**设计原则**
- `After=` 只声明最小必要依赖:network-online.target + 直接上游 service。
- 不要为了让下游等上游成功而过度串 service;成功/失败应通过 `daily_sync_check` 和回填规则兜底。
---
### 案例 3`dataset_registry` 状态卡在 `running`
**现象**
- `mairui_ma_daily` 的 `sync_history` 已记录成功,但 `dataset_registry.status='running'``finished_at=NULL`。
- `ps` 中无对应进程。
**根因**
- 状态更新与 `sync_history` 写入不在同一事务;极端情况下任务进程异常退出,导致 `mark_sync_success` 未执行。
- 或并发/重入导致后一次 `mark_sync_running` 覆盖了前一次的成功状态。
**排查命令**
```bash
SELECT dataset_id, status, started_at, finished_at, last_success_at, message
FROM market_data.dataset_registry
WHERE dataset_id = 'mairui_ma_daily';
SELECT * FROM market_data.sync_history
WHERE dataset_id = 'mairui_ma_daily'
ORDER BY started_at DESC LIMIT 5;
```
**修复方法**
```python
from app.core.db import ops as db_ops
from datetime import datetime
db_ops.update_dataset_registry_state(
'mairui_ma_daily',
status='success',
finished_at='2026-07-15 12:02:21',
last_success_at='2026-07-15 12:02:21',
message='MA [5, 10, 20, 60] 共 11,728,408 行, 5511 只, 1349.2s',
last_error=None,
needs_resync=0,
progress_current=0,
progress_total=0,
current_step='',
updated_at=datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
)
```
**改进建议**
- 启动 `runall_once.py` 时自动调用 `recover_interrupted_dataset_registry()` 清理卡死状态(已实现)。
- `task_watch` 可升级为:发现任务 `running > 2× 历史平均耗时` 且无进程时,自动标记为 failed。
## Market Supervisor 工作流
> 本项目的 Agent 不仅是 code agent,也是 market data supervisor。修改代码/配置后必须完成部署与验证闭环,不能停留在 repo 层面。
### Agent 职责
1. **主动巡检**:定期/按需运行 `bin/daily_sync_check.py --report-only`,确认 dataset 状态、systemd drift、数据一致性。
2. **问题闭环**:发现告警后,定位根因 → 修复代码/config → 部署到 /etc → 验证告警消除。
3. **项目记忆同步**:把已确定的问题、根因、修复方法、验证命令写回 `AGENTS.md`,供后续复察。
4. **数据依赖守护**:上游数据回填/修复后,按「数据回填级联规则」触发下游重跑,并验证一致性。
### systemd unit 修改后的标准流程
任何修改了 `deploy/systemd/units/` 或 `bin/systemd/` 下 unit 文件的操作,必须:
1. **保持两边同步**:同时修改 `deploy/systemd/units/` 和 `bin/systemd/` 的对应文件(目前两者是镜像关系)。
2. **执行 deploy**
```bash
cd /home/gao/Development/quant_home/market_sync
sudo bash deploy/systemd/deploy.sh
# 或跳过确认:sudo bash deploy/systemd/deploy.sh --yes
```
3. **验证 drift**
```bash
.venv/bin/python bin/daily_sync_check.py --report-only
```
确保 `systemd_drift` 中 `has_drift=false`。
4. **验证 unit 状态**
```bash
systemctl list-timers market-sync-*
systemctl status market-sync-mairui-indicators.service
systemctl status market-sync-stock-node.service
```
### 部署验证 Checklist
- [ ] repo unit 与 `/etc/systemd/system/` 内容一致(`daily_sync_check` 无 drift 告警)
- [ ] `systemctl daemon-reload` 已执行
- [ ] 相关 timer 仍 enabled`systemctl is-enabled <name>.timer`
- [ ] 修改后的 service 无语法错误(`systemd-analyze verify /etc/systemd/system/<name>.service`
- [ ] 数据一致性检查通过(`kline_stock` 与下游表日期对齐)
## 已部署的优化(确认完成)
| 项目 | 日期 | 说明 |
|------|------|------|
| `RUNTIME_MODE=systemd` 写入 `.env` | 07-13 | 防止双调度器冲突 |
| `market-sync-worker.service` mask | 07-13 | systemd 模式下禁用进程内 scheduler |
| mairui_ma_daily / mairui_indicators timer 部署 | 07-13 | 2 个缺失的 timer 补部署并启用 |
| `/etc` market-sync.service 漂移修复 | 07-13 | deploy.sh 同步 repo→etc,消除 drift |
| moneyflow/lhb `After=` 依赖链修复 | 07-13 | 去掉 `After=market-sync-tick.service`,任务不再串行阻塞 |
| `bin/task_watch.py` 创建 | 07-13 | 自适应间隔的任务跟踪监视器(nohup 后台运行) |
| `runall_once.py` logger f-string 修复 | 07-13 | 超时日志改为 f-string |
| `sync/base.py` docstring 更新 | 07-13 | `_effective_sync_end` 注释过期问题修复 |
| `call_with_timeout` 超时后线程阻塞修复 | 07-13 | 改用 daemon 线程替代 `ThreadPoolExecutor`,避免超时后 `shutdown(wait=True)` 阻塞调用方 |
| `tick_trade` 外层超时加固 | 07-13 | 加上 FETCH_HARD_TIMEOUT=120s + 手动 pool 管理,防止 mairui 挂起时永久卡住 |
| `kline_daily` per-stock fallback | 07-13 | 雪球失败自动降级到 mairui/新浪,`fallback_ok` 统计备胎救回数 |
| `kline_5min` 超时后重入队列 | 07-13 | mairui 全 hang 超时后冷却 30s 重试一次失败股票,救回临时故障 |
| MCP Server SSE 模式 + systemd 服务 | 07-13 | `market-sync-mcp.service` 开机自启,SSE 端点 `http://127.0.0.1:8101/sse` |
Symlink
+1
View File
@@ -0,0 +1 @@
deploy/docker/Dockerfile
+99 -28
View File
@@ -2,7 +2,7 @@
A 股市场数据**定时同步与管理**框架。
`akshare` / `baostock` / 新浪 / 雪球 / 麦蕊智数 等多个数据源接入到 MySQL 库,提供:
`akshare` / `baostock` / 新浪 / 雪球 / 麦蕊智数 等多个数据源接入到 PostgreSQL 库,提供:
- 后台线程调度器(time-based + interval-based,交易日感知)
- 多数据源 + 自动降级 + 健康度监控
- `dataset_registry` 同步状态机(运行中 / 成功 / 失败 / 中断恢复)
@@ -11,15 +11,69 @@ A 股市场数据**定时同步与管理**框架。
> 📖 详细架构说明见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
## 快速开始
## 运行模式
项目支持两种互斥的运行模式,由环境变量 `RUNTIME_MODE` 控制:
| 模式 | 值 | 适用场景 | 调度方式 |
|---|---|---|---|
| **Docker 模式** | `docker` | `docker compose up` 单容器部署 | 进程内 scheduleruvicorn + daemon thread |
| **systemd 模式** | `systemd` | Linux 宿主机长期运行 | systemd timer/service |
> ⚠️ 两种模式**不能同时启用**,否则同一个 task 会被两边各触发一次,造成状态机抖动和重复 IO。
### Docker 模式
```bash
# 1. 准备环境(首次)
cp .env.example .env # 编辑 .env 填写真实 MySQL 凭据 + XUEQIU_TOKEN
chmod +x start.sh
./start.sh # 自动创建 .venv + 装依赖 + 启动 APIhttp://localhost:8100
cp .env.example .env # 编辑 .env 填写真实 PG 凭据 + XUEQIU_TOKEN + MAIRUI_LICENCE
# 2. 常用命令
# 2. 启动(包含 postgres + market_sync 服务)
docker compose up -d
# 3. 查看日志
docker compose logs -f market_sync
# 4. 手动触发一次任务
docker compose exec market_sync python -m app.entrypoints.cli sync kline_daily
```
Docker 模式下 `RUNTIME_MODE=docker` 已写在 `deploy/docker/docker-compose.yml` 中,容器内会启动进程内 scheduler 负责所有同步任务。
### systemd 模式
```bash
# 1. 准备环境(首次)
cp .env.example .env # 编辑 .env 填写真实 PG 凭据
# 并在 .env 中加上 RUNTIME_MODE=systemd
# 2. 部署 systemd units
sudo bash deploy/systemd/deploy.sh
# 3. 查看 timer
systemctl list-timers market-sync*
# 4. 查看 worker / 任务日志
journalctl -u market-sync -f
journalctl -u market-sync-moneyflow -f
```
systemd 模式下:
- 所有 14 个 task 都由 systemd timer 触发
- `market-sync-worker.service` 会被部署脚本自动 **stop / disable / mask**,避免进程内 scheduler 与 systemd timers 重叠
- 每日 23:00 `market-sync-daily-check.timer` 跑巡检,失败时 POST webhook
### 本地开发模式
```bash
# 1. 准备环境(首次)
cp .env.example .env # 编辑 .env 填写凭据
chmod +x start.sh
# 2. 启动 API + scheduler(默认 docker 行为,等价于本地单进程)
./start.sh # http://localhost:8100
# 3. 常用命令
./start.sh worker # 仅启动调度器(无 web
./start.sh list # 列出所有同步任务
./start.sh sync kline_daily # 手动触发一次(dataset_id 即可)
@@ -27,39 +81,54 @@ chmod +x start.sh
./start.sh datasources # 查看数据源健康度
./start.sh reseed # 重新 seed 默认配置到 config 表
# 3. 一键全量同步(首次接入)
# 4. 一键全量同步(首次接入)
python bin/runall_once.py
# 4. 跑测试
# 5. 跑测试
.venv/bin/pytest tests/ -v
```
## 项目结构
```
app/
├── core/ # 核心抽象(config / db / sync / scheduler / datasource base
├── sources/ # 数据源实现5 个)
├── tasks/ # 同步任务实现(8 个)
├── api/ # FastAPI 管理接口
└── entrypoints/ # 进程入口(cli + worker
bin/ # 一次性脚本
docs/ # 架构文档
tests/ # 单元测试
app/ # 应用源码
├── core/ # 核心抽象(config / db / sync / scheduler / datasource base
├── sources/ # 数据源实现
├── tasks/ # 同步任务实现(14 个)
├── api/ # FastAPI 管理接口
└── entrypoints/ # 进程入口(cli + worker
bin/ # 通用脚本(运行入口、CLI、一次性任务)
config/ # 配置文件、secrets
deploy/ # 部署相关
├── docker/ # Dockerfile + docker-compose.yml
└── systemd/ # systemd units + 部署脚本
│ ├── units/ # *.service / *.timer
│ └── deploy.sh # 一键部署脚本
docs/ # 架构文档
logs/ # 日志输出
tests/ # 单元测试
Dockerfile -> deploy/docker/Dockerfile
docker-compose.yml -> deploy/docker/docker-compose.yml
bin/deploy_systemd.sh -> deploy/systemd/deploy.sh
bin/systemd_deploy.sh -> deploy/systemd/deploy.sh
```
## 内置同步任务
| dataset_id | 触发时间 | 说明 |
|---|---|---|
| `stock_basic` | 交易日 09:00 | 全市场股票基础信息 + 股本 |
| `kline_daily` | 交易日 15:40 | 增量日 K 线(OHLCV,多源交叉) |
| `kline_index` | 交易日 15:50 | 六大指数日线 |
| `kline_5min` | 交易日 16:00 | 增量 5 分钟 K 线 |
| `moneyflow` | 交易日 16:30 | 资金流(主力/大/中/小单净额 |
| `industry_sector` | 周一 09:30 | 股票-行业映射(Baostock |
| `share_snapshot` | 交易日 09:30 | 股本快照(雪球 |
| `market_regime` | 交易日 16:00 | 市场情绪(衍生源 |
| dataset_id | systemd 触发时间 | Docker scheduler 时间 | 说明 |
|---|---|---|---|
| `stock_basic` | 工作日 09:00 | 09:00 | 全市场股票基础信息 + 股本 |
| `kline_index` | 工作日 15:30 | 15:30 | 六大指数日线 |
| `kline_daily` | 工作日 15:30runall| 15:40 | 增量日 K 线(OHLCV,多源交叉) |
| `kline_5min` | 工作日 15:30runall| 16:00 | 增量 5 分钟 K 线 |
| `market_regime` | 工作日 15:30runall| 16:15 | 市场情绪(衍生源 |
| `share_snapshot` | 工作日 15:30runall| 16:20 | 股本快照(雪球 |
| `mairui_ma_daily` | 工作日 16:30 | 16:30 | 日 K MA5/10/20/60(本地派生 |
| `mairui_indicators` | 工作日 16:40 | 16:40 | 日 K MACD/KDJ/BOLLmairui |
| `moneyflow` | 工作日 21:35 | 21:35 | 资金流(主力/大/中/小单净额) |
| `tick_trade` | 工作日 21:05 | 21:05 | 当天逐笔成交(mairui |
| `longhubang` | 工作日 22:00 | 22:00 | 龙虎榜(akshare / 东方财富) |
| `stock_node` | 周六 11:30 | 周六 11:30 | 股票-节点映射(mairui |
## 添加新数据源
@@ -70,7 +139,9 @@ tests/ # 单元测试
1.`app/tasks/` 新建 `task_xxx.py`,继承 `SyncTask`,设置 `dataset_id`
2.`app/tasks/__init__.py``TASKS` 字典注册
3. (可选)`app/core/sync/registry.py``SYNC_DEFINITIONS` 加条目
3.`app/core/sync/registry.py``SYNC_DEFINITIONS` 加条目
4. 如果使用 systemd 模式,在 `deploy/systemd/units/` 新增对应的 `.service``.timer`
5. 如果使用 Docker 模式,在 `app/core/scheduler/scheduler.py``DEFAULT_SCHEDULES` 新增调度项
## License
+10 -1
View File
@@ -13,10 +13,11 @@ if str(_PROJECT_ROOT) not in sys.path:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.core.config import settings
from app.core.utils.logging import get_logger, setup_logging
from app.api.routes import health, sync, datasources, schedule
from app.api.routes import health, sync, datasources, schedule, dashboard, config
setup_logging()
logger = get_logger("api")
@@ -38,6 +39,14 @@ app.include_router(health.router)
app.include_router(sync.router)
app.include_router(datasources.router)
app.include_router(schedule.router)
app.include_router(dashboard.router)
app.include_router(config.router)
# 看板 HTML 静态文件
import pathlib
_static_dir = pathlib.Path(__file__).resolve().parent / "static"
if _static_dir.exists():
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
# ── 启动钩子 ──────────────────────────────────────────────────────────
+120
View File
@@ -0,0 +1,120 @@
"""配置管理 API:读写 config 表(MAIRUI_LICENCE / XUEQIU_TOKEN 等可视化配置)。
凭证类配置(MAIRUI_LICENCE / XUEQIU_TOKEN)写到 config 表后,可在下次
任务运行时通过 settings 重新读取(注: 当前代码是 os.environ.get 一启动就
固化,改 config 表不会立即生效 — 见 README 说明)。
"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Body, HTTPException, Query
from app.core.db import ops as db_ops
from app.core.db.models import Config
router = APIRouter(prefix="/api/config", tags=["config"])
# 已知凭证类配置: 写入时建议显示为占位符,读出时也建议脱敏
SENSITIVE_KEYS = {"MAIRUI_LICENCE", "XUEQIU_TOKEN", "MAIRUI_RPS_LIMIT"}
def _mask(key: str, value: str) -> str:
"""凭证类配置显示前 8 位 + ***。"""
if key in SENSITIVE_KEYS and value:
if len(value) <= 8:
return "*" * len(value)
return value[:8] + "***"
return value
@router.get("/")
def list_configs(
category: Optional[str] = Query(None),
search: Optional[str] = Query(None, description="按 key 模糊搜索"),
):
"""列出所有 config 项。凭证类自动脱敏。"""
rows = db_ops.fetch_all_config()
if category:
rows = [r for r in rows if r.get("category") == category]
if search:
s = search.lower()
rows = [r for r in rows if s in r["key"].lower()]
# 脱敏
for r in rows:
r["value"] = _mask(r["key"], r.get("value", ""))
return {"count": len(rows), "configs": rows}
@router.get("/{key}")
def get_config(key: str):
r = db_ops.fetch_config_by_key(key)
if not r:
raise HTTPException(status_code=404, detail=f"config {key!r} not found")
r["value"] = _mask(key, r.get("value", ""))
return r
@router.put("/{key}")
def upsert_config(
key: str,
body: dict = Body(...),
):
"""新建或更新一条 config。"""
value = body.get("value", "")
category = body.get("category", "general")
description = body.get("description", "")
if not isinstance(value, str):
raise HTTPException(status_code=400, detail="value must be a string")
db_ops.upsert_config(key=key, value=value, category=category, description=description)
return {"ok": True, "key": key}
@router.delete("/{key}")
def delete_config(key: str):
db_ops.delete_config(key)
return {"ok": True, "key": key}
@router.get("/categories/list")
def list_categories():
"""返回所有 category(用于前端下拉)。"""
from sqlalchemy import distinct, select
with db_ops.get_session() as s:
cats = s.execute(select(distinct(Config.category))).scalars().all()
return {"categories": sorted([c for c in cats if c])}
@router.post("/reload")
def reload_from_env():
"""把 .env / 环境变量里的已知配置回填到 config 表(覆盖式)。"""
import os
from app.core.config import settings
# 已知 env-driven 配置项
env_keys = [
("MAIRUI_LICENCE", os.environ.get("MAIRUI_LICENCE", ""), "general", "麦蕊智数 licence"),
("XUEQIU_TOKEN", os.environ.get("XUEQIU_TOKEN", ""), "credential", "雪球 token (含 xq_a_token=...;u=...)"),
("MAIRUI_RPS_LIMIT", os.environ.get("MAIRUI_RPS_LIMIT", ""), "tuning", "麦蕊智数 RPS 限速"),
("DS_BAOSTOCK_ENABLED", str(settings.ds_baostock_enabled).lower(), "datasource", "baostock 开关"),
("DS_SINA_ENABLED", str(settings.ds_sina_enabled).lower(), "datasource", "新浪 开关"),
("TRADING_HOLIDAYS", settings.trading_holidays, "trading_calendar", "A 股休市日 (YYYY-MM-DD,逗号分隔)"),
("API_PORT", str(settings.api_port), "api", "API 监听端口"),
("SCHEDULER_TICK_SECONDS", str(settings.scheduler_tick_seconds), "scheduler", "调度器扫描间隔"),
]
n = 0
for key, value, cat, desc in env_keys:
if not value:
continue
db_ops.upsert_config(key=key, value=value, category=cat, description=desc)
n += 1
return {"ok": True, "synced": n}
@router.get("/page")
def config_page():
"""配置页 HTML 入口(前端单文件 SPA)。"""
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/static/config.html")
+134
View File
@@ -0,0 +1,134 @@
"""看板 API:每天同步情况 / 任务历史 / 数据集统计。
复用 db_ops.list_sync_history() + daily_sync_summary()。
"""
from __future__ import annotations
from datetime import date
from fastapi import APIRouter, Query
from app.core.db import ops as db_ops
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@router.get("/daily")
def daily_summary(days: int = Query(7, ge=1, le=90)):
"""按日期聚合的同步状态计数(看板首页用)。"""
return {"days": days, "summary": db_ops.daily_sync_summary(days=days)}
@router.get("/history")
def sync_history(
dataset_id: str | None = Query(None),
days: int = Query(7, ge=1, le=90),
limit: int = Query(200, ge=1, le=1000),
run_date: str | None = Query(None, description="YYYY-MM-DD 过滤某天"),
):
"""同步历史记录(每次 run 一行,append-only)。"""
rd = None
if run_date:
try:
rd = date.fromisoformat(run_date)
except ValueError:
return {"error": f"run_date must be YYYY-MM-DD, got {run_date!r}"}
rows = db_ops.list_sync_history(
dataset_id=dataset_id, run_date=rd, days=days, limit=limit,
)
return {"dataset_id": dataset_id, "days": days, "count": len(rows), "history": rows}
@router.get("/today")
def today_summary():
"""今天的同步情况(看板头部卡片用)。"""
rows = db_ops.daily_sync_summary(days=1)
today_str = date.today().isoformat()
today = next((r for r in rows if r["date"] == today_str), None) or {
"date": today_str, "ok": 0, "warning": 0, "error": 0, "blocked": 0,
"total": 0, "tasks_run": 0,
}
# 列出今天每个 task 的最新状态
history = db_ops.list_sync_history(days=1, limit=500)
return {"summary": today, "tasks": history}
@router.get("/datasets")
def dataset_overview():
"""所有 dataset 的当前状态 + 最近一次 run。
合并 dataset_registry(当前状态)+ sync_history(最近一次历史)。
"""
from app.core.sync import get_registry_status
registry = get_registry_status()
history = db_ops.list_sync_history(days=7, limit=500)
last_by_task: dict[str, dict] = {}
for h in history:
if h["dataset_id"] not in last_by_task:
last_by_task[h["dataset_id"]] = h
enriched = []
for r in registry:
ds_id = r["dataset_id"]
last = last_by_task.get(ds_id)
enriched.append({
**r,
"last_run": last,
"history_count_7d": sum(1 for h in history if h["dataset_id"] == ds_id),
})
return {"count": len(enriched), "datasets": enriched}
@router.get("/stats")
def data_stats():
"""数据集的数据范围/数据量(用于看板"数据规模"卡片)。"""
from sqlalchemy import func, select
from app.core.db.models import (
KlineStock, KlineIndex, Kline5Min, Moneyflow, Share, TickTrade,
MarketRegimeDaily,
LonghubangDaily, LonghubangSeat, Stock,
NodeCategory, Node, StockNodeMap, KlineStockMADaily,
KlineStockMACDDaily, KlineStockKDJDaily, KlineStockBOLLDaily,
)
from app.core.db.orm import SessionLocal
targets = [
("stocks", Stock),
("kline_stock", KlineStock),
("kline_index", KlineIndex),
("kline_5min", Kline5Min),
("kline_stock_ma_daily", KlineStockMADaily),
("moneyflow", Moneyflow),
("share", Share),
("tick_trade", TickTrade),
("market_regime_daily", MarketRegimeDaily),
("longhubang_daily", LonghubangDaily),
("longhubang_seat", LonghubangSeat),
("node_categories", NodeCategory),
("nodes", Node),
("stock_node_map", StockNodeMap),
("kline_stock_macd_daily", KlineStockMACDDaily),
("kline_stock_kdj_daily", KlineStockKDJDaily),
("kline_stock_boll_daily", KlineStockBOLLDaily),
]
out = []
with SessionLocal() as s:
for name, model in targets:
try:
count = s.execute(select(func.count()).select_from(model)).scalar() or 0
# 尝试拿最早/最新 trade_date
date_range = None
if hasattr(model, "trade_date"):
mn = s.execute(select(func.min(model.trade_date))).scalar()
mx = s.execute(select(func.max(model.trade_date))).scalar()
date_range = {"min": str(mn), "max": str(mx)} if mn else None
out.append({"table": name, "rows": int(count), "date_range": date_range})
except Exception as e:
out.append({"table": name, "rows": -1, "error": str(e)[:100]})
return {"tables": out}
@router.get("/")
def dashboard_index():
"""看板 HTML 入口(前端单文件 SPA)。"""
from fastapi.responses import FileResponse, RedirectResponse
return RedirectResponse(url="/static/dashboard.html")
+264
View File
@@ -0,0 +1,264 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>market_sync 参数配置</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0; padding: 20px; background: #f6f8fa; color: #24292e; }
h1 { margin: 0 0 8px; font-size: 22px; }
h2 { margin: 24px 0 12px; font-size: 16px; color: #586069; }
.meta { color: #6a737d; font-size: 13px; margin-bottom: 20px; }
.meta a { color: #0969da; text-decoration: none; }
.toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
select, input, button, textarea {
padding: 6px 10px; border: 1px solid #d0d7de; border-radius: 6px;
background: #fff; font-size: 13px; font-family: inherit;
}
button { cursor: pointer; }
button:hover { background: #f3f4f6; }
button.primary { background: #1f883d; color: #fff; border-color: #1a7f37; }
button.primary:hover { background: #1a7f37; }
button.danger { background: #cf222e; color: #fff; border-color: #cf222e; }
button.danger:hover { background: #b51a24; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #d0d7de;
border-radius: 8px; overflow: hidden; font-size: 13px; }
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eaecef; vertical-align: middle; }
th { background: #f6f8fa; font-weight: 600; color: #57606a; font-size: 12px;
text-transform: uppercase; letter-spacing: 0.3px; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f6f8fa; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
background: #eff1f3; padding: 1px 6px; border-radius: 3px; }
.category-pill { display: inline-block; padding: 1px 8px; border-radius: 10px;
font-size: 11px; background: #ddf4ff; color: #0969da; }
.muted { color: #6a737d; font-size: 12px; }
.alert { padding: 10px 14px; border-radius: 6px; margin-bottom: 12px; font-size: 13px; }
.alert.info { background: #ddf4ff; color: #0969da; border: 1px solid #b6e3ff; }
.alert.warn { background: #fff8c5; color: #9a6700; border: 1px solid #d4a72c; }
.alert.ok { background: #dafbe1; color: #1a7f37; border: 1px solid #4ac26b; }
input[type=text] { width: 100%; }
td.actions { white-space: nowrap; text-align: right; }
td.actions button { margin-left: 4px; padding: 3px 8px; font-size: 12px; }
.sensitive { font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: #8250df; }
</style>
</head>
<body>
<h1>⚙️ 参数配置</h1>
<div class="meta">
读写 <code>market_data.config</code> 表。
· <a href="/api/dashboard/">📊 数据看板</a>
· <a href="/docs" target="_blank">FastAPI Docs</a>
</div>
<div id="alert"></div>
<div class="toolbar">
<label>分类:</label>
<select id="catFilter">
<option value="">— 全部 —</option>
</select>
<label>搜索:</label>
<input type="text" id="searchBox" placeholder="按 key 模糊搜索..." style="width: 240px;">
<button onclick="loadConfigs()">刷新</button>
<button onclick="reloadFromEnv()">从环境变量回填</button>
<button class="primary" onclick="showCreate()">+ 新建</button>
</div>
<table id="cfgTable">
<thead><tr>
<th>Key</th><th>Value</th><th>分类</th><th>说明</th>
<th>更新时间</th><th class="actions">操作</th>
</tr></thead>
<tbody><tr><td colspan="6" class="muted">加载中...</td></tr></tbody>
</table>
<!-- 编辑/新建弹窗 -->
<div id="modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.4);
z-index:100; align-items:center; justify-content:center;">
<div style="background:#fff; border-radius:8px; padding:20px; width:520px; max-width:90vw;">
<h3 id="modalTitle">新建配置</h3>
<div style="margin:12px 0;">
<label>Key</label>
<input type="text" id="modalKey" placeholder="例: MAIRUI_LICENCE" style="width:100%;">
</div>
<div style="margin:12px 0;">
<label>Value <span id="sensitiveHint" style="color:#8250df; display:none;">(凭证类,输入完整值;显示时会脱敏)</span></label>
<textarea id="modalValue" rows="3" style="width:100%; font-family:monospace;"></textarea>
</div>
<div style="margin:12px 0;">
<label>分类</label>
<input type="text" id="modalCategory" placeholder="general / credential / scheduler / api / datasource / ..." style="width:100%;">
</div>
<div style="margin:12px 0;">
<label>说明</label>
<input type="text" id="modalDesc" placeholder="可选, 描述这个配置干嘛的" style="width:100%;">
</div>
<div style="text-align:right; margin-top:16px;">
<button onclick="hideModal()">取消</button>
<button class="primary" onclick="saveModal()">保存</button>
</div>
</div>
</div>
<script>
const SENSITIVE = new Set(['MAIRUI_LICENCE', 'XUEQIU_TOKEN', 'MAIRUI_RPS_LIMIT']);
let _allConfigs = [];
let _editingKey = null;
function alertBox(msg, type='info', timeout=4000) {
const el = document.getElementById('alert');
el.innerHTML = `<div class="alert ${type}">${msg}</div>`;
if (timeout > 0) setTimeout(() => el.innerHTML = '', timeout);
}
async function fetchJson(p, opts) {
const r = await fetch(p, opts);
if (!r.ok) {
const t = await r.text();
throw new Error(`HTTP ${r.status}: ${t}`);
}
return r.json();
}
async function loadConfigs() {
try {
const r = await fetchJson('/api/config/');
_allConfigs = r.configs;
// 填充 category 下拉
const cats = await fetchJson('/api/config/categories/list');
const sel = document.getElementById('catFilter');
const cur = sel.value;
sel.innerHTML = '<option value="">— 全部 —</option>' +
cats.categories.map(c => `<option value="${c}">${c}</option>`).join('');
sel.value = cur;
renderTable();
} catch (e) {
alertBox('加载失败: ' + e.message, 'warn');
}
}
function renderTable() {
const cat = document.getElementById('catFilter').value;
const search = document.getElementById('searchBox').value.toLowerCase();
const rows = _allConfigs.filter(r =>
(!cat || r.category === cat) &&
(!search || r.key.toLowerCase().includes(search))
);
const tbody = document.querySelector('#cfgTable tbody');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="6" class="muted">没有匹配的配置</td></tr>';
return;
}
tbody.innerHTML = rows.map(r => {
const sensitive = SENSITIVE.has(r.key);
const valDisplay = sensitive
? `<code class="sensitive" title="点击查看完整值 → 编辑时输入新值">${r.value || '(empty)'}</code>`
: `<code>${(r.value || '').slice(0, 80)}${(r.value || '').length > 80 ? '...' : ''}</code>`;
return `<tr>
<td><code>${r.key}</code></td>
<td>${valDisplay}</td>
<td><span class="category-pill">${r.category || '-'}</span></td>
<td class="muted">${r.description || ''}</td>
<td class="muted">${r.updated_at ? new Date(r.updated_at).toLocaleString('zh-CN') : '-'}</td>
<td class="actions">
<button onclick="editConfig('${r.key}')">编辑</button>
<button class="danger" onclick="deleteConfig('${r.key}')">删除</button>
</td>
</tr>`;
}).join('');
}
function showCreate() {
_editingKey = null;
document.getElementById('modalTitle').textContent = '新建配置';
document.getElementById('modalKey').value = '';
document.getElementById('modalKey').disabled = false;
document.getElementById('modalValue').value = '';
document.getElementById('modalCategory').value = 'general';
document.getElementById('modalDesc').value = '';
document.getElementById('sensitiveHint').style.display = 'none';
document.getElementById('modal').style.display = 'flex';
document.getElementById('modalKey').oninput = () => {
const k = document.getElementById('modalKey').value;
document.getElementById('sensitiveHint').style.display =
SENSITIVE.has(k) ? 'inline' : 'none';
};
}
async function editConfig(key) {
try {
const r = await fetchJson(`/api/config/${encodeURIComponent(key)}`);
_editingKey = key;
document.getElementById('modalTitle').textContent = '编辑配置 ' + key;
document.getElementById('modalKey').value = key;
document.getElementById('modalKey').disabled = true;
document.getElementById('modalValue').value = ''; // 编辑凭证类时强制重新输入完整值
document.getElementById('modalValue').placeholder = SENSITIVE.has(key)
? '凭证类 — 输入完整值(显示已脱敏)' : '新值(留空保留原值不可行,PUT 接口会覆盖)';
document.getElementById('modalCategory').value = r.category || 'general';
document.getElementById('modalDesc').value = r.description || '';
document.getElementById('sensitiveHint').style.display =
SENSITIVE.has(key) ? 'inline' : 'none';
document.getElementById('modal').style.display = 'flex';
} catch (e) {
alertBox('加载失败: ' + e.message, 'warn');
}
}
function hideModal() { document.getElementById('modal').style.display = 'none'; }
async function saveModal() {
const key = document.getElementById('modalKey').value.trim();
const value = document.getElementById('modalValue').value;
const category = document.getElementById('modalCategory').value.trim() || 'general';
const description = document.getElementById('modalDesc').value;
if (!key) { alertBox('Key 不能为空', 'warn'); return; }
try {
await fetchJson(`/api/config/${encodeURIComponent(key)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({value, category, description}),
});
hideModal();
alertBox(`✓ 已保存 ${key}`, 'ok');
await loadConfigs();
} catch (e) {
alertBox('保存失败: ' + e.message, 'warn');
}
}
async function deleteConfig(key) {
if (!confirm(`确认删除 ${key} ?`)) return;
try {
await fetchJson(`/api/config/${encodeURIComponent(key)}`, {method: 'DELETE'});
alertBox(`✓ 已删除 ${key}`, 'ok');
await loadConfigs();
} catch (e) {
alertBox('删除失败: ' + e.message, 'warn');
}
}
async function reloadFromEnv() {
if (!confirm('把 .env / 环境变量里已知配置回填到 config 表(会覆盖)?')) return;
try {
const r = await fetchJson('/api/config/reload', {method: 'POST'});
alertBox(`✓ 已回填 ${r.synced}`, 'ok');
await loadConfigs();
} catch (e) {
alertBox('回填失败: ' + e.message, 'warn');
}
}
document.getElementById('catFilter').addEventListener('change', renderTable);
document.getElementById('searchBox').addEventListener('input', renderTable);
loadConfigs();
</script>
</body>
</html>
+231
View File
@@ -0,0 +1,231 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>market_sync 数据看板</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0; padding: 20px; background: #f6f8fa; color: #24292e; }
h1 { margin: 0 0 8px; font-size: 22px; }
h2 { margin: 24px 0 12px; font-size: 16px; color: #586069; }
.meta { color: #6a737d; font-size: 13px; margin-bottom: 20px; }
.grid { display: grid; gap: 12px; }
.grid.cards { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
.card { background: #fff; border: 1px solid #d0d7de; border-radius: 8px; padding: 14px 16px; }
.card .label { font-size: 12px; color: #6a737d; text-transform: uppercase; letter-spacing: 0.5px; }
.card .value { font-size: 26px; font-weight: 600; margin-top: 4px; }
.card.ok .value { color: #1a7f37; }
.card.warn .value { color: #9a6700; }
.card.err .value { color: #cf222e; }
.card.block .value { color: #8250df; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #d0d7de;
border-radius: 8px; overflow: hidden; font-size: 13px; }
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eaecef; }
th { background: #f6f8fa; font-weight: 600; color: #57606a; font-size: 12px;
text-transform: uppercase; letter-spacing: 0.3px; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f6f8fa; }
.status-pill { display: inline-block; padding: 1px 8px; border-radius: 10px;
font-size: 11px; font-weight: 600; }
.status-ok { background: #dafbe1; color: #1a7f37; }
.status-warning { background: #fff8c5; color: #9a6700; }
.status-error { background: #ffebe9; color: #cf222e; }
.status-blocked { background: #fbefff; color: #8250df; }
.status-running { background: #ddf4ff; color: #0969da; }
.status-idle { background: #eaeef2; color: #57606a; }
.toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
select, button { padding: 6px 10px; border: 1px solid #d0d7de; border-radius: 6px;
background: #fff; font-size: 13px; }
button { cursor: pointer; }
button:hover { background: #f3f4f6; }
.row { display: flex; gap: 12px; align-items: center; }
.muted { color: #6a737d; font-size: 12px; }
.err-cell { color: #cf222e; font-family: monospace; font-size: 11px; max-width: 360px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.num { font-variant-numeric: tabular-nums; text-align: right; }
</style>
</head>
<body>
<h1>📊 market_sync 数据看板</h1>
<div class="meta">
<span id="updatedAt">加载中...</span>
· 自动每 30s 刷新
· 数据源: <a href="/docs" target="_blank">/api/dashboard/*</a>
</div>
<!-- 今日状态卡片 -->
<h2>今日 (今日运行情况)</h2>
<div class="grid cards" id="todayCards">
<div class="card"><div class="label">总任务</div><div class="value" id="kpi-total">-</div></div>
<div class="card ok"><div class="label">✓ OK</div><div class="value" id="kpi-ok">-</div></div>
<div class="card warn"><div class="label">⚠ Warning</div><div class="value" id="kpi-warn">-</div></div>
<div class="card err"><div class="label">✗ Error</div><div class="value" id="kpi-err">-</div></div>
<div class="card block"><div class="label">⊘ Blocked</div><div class="value" id="kpi-block">-</div></div>
</div>
<!-- 7 天趋势 -->
<h2>最近 7 天每日同步情况</h2>
<table id="dailyTable">
<thead><tr>
<th>日期</th><th class="num">OK</th><th class="num">Warning</th>
<th class="num">Error</th><th class="num">Blocked</th>
<th class="num">Total</th><th class="num">Tasks Run</th>
</tr></thead>
<tbody><tr><td colspan="7" class="muted">加载中...</td></tr></tbody>
</table>
<!-- 任务状态 -->
<h2>所有同步任务 (实时状态 + 最近 7 天)</h2>
<table id="datasetsTable">
<thead><tr>
<th>Task</th><th>Name</th><th>Source</th><th>Status</th>
<th>Last Run</th><th>7d Runs</th><th>Rows</th><th>Elapsed</th>
</tr></thead>
<tbody><tr><td colspan="8" class="muted">加载中...</td></tr></tbody>
</table>
<!-- 数据规模 -->
<h2>数据规模</h2>
<table id="statsTable">
<thead><tr><th>Table</th><th class="num">Rows</th><th>Date Range</th></tr></thead>
<tbody><tr><td colspan="3" class="muted">加载中...</td></tr></tbody>
</table>
<!-- 历史详情 -->
<h2>同步历史 (最近 200 条)</h2>
<div class="toolbar">
<label>Filter by Task:</label>
<select id="taskFilter"><option value="">— 全部 —</option></select>
<button onclick="loadHistory()">刷新</button>
</div>
<table id="historyTable">
<thead><tr>
<th>Started</th><th>Task</th><th>Status</th><th>Trigger</th>
<th class="num">Elapsed (s)</th><th class="num">Rows</th>
<th>Message</th>
</tr></thead>
<tbody><tr><td colspan="7" class="muted">加载中...</td></tr></tbody>
</table>
<script>
const fmt = n => (n == null || n === '' ? '-' : Number(n).toLocaleString());
const fmtTime = s => s ? new Date(s).toLocaleString('zh-CN') : '-';
const statusPill = s => `<span class="status-pill status-${s}">${s || '-'}</span>`;
async function fetchJson(path) {
const r = await fetch(path);
if (!r.ok) throw new Error(`${path}: HTTP ${r.status}`);
return r.json();
}
async function loadToday() {
const r = await fetchJson('/api/dashboard/today');
const s = r.summary || {};
document.getElementById('kpi-total').textContent = fmt(s.total);
document.getElementById('kpi-ok').textContent = fmt(s.ok);
document.getElementById('kpi-warn').textContent = fmt(s.warning);
document.getElementById('kpi-err').textContent = fmt(s.error);
document.getElementById('kpi-block').textContent = fmt(s.blocked);
}
async function loadDaily() {
const r = await fetchJson('/api/dashboard/daily?days=7');
const tbody = document.querySelector('#dailyTable tbody');
if (!r.summary.length) {
tbody.innerHTML = '<tr><td colspan="7" class="muted">暂无数据</td></tr>';
return;
}
tbody.innerHTML = r.summary.map(d => `<tr>
<td>${d.date}</td>
<td class="num" style="color:#1a7f37">${fmt(d.ok)}</td>
<td class="num" style="color:#9a6700">${fmt(d.warning)}</td>
<td class="num" style="color:#cf222e">${fmt(d.error)}</td>
<td class="num" style="color:#8250df">${fmt(d.blocked)}</td>
<td class="num">${fmt(d.total)}</td>
<td class="num">${fmt(d.tasks_run)}</td>
</tr>`).join('');
}
async function loadDatasets() {
const r = await fetchJson('/api/dashboard/datasets');
const tbody = document.querySelector('#datasetsTable tbody');
if (!r.datasets.length) {
tbody.innerHTML = '<tr><td colspan="8" class="muted">暂无数据</td></tr>';
return;
}
// populate task filter
const sel = document.getElementById('taskFilter');
r.datasets.forEach(d => {
const opt = document.createElement('option');
opt.value = d.dataset_id; opt.textContent = d.dataset_id;
sel.appendChild(opt);
});
tbody.innerHTML = r.datasets.map(d => {
const last = d.last_run || {};
return `<tr>
<td><code>${d.dataset_id}</code></td>
<td>${d.name || '-'}</td>
<td class="muted">${(d.source || '').slice(0, 40)}</td>
<td>${statusPill(d.status)}</td>
<td class="muted">${fmtTime(last.started_at)}</td>
<td class="num">${fmt(d.history_count_7d)}</td>
<td class="num">${fmt(last.rows_written)}</td>
<td class="num">${last.elapsed_sec ? last.elapsed_sec.toFixed(1) : '-'}</td>
</tr>`;
}).join('');
}
async function loadStats() {
const r = await fetchJson('/api/dashboard/stats');
const tbody = document.querySelector('#statsTable tbody');
if (!r.tables.length) {
tbody.innerHTML = '<tr><td colspan="3" class="muted">暂无数据</td></tr>';
return;
}
tbody.innerHTML = r.tables.map(t => `<tr>
<td><code>${t.table}</code></td>
<td class="num">${t.rows < 0 ? '⚠ ' + t.error : fmt(t.rows)}</td>
<td class="muted">${t.date_range ? `${t.date_range.min} ~ ${t.date_range.max}` : '-'}</td>
</tr>`).join('');
}
async function loadHistory() {
const taskId = document.getElementById('taskFilter').value;
const url = taskId
? `/api/dashboard/history?dataset_id=${encodeURIComponent(taskId)}&limit=200`
: `/api/dashboard/history?limit=200`;
const r = await fetchJson(url);
const tbody = document.querySelector('#historyTable tbody');
if (!r.history.length) {
tbody.innerHTML = '<tr><td colspan="7" class="muted">暂无数据</td></tr>';
return;
}
tbody.innerHTML = r.history.map(h => `<tr>
<td class="muted">${fmtTime(h.started_at)}</td>
<td><code>${h.dataset_id}</code></td>
<td>${statusPill(h.status)}</td>
<td class="muted">${h.trigger_source}</td>
<td class="num">${h.elapsed_sec != null ? h.elapsed_sec.toFixed(1) : '-'}</td>
<td class="num">${fmt(h.rows_written)}</td>
<td title="${(h.error || '').slice(0, 200)}" class="err-cell">${(h.message || '').slice(0, 80)}</td>
</tr>`).join('');
}
async function refreshAll() {
try {
await Promise.all([loadToday(), loadDaily(), loadDatasets(), loadStats(), loadHistory()]);
document.getElementById('updatedAt').textContent = '✓ 已更新 ' + new Date().toLocaleTimeString('zh-CN');
} catch (e) {
document.getElementById('updatedAt').textContent = '✗ 加载失败: ' + e.message;
}
}
refreshAll();
setInterval(refreshAll, 30000);
</script>
</body>
</html>
+9
View File
@@ -30,6 +30,12 @@ if _HAS_DOTENV:
class Settings(BaseSettings):
"""应用配置。所有字段都可以通过环境变量或 .env 文件覆盖。"""
# Runtime mode: "docker" | "systemd"
# docker : 单容器运行,依赖进程内 scheduler 触发所有同步任务
# systemd : Linux 宿主机运行,由 systemd timer/service 触发任务,
# 进程内 scheduler 必须关闭,避免双调度器重叠
runtime_mode: str = "docker"
# Scheduler
scheduler_tick_seconds: int = 5
scheduler_timezone: str = "Asia/Shanghai"
@@ -56,6 +62,9 @@ class Settings(BaseSettings):
pg_password: str = "market_sync"
pg_db_name: str = "market_data"
# QMT Bridge
qmt_bridge_url: str = "http://127.0.0.1:8610"
# Trading calendar
trading_holidays: str = ""
+12
View File
@@ -23,6 +23,16 @@ logger = get_logger("datasource_health")
_DATASOURCE_SEED: list[tuple[str, dict, str]] = [
(
"datasource_qmt_bridge",
{
"name": "QMT Bridge(本地行情桥)",
"provides": ["kline_daily", "kline_5min", "index_daily"],
"requiresCredential": False,
"note": "本地 QMT HTTP 桥,日K + 5分钟K + 指数日K 主源",
},
"日 K + 5 分钟 K + 指数日 K 主源(替代 mairui)",
),
(
"datasource_xinlang",
{
@@ -248,10 +258,12 @@ def build_default_registry() -> None:
from app.sources.baostock import BaostockSource
from app.sources.xueqiu import XueqiuSource
from app.sources.mairui import MairuiSource
from app.sources.qmt_bridge import QmtBridgeSource
registry.register(SinaSource())
registry.register(BaostockSource())
registry.register(MairuiSource())
registry.register(QmtBridgeSource())
if settings.xueqiu_token:
registry.register(XueqiuSource())
else:
+167 -7
View File
@@ -1,15 +1,146 @@
"""数据源公共工具:代码转换、K 线标准化、日期过滤。
参考 dashboard/api/services/datasource/fetch_kline.py,但适配新项目。"""
参考 dashboard/api/services/datasource/fetch_kline.py,但适配新项目。
"""
from __future__ import annotations
import logging
import re
import threading
from datetime import datetime, time as dtime, timedelta
from typing import Any, Callable, TypeVar
import pandas as pd
logger = logging.getLogger("datasource.utils")
KLINE_COLS = ["trade_date", "open", "high", "low", "close", "volume"]
KLINE_5MIN_COLS = ["bar_time", "open", "high", "low", "close", "volume", "amount", "turnover_rate"]
T = TypeVar("T")
_THREAD_POOL: dict[int, threading.Thread] = {}
def call_with_timeout(
func: Callable[..., T],
*args: Any,
timeout: float = 20.0,
on_timeout: T | None = None,
description: str = "",
**kwargs: Any,
) -> T | None:
"""用 daemon 线程跑 `func(*args, **kwargs)`,超时返回 `on_timeout`。
Why: 之前用 ThreadPoolExecutor,但超时后 `future.cancel()` 无法终止
已在运行的线程,pool 的 `shutdown(wait=True)` 会等待卡死的函数返回,
把 20s 超时变成无限阻塞——这是 kline_daily 3406 只股票批量失败的根因。
改用 daemon 线程:超时后直接返回,线程成为孤儿进程,不会阻塞任何调用方。
daemon=True 确保进程退出时不会等待这些孤儿线程。
Args:
func: 要跑的同步函数
*args/**kwargs: 透传给 func
timeout: 秒数,默认 20s(对标 mairui/sina 的 urlopen timeout)
on_timeout: 超时返回的占位值(默认 None,调用方需处理 None)
description: 日志里的调用描述(如 'xueqiu.kline SH600519')
Returns: func 的返回值,或 on_timeout(超时时)
"""
result: list[T | None | BaseException] = [None]
done = threading.Event()
def _wrapper() -> None:
try:
result[0] = func(*args, **kwargs)
except BaseException as e:
result[0] = e
finally:
done.set()
t = threading.Thread(
target=_wrapper,
daemon=True,
name=f"ds-timeout-{description or func.__name__}",
)
t.start()
if not done.wait(timeout=timeout):
logger.warning(
"[%s] %s 超时 (>%ss), 返回 on_timeout",
description or func.__name__, description or func.__name__, timeout,
)
return on_timeout
if isinstance(result[0], BaseException):
raise result[0] # type: ignore[misc]
return result[0]
def effective_market_date(
now: datetime | None = None,
holidays: set[str] | None = None,
) -> str:
"""返回"数据生效日"(最近已完成交易日)。
15:30 是 A 股收盘时刻;之后今天的数据才完整,之前应取昨天。
然后向前回退直到找到一个交易日(跳过周末和法定假期)。
用于数据源给"无明确来源日期"的数据(snowball quote_detail 的股本快照等)
打 trade_date 时用 — 永远不要用 datetime.now() 直接当 trade_date。
Why: 任务运行日不等于数据交易日。例如 7月9日 早盘 9:30 跑 share_snapshot,
此时拿到的股本快照实际对应 7月8日 收盘,不能标 7月9日。早期代码用
`datetime.now().strftime("%Y-%m-%d")` 导致 share 表里 7月9日 跑出来的
行标成 7月9日,与实际数据日不符,统计/回溯会出错。
2026-07-12 增强: 加入交易日历回退逻辑。如果 15:30 后是法定节假日
(如春节/国庆),会向前回退到最后一个交易日,不会把节假日当天当 trade_date。
Args:
now: 测试用注入点(默认 datetime.now())
holidays: 已知的 A 股休市日集合(YYYY-MM-DD 格式)。
默认从 config 表 trading_calendar_holidays 加载。
Returns: 'YYYY-MM-DD' 字符串(永远是合法的交易日)
"""
t = now or datetime.now()
if t.time() >= dtime(15, 0):
candidate = t
else:
candidate = t - timedelta(days=1)
# 加载交易日历(如果调用方未提供)
if holidays is None:
try:
from app.core.db import ops as db_ops
h_row = db_ops.fetch_config_by_key("trading_calendar_holidays")
if h_row and h_row.get("category") == "general":
import json
vals = json.loads(h_row.get("value", "") or "[]")
if isinstance(vals, list):
holidays = {h for h in vals if isinstance(h, str)}
except Exception:
pass
if holidays is None:
holidays = set()
# 向前回退直到找到交易日
max_iter = 30 # 安全上限,避免死循环
while max_iter > 0:
date_str = candidate.strftime("%Y-%m-%d")
if candidate.weekday() < 5 and date_str not in holidays:
return date_str
candidate -= timedelta(days=1)
max_iter -= 1
# 兜底(正常情况下不会走到这里)
t2 = now or datetime.now()
if t2.time() >= dtime(15, 30):
return t2.strftime("%Y-%m-%d")
return (t2 - timedelta(days=1)).strftime("%Y-%m-%d")
def to_code6(code: str) -> str:
"""'SH600000' / 'sh.600000' / '600000' / 'SH600000.SH''600000'"""
@@ -20,31 +151,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 +277,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
+237 -96
View File
@@ -95,6 +95,41 @@ class DatasetRegistry(ORMBase):
updated_at: Mapped[Optional[datetime]] = _updated_at()
# ──────────────────────── 2b. sync_history ────────────────────────
class SyncHistory(ORMBase):
"""同步任务执行历史(每次跑都留一行,append-only)。
与 dataset_registry 的区别:dataset_registry 只保留"最后一次状态",便于快查;
sync_history 保留完整历史,支持看板按天/按 task 维度聚合 + 失败回溯。
append-only:每次 task.run() 完成(无论 ok/warning/failed/blocked)插入一行。
"""
__tablename__ = "sync_history"
__table_args__ = (
Index("idx_sync_history_dataset_started", "dataset_id", "started_at"),
Index("idx_sync_history_started", "started_at"),
Index("idx_sync_history_status", "status"),
{"schema": "market_data"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
dataset_id: Mapped[str] = mapped_column(String(64), nullable=False)
run_date: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False) # ok/warning/error/blocked
trigger_source: Mapped[str] = mapped_column(String(32), nullable=False, default="manual")
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
elapsed_sec: Mapped[Optional[float]] = mapped_column(Float, default=0)
rows_written: Mapped[int] = mapped_column(Integer, default=0)
message: Mapped[Optional[str]] = mapped_column(Text)
error: Mapped[Optional[str]] = mapped_column(Text)
# 子任务维度统计(JSONB): {"ok": 5204, "fail": 1, "skip": 0, "total": 5205}
stats: Mapped[Optional[dict]] = mapped_column(JSONB, default=dict)
# 触发者上下文 (cli 命令 / scheduler key / manual user)
triggered_by: Mapped[Optional[str]] = mapped_column(String(64), default="")
updated_at: Mapped[Optional[datetime]] = _updated_at()
# ──────────────────────── 3. stocks ────────────────────────
class Stock(ORMBase):
"""股票基础信息 + 最新股本快照(冗余缓存)。"""
@@ -106,7 +141,6 @@ class Stock(ORMBase):
exchange: Mapped[str] = mapped_column(String(8), nullable=False, default="")
list_date: Mapped[Optional[date]] = mapped_column(Date)
listing_status: Mapped[str] = mapped_column(String(16), nullable=False, default="normal")
industry: Mapped[Optional[str]] = mapped_column(String(64), default="")
total_share: Mapped[float] = mapped_column(Float, default=0)
float_share: Mapped[float] = mapped_column(Float, default=0)
share_updated_at: Mapped[Optional[date]] = mapped_column(Date)
@@ -223,92 +257,7 @@ class Share(ORMBase):
float_share: Mapped[float] = mapped_column(Float, nullable=False)
# ──────────────────────── 10. sectors ────────────────────────
class Sectors(ORMBase):
"""行业字典(baostock 行业分类 / 申万 / 中证 等多种 taxonomy)。"""
__tablename__ = "sectors"
__table_args__ = (
Index("idx_sectors_sector_name", "sector_name"),
{"schema": "market_data"},
)
sector_key: Mapped[str] = mapped_column(String(64), primary_key=True)
sector_name: Mapped[str] = mapped_column(String(64), nullable=False, default="")
taxonomy: Mapped[Optional[str]] = mapped_column(String(64), default="")
level: Mapped[Optional[str]] = mapped_column(String(16), default="")
source: Mapped[Optional[str]] = mapped_column(String(32), default="")
enabled: Mapped[int] = mapped_column(Integer, default=1)
updated_at: Mapped[Optional[datetime]] = _updated_at()
# ──────────────────────── 11. stock_sector_map ────────────────────────
class StockSectorMap(ORMBase):
"""股票-行业多对多映射(一张股票可对应多个行业 / 概念板块)。"""
__tablename__ = "stock_sector_map"
__table_args__ = (
Index("idx_stock_sector_map_sector_key", "sector_key"),
{"schema": "market_data"},
)
stock_code: Mapped[str] = mapped_column(String(10), primary_key=True)
sector_key: Mapped[str] = mapped_column(String(64), nullable=False, default="")
updated_at: Mapped[Optional[datetime]] = _updated_at()
# ──────────────────────── 12. industry ────────────────────────
class Industry(ORMBase):
"""股票-行业映射(baostock 源,证监会分类标准)。"""
__tablename__ = "industry"
__table_args__ = (
Index("idx_industry_industry_name", "industry_name"),
{"schema": "market_data"},
)
code: Mapped[str] = mapped_column(String(10), primary_key=True)
industry_name: Mapped[Optional[str]] = mapped_column(String(64), default="")
industry_classification: Mapped[Optional[str]] = mapped_column(String(32), default="")
update_date: Mapped[Optional[date]] = mapped_column(Date)
updated_at: Mapped[Optional[datetime]] = _updated_at()
# ──────────────────────── 13. sector_indices ────────────────────────
class SectorIndices(ORMBase):
"""行业日线(基点 100,复合收益)。"""
__tablename__ = "sector_indices"
__table_args__ = (
PrimaryKeyConstraint("trade_date", "sector_name"),
Index("idx_sector_indices_sector_name", "sector_name"),
{"schema": "market_data"},
)
trade_date: Mapped[date] = mapped_column(Date, nullable=False)
sector_name: Mapped[str] = mapped_column(String(64), nullable=False)
close: Mapped[float] = mapped_column(Float, default=0)
sector_amplitude: Mapped[float] = mapped_column(Float, default=0)
# ──────────────────────── 14. sector_features_daily ────────────────────────
class SectorFeaturesDaily(ORMBase):
"""行业日特征:sector_ret / sector_amplitude / close / EMA / score。"""
__tablename__ = "sector_features_daily"
__table_args__ = (
PrimaryKeyConstraint("trade_date", "sector_name"),
Index("idx_sector_features_sector_name", "sector_name"),
{"schema": "market_data"},
)
trade_date: Mapped[date] = mapped_column(Date, nullable=False)
sector_name: Mapped[str] = mapped_column(String(64), nullable=False)
sector_ret: Mapped[float] = mapped_column(Float, default=0)
sector_amplitude: Mapped[float] = mapped_column(Float, default=0)
close: Mapped[float] = mapped_column(Float, default=0)
ema10: Mapped[float] = mapped_column(Float, default=0)
ema20: Mapped[float] = mapped_column(Float, default=0)
ema200: Mapped[float] = mapped_column(Float, default=0)
score: Mapped[int] = mapped_column(Integer, default=0)
# ──────────────────────── 15. market_regime_daily ────────────────────────
# ──────────────────────── 12. market_regime_daily ────────────────────────
class MarketRegimeDaily(ORMBase):
"""市场情绪衍生指标(基于本地 kline 聚合)。"""
__tablename__ = "market_regime_daily"
@@ -362,7 +311,40 @@ class TickTrade(ORMBase):
amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
# ──────────────────────── 17. longhubang_daily ────────────────────────
# ──────────────────────── 17. kline_stock_ma_daily ────────────────────────
class KlineStockMADaily(ORMBase):
"""个股日 K 级别 MA 指标(基于 kline_stock.close 滚动计算)。
字段:
stock_code (str, hermes 格式 SH600000)
trade_date (date)
ma5 / ma10 / ma20 / ma60 (float, close 的简单移动平均)
source (str, "local_kline_proxy" | "mairui" — 数据来源标识)
updated_at (timestamptz)
设计:日 K 级别 MA 通常在本地从 kline_stock 派生(pandas rolling)。
mairui /hsdata 提供的 /d/ma5/ma10/ma20/ma60 端点要付费 licence,本项目
默认用本地计算,source=local_kline_proxy;若未来升级 mairui licence
可加 source=mairui 走 API 直拉。
"""
__tablename__ = "kline_stock_ma_daily"
__table_args__ = (
PrimaryKeyConstraint("stock_code", "trade_date"),
Index("idx_kline_stock_ma_date", "trade_date"),
{"schema": "market_data"},
)
stock_code: Mapped[str] = mapped_column(String(10), nullable=False)
trade_date: Mapped[date] = mapped_column(Date, nullable=False)
ma5: Mapped[Optional[float]] = mapped_column(Float)
ma10: Mapped[Optional[float]] = mapped_column(Float)
ma20: Mapped[Optional[float]] = mapped_column(Float)
ma60: Mapped[Optional[float]] = mapped_column(Float)
source: Mapped[Optional[str]] = mapped_column(String(32), default="local_kline_proxy")
updated_at: Mapped[Optional[datetime]] = _updated_at()
# ──────────────────────── 18. longhubang_daily ────────────────────────
class LonghubangDaily(ORMBase):
"""龙虎榜每日上榜股票汇总(聚合层,akshare 源)。
@@ -450,6 +432,161 @@ class LonghubangSeat(ORMBase):
explanation: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# ──────────────────────── 19. node_categories ────────────────────────
class NodeCategory(ORMBase):
"""mairui /hszg/list/ 顶层分类字典。
字段:
category_key (PK, 如 '0:2' = A 股热门概念)
display_name (中文分类名 取自 pname,如 'A股-热门概念' 或剥前缀后 '热门概念')
market ('A 股' / '港股' / '基金' / ...)
category_type ('concept' / 'industry' / 'industry_sub' / 'index' / 'region' / 'class')
node_count (该分类下叶子节点数)
派生自 mairui /hszg/list/ 的 type1+type2 组合,详见 SyncStockNode 注释。
"""
__tablename__ = "node_categories"
__table_args__ = {"schema": "market_data"}
category_key: Mapped[str] = mapped_column(String(16), primary_key=True)
display_name: Mapped[str] = mapped_column(String(64), nullable=False, default="")
market: Mapped[str] = mapped_column(String(16), nullable=False, default="")
category_type: Mapped[str] = mapped_column(String(16), nullable=False, default="")
node_count: Mapped[int] = mapped_column(Integer, default=0)
updated_at: Mapped[Optional[datetime]] = _updated_at()
# ──────────────────────── 20. nodes ────────────────────────
class Node(ORMBase):
"""mairui /hszg/list/ 节点字典(指数/行业/概念)。
字段(从 mairui 原样保留 + 派生分类):
node_code (PK,如 'chgn_730603' = '热门概念-肝炎治疗')
node_name (中文名,如 'A股-热门概念-肝炎治疗')
category_key (外键到 NodeCategory, 由 type1+type2 拼接)
parent_code (父节点 code,可空)
parent_name (父节点 name,可空)
level (mairui 的层级 0/1/2)
is_leaf (int 0/1, mairui 原 isleaf 字段)
mairui_type1, mairui_type2 (保留原始数值,便于回查/调试)
"""
__tablename__ = "nodes"
__table_args__ = (
Index("idx_nodes_category", "category_key"),
Index("idx_nodes_parent", "parent_code"),
Index("idx_nodes_leaf", "is_leaf"),
{"schema": "market_data"},
)
node_code: Mapped[str] = mapped_column(String(64), primary_key=True)
node_name: Mapped[str] = mapped_column(String(128), nullable=False, default="")
category_key: Mapped[str] = mapped_column(String(16), nullable=False, default="")
parent_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
parent_name: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
level: Mapped[int] = mapped_column(Integer, default=0)
is_leaf: Mapped[int] = mapped_column(Integer, default=0)
mairui_type1: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
mairui_type2: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
updated_at: Mapped[Optional[datetime]] = _updated_at()
# ──────────────────────── 21. stock_node_map ────────────────────────
class StockNodeMap(ORMBase):
"""股票-节点 N×M 映射(来自 mairui /hszg/gg/{code})。
一只股票可属于多个概念(同时属于"AI算力"+"国产芯片"+"特斯拉概念"),
也可属于多个指数("沪深300"+"上证50"+"科创50")。
PK (stock_code, node_code) 保证幂等 upsert。
stock_code 用 hermes 格式(SH600519),与项目其他表一致。
"""
__tablename__ = "stock_node_map"
__table_args__ = (
PrimaryKeyConstraint("stock_code", "node_code"),
Index("idx_stock_node_node", "node_code"),
Index("idx_stock_node_stock", "stock_code"),
{"schema": "market_data"},
)
stock_code: Mapped[str] = mapped_column(String(10), nullable=False)
node_code: Mapped[str] = mapped_column(String(64), nullable=False)
updated_at: Mapped[Optional[datetime]] = _updated_at()
# ──────────────────────── 22. kline_stock_macd_daily ────────────────────────
class KlineStockMACDDaily(ORMBase):
"""个股日 K 级别 MACD 指标(mairui /hsstock/history/macd 直拉)。
字段(mairui 原始字段名,与 K 线口径一致):
diff = DIF(快慢 EMA 差)
dea = DEADIF 的 9 日 EMA,即 signal line
macd = MACD 柱(2 ×(DIF DEA))
ema12 / ema26 = 计算 DIF 用的两条 EMA
stock_code 用 hermes 格式(SH600519),与项目其他表一致。
"""
__tablename__ = "kline_stock_macd_daily"
__table_args__ = (
PrimaryKeyConstraint("stock_code", "trade_date"),
Index("idx_kline_stock_macd_date", "trade_date"),
{"schema": "market_data"},
)
stock_code: Mapped[str] = mapped_column(String(10), nullable=False)
trade_date: Mapped[date] = mapped_column(Date, nullable=False)
diff: Mapped[Optional[float]] = mapped_column(Float)
dea: Mapped[Optional[float]] = mapped_column(Float)
macd: Mapped[Optional[float]] = mapped_column(Float)
ema12: Mapped[Optional[float]] = mapped_column(Float)
ema26: Mapped[Optional[float]] = mapped_column(Float)
source: Mapped[Optional[str]] = mapped_column(String(32), default="mairui")
updated_at: Mapped[Optional[datetime]] = _updated_at()
# ──────────────────────── 23. kline_stock_kdj_daily ────────────────────────
class KlineStockKDJDaily(ORMBase):
"""个股日 K 级别 KDJ 指标(mairui /hsstock/history/kdj 直拉)。
k / d / j 三条随机指标线。stock_code 用 hermes 格式(SH600519)。
"""
__tablename__ = "kline_stock_kdj_daily"
__table_args__ = (
PrimaryKeyConstraint("stock_code", "trade_date"),
Index("idx_kline_stock_kdj_date", "trade_date"),
{"schema": "market_data"},
)
stock_code: Mapped[str] = mapped_column(String(10), nullable=False)
trade_date: Mapped[date] = mapped_column(Date, nullable=False)
k: Mapped[Optional[float]] = mapped_column(Float)
d: Mapped[Optional[float]] = mapped_column(Float)
j: Mapped[Optional[float]] = mapped_column(Float)
source: Mapped[Optional[str]] = mapped_column(String(32), default="mairui")
updated_at: Mapped[Optional[datetime]] = _updated_at()
# ──────────────────────── 24. kline_stock_boll_daily ────────────────────────
class KlineStockBOLLDaily(ORMBase):
"""个股日 K 级别 BOLL 布林带指标(mairui /hsstock/history/boll 直拉)。
mairui 原始字段:u=上轨, m=中轨, d=下轨。落库列名统一为 upper/mid/lower。
stock_code 用 hermes 格式(SH600519)。
"""
__tablename__ = "kline_stock_boll_daily"
__table_args__ = (
PrimaryKeyConstraint("stock_code", "trade_date"),
Index("idx_kline_stock_boll_date", "trade_date"),
{"schema": "market_data"},
)
stock_code: Mapped[str] = mapped_column(String(10), nullable=False)
trade_date: Mapped[date] = mapped_column(Date, nullable=False)
upper: Mapped[Optional[float]] = mapped_column(Float)
mid: Mapped[Optional[float]] = mapped_column(Float)
lower: Mapped[Optional[float]] = mapped_column(Float)
source: Mapped[Optional[str]] = mapped_column(String(32), default="mairui")
updated_at: Mapped[Optional[datetime]] = _updated_at()
__all__ = [
# 1-2
"Config",
@@ -463,17 +600,21 @@ __all__ = [
"Kline5Min",
"Moneyflow",
"Share",
# 10-12
"Sectors",
"StockSectorMap",
"Industry",
# 13-15
"SectorIndices",
"SectorFeaturesDaily",
# 10-11
"MarketRegimeDaily",
# 16
"TickTrade",
# 17-18 (2026-07-01 龙虎榜)
# 17 (2026-07-03 mairui 历史分时 MA - 日 K 级别)
"KlineStockMADaily",
# 18-19 (2026-07-01 龙虎榜)
"LonghubangDaily",
"LonghubangSeat",
# 19-21 (2026-07-02 股票-节点映射 mairui)
"NodeCategory",
"Node",
"StockNodeMap",
# 22-24 (2026-07-08 mairui 技术指标 MACD/KDJ/BOLL - 日 K 级别)
"KlineStockMACDDaily",
"KlineStockKDJDaily",
"KlineStockBOLLDaily",
]
+348 -142
View File
@@ -12,7 +12,8 @@
from __future__ import annotations
import json
from datetime import date, datetime
import os
from datetime import date, datetime, timedelta
from typing import Any, Iterable, Optional
from sqlalchemy import and_, case, delete, exists, func, or_, select, update
@@ -21,21 +22,24 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from app.core.db.models import (
Config,
DatasetRegistry,
Industry,
Kline5Min,
KlineIndex,
KlineStock,
KlineStockBOLLDaily,
KlineStockKDJDaily,
KlineStockMACDDaily,
KlineStockMADaily,
LonghubangDaily,
LonghubangSeat,
MarketIndex,
MarketRegimeDaily,
Moneyflow,
SectorFeaturesDaily,
SectorIndices,
Sectors,
Node,
NodeCategory,
Share,
Stock,
StockSectorMap,
StockNodeMap,
SyncHistory,
TickTrade,
)
from app.core.db.orm import get_session
@@ -94,10 +98,31 @@ def _row_to_dict(row) -> dict[str, Any]:
def _ensure_schema() -> None:
"""所有读函数开头调用一次,幂等。
PG 的表由 ``app.core.db.pg_bootstrap`` 一次性建好(人工触发),这里 no-op 即可
保留这个函数是为了不破坏 9 个 sync task 的调用约定(行 14 个调用点)
用 superuser 连接跑 create_allmarket_sync role 没 CREATE 权限)
SA 2.x 内置 IF NOT EXISTS 检查,已存在则跳过
这样新增 model 后无需手动跑 pg_bootstrap。
首次部署仍然建议跑 pg_bootstrap(它还要建 role / schema);
这里是后续迭代加表的兜底。
"""
pass
from app.core.db import models # noqa: F401 触发全部 model 注册
from app.core.config import settings
from app.core.db.orm import ORMBase
from sqlalchemy import create_engine
su_url = (
f"postgresql+psycopg2://{os.environ.get('PG_SUPERUSER', 'postgres')}:"
f"{os.environ.get('PG_SUPERUSER_PASSWORD', 'postgres')}@"
f"{os.environ.get('PG_SUPERHOST', settings.pg_host)}:"
f"{os.environ.get('PG_SUPERPORT', settings.pg_port)}/{settings.pg_db_name}"
)
su_engine = create_engine(su_url, future=True)
try:
ORMBase.metadata.create_all(su_engine)
except Exception as e:
# 创建表失败不应阻塞主流程(表可能已存在 / 权限不够)
import logging
logging.getLogger("sync").debug(f"[_ensure_schema] create_all 失败(忽略): {e}")
# ── config 表 ───────────────────────────────────────────────────────────
@@ -277,6 +302,123 @@ def update_dataset_registry_state(dataset_id: str, **kwargs: Any) -> None:
)
# ──────── sync_history (append-only 每次同步留痕) ────────
def insert_sync_history(
*,
dataset_id: str,
run_date: date,
status: str,
trigger_source: str,
started_at: datetime,
finished_at: Optional[datetime] = None,
elapsed_sec: float = 0.0,
rows_written: int = 0,
message: str = "",
error: str = "",
stats: Optional[dict] = None,
triggered_by: str = "",
) -> int:
"""每次 task.run() 完成追加一行 sync_history。
看板和 MCP 都从这张表读历史;失败排查也靠它。
返回新行的 id。
"""
row = SyncHistory(
dataset_id=dataset_id,
run_date=run_date,
status=status,
trigger_source=trigger_source,
started_at=started_at,
finished_at=finished_at,
elapsed_sec=float(elapsed_sec),
rows_written=int(rows_written),
message=message or None,
error=error or None,
stats=stats or {},
triggered_by=triggered_by or None,
)
with get_session() as s:
s.add(row)
s.flush()
return int(row.id)
def list_sync_history(
*,
dataset_id: Optional[str] = None,
run_date: Optional[date] = None,
days: int = 7,
limit: int = 200,
) -> list[dict[str, Any]]:
"""查 sync_history(看板 / API 用),按 started_at DESC。
默认最近 7 天。指定 dataset_id 时只查该任务。
"""
with get_session() as s:
q = select(SyncHistory)
if dataset_id:
q = q.where(SyncHistory.dataset_id == dataset_id)
if run_date:
q = q.where(SyncHistory.run_date == run_date)
else:
cutoff = date.today() - timedelta(days=days)
q = q.where(SyncHistory.run_date >= cutoff)
q = q.order_by(SyncHistory.started_at.desc()).limit(limit)
rows = s.execute(q).scalars().all()
return [
{
"id": r.id,
"dataset_id": r.dataset_id,
"run_date": r.run_date.isoformat() if r.run_date else None,
"status": r.status,
"trigger_source": r.trigger_source,
"started_at": r.started_at.isoformat() if r.started_at else None,
"finished_at": r.finished_at.isoformat() if r.finished_at else None,
"elapsed_sec": r.elapsed_sec,
"rows_written": r.rows_written,
"message": r.message,
"error": r.error,
"stats": r.stats,
"triggered_by": r.triggered_by,
}
for r in rows
]
def daily_sync_summary(days: int = 7) -> list[dict[str, Any]]:
"""按日期聚合(看板首页用):每天 ok/warning/error/blocked 计数。
返回 [{date, ok, warning, error, blocked, total, tasks_run}, ...]
"""
with get_session() as s:
cutoff = date.today() - timedelta(days=days)
rows = s.execute(
select(
SyncHistory.run_date,
SyncHistory.status,
func.count().label("n"),
func.count(func.distinct(SyncHistory.dataset_id)).label("tasks"),
)
.where(SyncHistory.run_date >= cutoff)
.group_by(SyncHistory.run_date, SyncHistory.status)
.order_by(SyncHistory.run_date.desc())
).all()
# pivot 成每天一行
by_date: dict[date, dict[str, Any]] = {}
for run_date, status, n, tasks in rows:
d = by_date.setdefault(run_date, {
"date": run_date.isoformat(),
"ok": 0, "warning": 0, "error": 0, "blocked": 0,
"total": 0, "tasks_run": 0,
})
d[status] = int(n)
d["total"] += int(n)
d["tasks_run"] = max(d["tasks_run"], int(tasks)) # max 而不是 sum(去重)
return sorted(by_date.values(), key=lambda x: x["date"], reverse=True)
def recover_interrupted_dataset_registry() -> int:
"""把状态卡在 running 的同步任务标记为 failed(启动时调用)。"""
with get_session() as s:
@@ -307,19 +449,16 @@ def upsert_stock(
exchange: str,
list_date: str = "",
listing_status: str = "normal",
industry: str = "",
) -> None:
"""upsert 一只股票基础信息(单条)。
注意:MySQL 9.7.0 在 ON DUPLICATE KEY UPDATE 阶段对 DATE 字段的 VALUES()/new.col
求值存在 bug(会强制把空串塞进去,触发 1292 严格模式错误)。所以这里
- list_date 字段在首次 INSERT 时写入;UPDATE 分支不更新(保持原值或 NULL)
- industry 字段也避开这个 bug
求值存在 bug(会强制把空串塞进去,触发 1292 严格模式错误)。所以这里
list_date 字段在首次 INSERT 时写入;UPDATE 分支不更新(保持原值或 NULL)
批量写入请用 upsert_stocks_bulk(),性能高 10-20 倍。
"""
del list_date # 显式不接受 list_date 更新(旧值保留)
del industry # 同上
with get_session() as s:
stmt = _pg_upsert(
Stock,
@@ -333,7 +472,7 @@ def upsert_stock(
def upsert_stocks_bulk(rows: list[dict[str, Any]], chunk_size: int = 500) -> int:
"""批量 upsert 股票基础信息。
每条 row 需有: code, name, exchange, listing_status(可选 list_date / industry
每条 row 需有: code, name, exchange, listing_status(可选 list_date
性能:~5000 只股票从 ~50s 降到 ~3s。
"""
if not rows:
@@ -355,7 +494,7 @@ def upsert_stocks_bulk(rows: list[dict[str, Any]], chunk_size: int = 500) -> int
stmt = _pg_upsert(
Stock, values,
conflict_keys=["code"],
update_cols=["name", "exchange", "list_date", "listing_status", "industry"],
update_cols=["name", "exchange", "list_date", "listing_status"],
)
s.execute(stmt)
total += len(chunk)
@@ -363,15 +502,25 @@ def upsert_stocks_bulk(rows: list[dict[str, Any]], chunk_size: int = 500) -> int
def update_stock_share_snapshot(code: str, total_share: float, float_share: float, trade_date: str = "") -> None:
"""更新 stocks 表的股本字段。
Args:
trade_date: 数据源返回的股本快照"as-of"交易日(用于 share 表写入)。
注意:这个参数**不会**写到 stocks.share_updated_at 字段 —
那个字段的语义是"我们什么时候拉到的"(运行日,date.today()),
不是数据交易日。把 trade_date 写到 share_updated_at 会让
task_share_snapshot / task_stocks_basic 的 dedup 检查
(share_updated_at == today) 失效,见 task 层 fix。
"""
del trade_date # 显式不接受 — 见 docstring
with get_session() as s:
snap_date = _to_date_str(trade_date) if trade_date else None
s.execute(
update(Stock)
.where(Stock.code == code)
.values(
total_share=float(total_share),
float_share=float(float_share),
share_updated_at=snap_date,
share_updated_at=date.today(), # "拉取日",不是"数据日"
)
)
@@ -399,7 +548,6 @@ def _stock_row_to_dict(r: Stock) -> dict[str, Any]:
"exchange": r.exchange or "",
"list_date": _to_date_str(r.list_date),
"listing_status": r.listing_status or "normal",
"industry": r.industry or "",
"total_share": r.total_share if r.total_share is not None else 0,
"float_share": r.float_share if r.float_share is not None else 0,
"share_updated_at": _to_date_str(r.share_updated_at),
@@ -530,9 +678,13 @@ def get_kline_5min_snapshots() -> dict[str, Optional[datetime]]:
返回:{stock_code (6位): max(bar_time) 或 None}
用于 kline_5min 任务的"全量/增量"统一规划。
DB 里 stock_code 形如 "000001.SZ"mairui 写入格式),函数剥掉
交易所后缀统一为 6 位。bar_time 是 DATETIME 字段,SQLAlchemy 2.x
native 返回 datetime;如果是 str(方言边界情况)手动解析
DB 里 stock_code 是 hermes 格式 "SH600000"带 SH/SZ/BJ 前缀,无点),
2026-07-09 之前误以为 mairui 原始格式 "000001.SZ",用 split(".")[0] 剥
不到 → 全部股票被 skip → task 把整市场当"全量"重跑(实际是增量)
修:用正则剥 SH/SZ/BJ 前缀。
bar_time 是 DATETIME 字段,SQLAlchemy 2.x native 返回 datetime;如果是
str(方言边界情况)手动解析。
"""
_ensure_schema()
out: dict[str, Optional[datetime]] = {}
@@ -542,7 +694,13 @@ def get_kline_5min_snapshots() -> dict[str, Optional[datetime]]:
.group_by(Kline5Min.stock_code)
).all()
for raw_code, latest in rows:
code6 = str(raw_code or "").strip().split(".")[0]
# 剥 SH/SZ/BJ 前缀(hermes 格式),容错 6 位裸码
code6 = str(raw_code or "").strip()
for prefix in ("SH", "SZ", "BJ"):
if code6.startswith(prefix):
code6 = code6[len(prefix):]
break
code6 = code6.split(".")[0] # 兜底剥 "000001.SZ" 老格式
if len(code6) != 6 or not code6.isdigit():
continue
if isinstance(latest, datetime):
@@ -601,140 +759,92 @@ def upsert_longhubang_seat(rows: list[dict[str, Any]]) -> int:
return _bulk_upsert_orm(s, LonghubangSeat, rows, chunk_size=500)
# ── 行业 / 概念板块 ─────────────────────────────────────────────────────
# ── 节点映射 (mairui /hszg) ─────────────────────────────────
def replace_all_industries(rows: list[dict[str, Any]]) -> None:
"""全量替换 industry 表。rows: code, industry_name, industry_classification, update_date"""
if not rows:
return
def replace_all_node_categories(rows: list[dict[str, Any]]) -> None:
"""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(Industry))
values = [
{
"code": str(r.get("code", "")).zfill(6),
"industry_name": r.get("industry_name") or None,
"industry_classification": r.get("industry_classification") or None,
"update_date": _to_date_str(r.get("update_date")),
}
for r in rows
]
if values:
stmt = _pg_upsert(Industry, values,
conflict_keys=["code"],
update_cols=["industry_name", "industry_classification", "update_date"])
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 = [
{
"category_key": r.get("category_key", ""),
"display_name": r.get("display_name", ""),
"market": r.get("market", ""),
"category_type": r.get("category_type", ""),
"node_count": int(r.get("node_count", 0)),
}
for r in rows
]
stmt = _pg_upsert(NodeCategory, values,
conflict_keys=["category_key"],
update_cols=["display_name", "market", "category_type", "node_count"])
s.execute(stmt)
def fetch_all_industries() -> list[dict[str, Any]]:
_ensure_schema()
with get_session() as s:
rows = s.execute(
select(Industry.code, Industry.industry_name, Industry.industry_classification, Industry.update_date)
).all()
return [
{
"code": r.code,
"industry_name": r.industry_name,
"industry_classification": r.industry_classification,
"update_date": _to_date_str(r.update_date),
}
for r in rows
]
def replace_all_sectors(rows: list[dict[str, Any]]) -> None:
"""rows: sector_key, sector_name, taxonomy, level, source, enabled"""
if not rows:
return
with get_session() as s:
s.execute(delete(Sectors))
values = [
{
"sector_key": r.get("sector_key", ""),
"sector_name": r.get("sector_name", ""),
"taxonomy": r.get("taxonomy", ""),
"level": r.get("level", ""),
"source": r.get("source", ""),
"enabled": int(r.get("enabled", 1)),
}
for r in rows
]
if values:
stmt = _pg_upsert(Sectors, values,
conflict_keys=["sector_key"],
update_cols=["sector_name", "taxonomy", "level", "source", "enabled"])
s.execute(stmt)
def replace_all_stock_sector_map(rows: list[dict[str, Any]]) -> None:
"""rows: stock_code, sector_key"""
if not rows:
return
with get_session() as s:
s.execute(delete(StockSectorMap))
values = [
{
"stock_code": str(r.get("stock_code", "")).zfill(6),
"sector_key": r.get("sector_key", ""),
}
for r in rows
]
if values:
stmt = _pg_upsert(StockSectorMap, values,
conflict_keys=["stock_code"],
update_cols=["sector_key"])
s.execute(stmt)
# ── 行业聚合(衍生)─────────────────────────────────────────────────────
def replace_all_sector_indices(rows: list[dict[str, Any]]) -> None:
"""rows: trade_date, sector_name, close, sector_amplitude"""
def replace_all_nodes(rows: list[dict[str, Any]]) -> None:
"""rows: node_code, node_name, category_key, parent_code, parent_name,
level, is_leaf, mairui_type1, mairui_type2"""
if not rows:
return
with get_session() as s:
values = [
{
"trade_date": _to_date_str(r.get("trade_date")),
"sector_name": r.get("sector_name", ""),
"close": float(r.get("close") or 0),
"sector_amplitude": float(r.get("sector_amplitude") or 0),
"node_code": r.get("node_code", ""),
"node_name": r.get("node_name", ""),
"category_key": r.get("category_key", ""),
"parent_code": r.get("parent_code") or None,
"parent_name": r.get("parent_name") or None,
"level": int(r.get("level", 0)),
"is_leaf": int(r.get("is_leaf", 0)),
"mairui_type1": r.get("mairui_type1"),
"mairui_type2": r.get("mairui_type2"),
}
for r in rows
]
if values:
stmt = _pg_upsert(SectorIndices, values,
conflict_keys=["trade_date", "sector_name"],
update_cols=["close", "sector_amplitude"])
s.execute(stmt)
stmt = _pg_upsert(Node, values,
conflict_keys=["node_code"],
update_cols=["node_name", "category_key", "parent_code",
"parent_name", "level", "is_leaf",
"mairui_type1", "mairui_type2"])
s.execute(stmt)
def replace_all_sector_features(rows: list[dict[str, Any]]) -> None:
"""rows: trade_date, sector_name, sector_ret, sector_amplitude, close, ema10, ema20, ema200, score"""
if not rows:
return
def replace_all_stock_node_map(rows: list[dict[str, Any]]) -> None:
"""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 = [
{
"trade_date": _to_date_str(r.get("trade_date")),
"sector_name": r.get("sector_name", ""),
"sector_ret": float(r.get("sector_ret") or 0),
"sector_amplitude": float(r.get("sector_amplitude") or 0),
"close": float(r.get("close") or 0),
"ema10": float(r.get("ema10") or 0),
"ema20": float(r.get("ema20") or 0),
"ema200": float(r.get("ema200") or 0),
"score": int(r.get("score") or 0),
}
for r in rows
]
if values:
stmt = _pg_upsert(SectorFeaturesDaily, values,
conflict_keys=["trade_date", "sector_name"],
update_cols=["sector_ret", "sector_amplitude", "close",
"ema10", "ema20", "ema200", "score"])
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)
@@ -772,12 +882,108 @@ def upsert_market_regime_rows(rows: list[dict[str, Any]]) -> None:
# ── kline 查询(同步时用于判断增量起点)─────────────────────────────────
def get_stock_kline_max_date(code: str) -> Optional[str]:
def upsert_kline_stock_ma_daily_rows(rows: list[dict[str, Any]]) -> None:
"""rows: stock_code, trade_date, ma5, ma10, ma20, ma60, source"""
if not rows:
return
with get_session() as s:
values = [
{
"stock_code": str(r.get("stock_code") or ""),
"trade_date": _to_date_str(r.get("trade_date")),
"ma5": float(r["ma5"]) if r.get("ma5") is not None and not pd_isna(r["ma5"]) else None,
"ma10": float(r["ma10"]) if r.get("ma10") is not None and not pd_isna(r["ma10"]) else None,
"ma20": float(r["ma20"]) if r.get("ma20") is not None and not pd_isna(r["ma20"]) else None,
"ma60": float(r["ma60"]) if r.get("ma60") is not None and not pd_isna(r["ma60"]) else None,
"source": r.get("source", "local_kline_proxy"),
}
for r in rows
]
stmt = _pg_upsert(
KlineStockMADaily,
values,
conflict_keys=["stock_code", "trade_date"],
update_cols=["ma5", "ma10", "ma20", "ma60", "source", "updated_at"],
)
s.execute(stmt)
def upsert_kline_stock_macd_daily_rows(rows: list[dict[str, Any]]) -> int:
"""rows: stock_code, trade_date, diff, dea, macd, ema12, ema26, source"""
if not rows:
return 0
with get_session() as s:
return _bulk_upsert_orm(s, KlineStockMACDDaily, rows)
def upsert_kline_stock_kdj_daily_rows(rows: list[dict[str, Any]]) -> int:
"""rows: stock_code, trade_date, k, d, j, source"""
if not rows:
return 0
with get_session() as s:
return _bulk_upsert_orm(s, KlineStockKDJDaily, rows)
def upsert_kline_stock_boll_daily_rows(rows: list[dict[str, Any]]) -> int:
"""rows: stock_code, trade_date, upper, mid, lower, source"""
if not rows:
return 0
with get_session() as s:
return _bulk_upsert_orm(s, KlineStockBOLLDaily, rows)
_INDICATOR_MODEL = {
"macd": KlineStockMACDDaily,
"kdj": KlineStockKDJDaily,
"boll": KlineStockBOLLDaily,
}
def get_indicator_max_date(indicator: str, code: str) -> Optional[str]:
"""某只票某指标已入库的最大 trade_dateYYYY-MM-DD),无则 None。"""
model = _INDICATOR_MODEL.get(indicator.lower())
if model is None:
raise ValueError(f"未知指标: {indicator!r}")
_ensure_schema()
with get_session() as s:
d = s.execute(
select(func.max(model.trade_date))
.where(model.stock_code == code)
).scalar()
return _to_date_str(d) if d else None
def pd_isna(v: Any) -> bool:
"""避免直接 import pandas(开销大),手写 nan/None 检查。"""
if v is None:
return True
try:
return float(v) != float(v) # NaN != NaN
except (TypeError, ValueError):
return False
def get_stock_kline_max_date(code: str) -> Optional[str]:
"""某只票 kline_stock 已入库的最大 trade_date (YYYY-MM-DD),无则 None。
code 接受 6 位 ("600000") 或 hermes 格式 ("SH600000") — DB 里实际存的是
hermes 格式,2026-07-09 之前用 `where KlineStock.stock_code == '600000'`
永远查不到(hermes 有 SH/SZ/BJ 前缀),导致 incremental 把全市场当"无数据"
重跑。修:同时查 6 位和 hermes 两种格式,取较新结果。
"""
_ensure_schema()
c6 = str(code or "").strip()
hermes = f"SH{c6}" if c6.startswith(("5", "6", "9")) else (
f"SZ{c6}" if c6.startswith(("0", "2", "3")) else (
f"BJ{c6}" if c6.startswith(("4", "8")) else c6
))
candidates = [c6]
if hermes != c6:
candidates.append(hermes)
with get_session() as s:
d = s.execute(
select(func.max(KlineStock.trade_date))
.where(KlineStock.stock_code == code)
.where(KlineStock.stock_code.in_(candidates))
).scalar()
return _to_date_str(d) if d else None
+35 -24
View File
@@ -323,17 +323,6 @@ DEFAULT_SCHEDULES: list[tuple[str, dict, str]] = [
},
"每个交易日 21:35 拉取个股资金流(mairui 21:30 发布)",
),
(
"schedule_industry_sector",
{
"name": "周一行业映射",
"time": "09:30",
"condition": "trading_day",
"job": "industry_sector",
"enabled": True,
},
"每个交易日 09:30 全量更新股票-行业映射(开销大,可改为 weekly)",
),
(
"schedule_market_regime",
{
@@ -345,17 +334,6 @@ DEFAULT_SCHEDULES: list[tuple[str, dict, str]] = [
},
"每个交易日 16:15 聚合市场情绪(基于本地日K线)",
),
(
"schedule_share_snapshot",
{
"name": "盘后股本快照",
"time": "16:20",
"condition": "trading_day",
"job": "share_snapshot",
"enabled": True,
},
"每个交易日 16:20 刷新个股总股本/流通股本快照",
),
(
"schedule_longhubang",
{
@@ -367,6 +345,39 @@ DEFAULT_SCHEDULES: list[tuple[str, dict, str]] = [
},
"每个交易日 22:00 拉龙虎榜聚合层 + 席位层(akshare/东方财富 19:00~21:00 出齐)",
),
(
"schedule_stock_node",
{
"name": "周度股票-节点映射",
"time": "11:30",
"condition": "trading_day",
"job": "stock_node",
"enabled": True,
},
"每周六 11:30 拉 mairui /hszg 节点树 + 1100+ 叶子成分股(约 2min @10RPS)",
),
(
"schedule_mairui_ma_daily",
{
"name": "日 K 级别 MA 指标",
"time": "16:30",
"condition": "trading_day",
"job": "mairui_ma_daily",
"enabled": True,
},
"每个交易日 16:30 基于 kline_stock 计算 MA5/10/20/60 (本地派生,~30s @5213 只)",
),
(
"schedule_mairui_indicators",
{
"name": "日 K 技术指标 MACD/KDJ/BOLL",
"time": "16:40",
"condition": "trading_day",
"job": "mairui_indicators",
"enabled": True,
},
"每个交易日 16:40 从 mairui 直拉 MACD/KDJ/BOLL 增量 (3 指标 × 全市场,增量约数分钟)",
),
]
@@ -406,8 +417,8 @@ def register_sync_jobs() -> None:
for dataset_id in [
"stock_basic", "kline_daily", "kline_index", "kline_5min",
"moneyflow", "industry_sector", "share_snapshot", "market_regime",
"longhubang",
"moneyflow", "share_snapshot", "market_regime",
"longhubang", "stock_node", "mairui_ma_daily", "mairui_indicators",
]:
def _make_job(did=dataset_id):
+75 -8
View File
@@ -4,6 +4,7 @@
- 状态自动更新(running → success / failed
- 进度回调
- 异常捕获 → 写 dataset_registry
- 每次 run 完成追加一行 sync_history(看板/MCP/失败排查)
子类只需实现 _run() 即可。
"""
@@ -14,6 +15,7 @@ import traceback
from datetime import datetime, time as dtime
from typing import Any, Optional
from app.core.db import ops as db_ops
from app.core.sync.registry import (
mark_sync_failed,
mark_sync_progress,
@@ -32,13 +34,13 @@ def _is_market_closed() -> bool:
def _effective_sync_end() -> str:
"""若盘后则今天,否则昨天。"""
today = datetime.now()
if today.time() >= dtime(15, 30):
return today.strftime("%Y-%m-%d")
# 昨天
from datetime import timedelta
return (today - timedelta(days=1)).strftime("%Y-%m-%d")
"""若盘后则今天,否则昨天。
委派到 app.core.datasource.utils.effective_market_date() 统一实现,
该函数加载交易日历,会跳过节假日返回最近交易日。
"""
from app.core.datasource.utils import effective_market_date
return effective_market_date()
class SyncTask:
@@ -55,7 +57,10 @@ class SyncTask:
# ── 公开入口 ─────────────────────────────────────────
def run(self, *, trigger_source: str = "manual", **kwargs) -> dict[str, Any]:
"""统一入口:自动包 mark_running / mark_success / mark_failed。"""
"""统一入口:自动包 mark_running / mark_success / mark_failed。
完成后追加一行 sync_history(看板 / MCP / 失败排查共用)。
"""
# 1. 确保 health check 跑过(CLI / 手动触发场景下 schedule 后台不会先跑)
self._ensure_health_checked()
@@ -66,6 +71,7 @@ class SyncTask:
message=f"开始同步 {self.dataset_id}...",
)
t0 = time.time()
started_at = datetime.now()
try:
result = self._run(trigger_source=trigger_source, **kwargs)
elapsed = round(time.time() - t0, 1)
@@ -94,6 +100,17 @@ class SyncTask:
logger.info(f"[{self.dataset_id}] 完成 {status}: {message} ({elapsed}s)")
# 同步 schedule config 的 lastRunfix Bug 2
self._maybe_mirror_schedule_status(trigger_source, status, message)
# 追加 sync_history(每次 run 留痕,看板/MCP/失败排查共用)
self._record_history(
trigger_source=trigger_source,
started_at=started_at,
finished_at=datetime.now(),
elapsed_sec=elapsed,
status=status,
message=message,
error=result.get("error", ""),
result=result,
)
return result
except Exception as e:
elapsed = round(time.time() - t0, 1)
@@ -101,6 +118,17 @@ class SyncTask:
err_msg = f"{type(e).__name__}: {e}"
mark_sync_failed(self.dataset_id, message=err_msg, error=tb)
logger.error(f"[{self.dataset_id}] 失败: {err_msg}\n{tb}")
# 异常路径也要写 sync_history
self._record_history(
trigger_source=trigger_source,
started_at=started_at,
finished_at=datetime.now(),
elapsed_sec=elapsed,
status="error",
message=err_msg,
error=tb,
result={"status": "error"},
)
return {
"status": "error",
"message": err_msg,
@@ -108,6 +136,45 @@ class SyncTask:
"elapsed_sec": elapsed,
}
def _record_history(
self, *,
trigger_source: str,
started_at: datetime,
finished_at: datetime,
elapsed_sec: float,
status: str,
message: str,
error: str,
result: dict[str, Any],
) -> None:
"""把这次 run 的核心指标落 sync_history(看板/MCP 共用)。
stats 字段收集 _run() 返回 dict 里的统计字段(ok/fail/skip/total/rows 等)。
"""
try:
stats_keys = {"ok", "fail", "skip", "total", "rows", "rows_written",
"categories", "nodes", "mappings", "days", "sectors"}
stats = {k: result[k] for k in stats_keys if k in result}
rows_written = int(stats.get("rows", 0) or stats.get("rows_written", 0)
or stats.get("mappings", 0) or 0)
db_ops.insert_sync_history(
dataset_id=self.dataset_id,
run_date=started_at.date(),
status=status,
trigger_source=trigger_source,
started_at=started_at,
finished_at=finished_at,
elapsed_sec=elapsed_sec,
rows_written=rows_written,
message=message[:1000] if message else "",
error=error[:2000] if error else "",
stats=stats,
triggered_by=trigger_source,
)
except Exception as e:
# 落库失败不应阻塞主流程
logger.warning(f"[{self.dataset_id}] sync_history 落库失败(不影响主流程): {e}")
def _ensure_health_checked(self) -> None:
"""若内存里 health_results 为空,跑一次。"""
try:
+37 -25
View File
@@ -53,7 +53,7 @@ SYNC_DEFINITIONS: list[dict[str, Any]] = [
"storage_uri": "PG market_data.kline_index + indices",
"storage_layer": "pg",
"management_role": "原始源",
"source": "新浪指数日K",
"source": "QMT Bridge(主)→ 麦蕊 → 新浪 逐级 fallback",
"sync_script": "app.tasks.task_kline_index:run",
"dependency_ids": [],
"sort_order": 30,
@@ -94,30 +94,6 @@ SYNC_DEFINITIONS: list[dict[str, Any]] = [
"dependency_ids": ["stock_basic"],
"sort_order": 50,
},
{
"dataset_id": "industry_sector",
"name": "股票-行业映射",
"description": "股票-行业映射 + 行业字典(Baostock)",
"storage_uri": "PG market_data.industry + sectors + stock_sector_map",
"storage_layer": "pg",
"management_role": "基础字典",
"source": "Baostock query_stock_industry",
"sync_script": "app.tasks.task_industry_sector:run",
"dependency_ids": ["stock_basic"],
"sort_order": 60,
},
{
"dataset_id": "sector_features",
"name": "行业聚合特征",
"description": "由 kline_stock + industry 衍生:行业日收益 / 行业指数 close / EMA10-20-200 / score。纯本地计算,无外部 API。",
"storage_uri": "PG market_data.sector_indices + sector_features_daily",
"storage_layer": "pg",
"management_role": "衍生源",
"source": "本地计算(kline_stock + industry",
"sync_script": "app.tasks.task_sector_features:run",
"dependency_ids": ["kline_daily", "industry_sector"],
"sort_order": 65,
},
{
"dataset_id": "share_snapshot",
"name": "股本快照",
@@ -154,6 +130,42 @@ SYNC_DEFINITIONS: list[dict[str, Any]] = [
"dependency_ids": ["stock_basic"],
"sort_order": 85,
},
{
"dataset_id": "stock_node",
"name": "股票-指数/行业/概念映射(mairui)",
"description": "mairui /hszg 三接口:节点树(1464)+ gg 反查成分股(1100+);每周六 11:30",
"storage_uri": "PG market_data.node_categories + nodes + stock_node_map",
"storage_layer": "pg",
"management_role": "原始源",
"source": "mairui /hszg/{list,gg,zg}",
"sync_script": "app.tasks.task_stock_node:run",
"dependency_ids": ["stock_basic"],
"sort_order": 21,
},
{
"dataset_id": "mairui_ma_daily",
"name": "日 K 级别 MA 指标 (mairui)",
"description": "基于 kline_stock.close 计算 MA5/10/20/60;本地派生。mairui /hsdata 提供 /d/maN 端点但免费 licence 无数据 (返 数据不存在)。",
"storage_uri": "PG market_data.kline_stock_ma_daily",
"storage_layer": "pg",
"management_role": "衍生源",
"source": "本地计算(kline_stock+ mairui /hsdata 兜底",
"sync_script": "app.tasks.task_mairui_ma_daily:run",
"dependency_ids": ["kline_daily"],
"sort_order": 90,
},
{
"dataset_id": "mairui_indicators",
"name": "日 K 技术指标 MACD/KDJ/BOLL (mairui)",
"description": "mairui /hsstock/history/{macd,kdj,boll} 直拉,写 3 张表;每交易日 16:40 增量。",
"storage_uri": "PG market_data.kline_stock_macd_daily + kdj_daily + boll_daily",
"storage_layer": "pg",
"management_role": "原始源",
"source": "mairui /hsstock/history/{macd,kdj,boll}/{symbol}/d/n",
"sync_script": "app.tasks.task_mairui_indicators:run",
"dependency_ids": ["stock_basic"],
"sort_order": 91,
},
]
+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)
+76 -1
View File
@@ -1,11 +1,14 @@
"""纯 worker 模式(无 web)— 启动调度器 + 健康监控。
用法:python -m app.worker
用法:
python -m app.worker # 独立 worker 进程
python -c "from app.entrypoints.worker import start_scheduler_thread; start_scheduler_thread()" # 在 web 进程内 inline 跑
"""
from __future__ import annotations
import json
import sys
import threading
import time
from pathlib import Path
@@ -21,8 +24,80 @@ setup_logging()
logger = get_logger("worker")
def start_scheduler_thread() -> None:
"""在当前进程内启动 scheduler + 健康监控(daemon thread)。
给 service_run.sh 用:在 uvicorn 同进程跑后台 scheduler,避免
docker 一个容器跑两个进程(supervisord / s6 那种方案)。
幂等:多次调用只启动一次。
2026-07-12: 增加 RUNTIME_MODE 感知。systemd 模式下不启动 scheduler
因为同步由 systemd timer 触发,避免双调度器重叠。
"""
logger.info(f"[worker] runtime_mode={settings.runtime_mode}")
if settings.runtime_mode == "systemd":
logger.info("[worker] systemd 模式:不启动进程内 scheduler,同步由 systemd timer 触发")
return
# 1. 注册数据源 + seed config
from app.core.datasource.registry import (
build_default_registry,
seed_datasource_configs,
start_health_monitor,
)
build_default_registry()
seed_datasource_configs()
# 2. seed sync registry + 恢复卡死任务
from app.core.sync.registry import recover_interrupted_syncs, seed_sync_registry
seed_sync_registry()
n = recover_interrupted_syncs()
if n > 0:
logger.warning(f"[worker] 恢复了 {n} 个中断的同步任务")
# 3. seed 节假日
if settings.holidays_list:
db_ops.upsert_config(
"trading_calendar_holidays",
json.dumps(settings.holidays_list, ensure_ascii=False),
category="general",
description="A 股休市日",
)
# 4. seed 默认计划任务
if settings.scheduler_auto_seed:
from app.core.scheduler.scheduler import seed_schedule_configs
seed_schedule_configs()
# 5. 注册 sync job + 启动调度器 + 健康监控
from app.core.scheduler.scheduler import (
register_sync_jobs,
start_scheduler,
)
register_sync_jobs()
start_scheduler()
start_health_monitor()
logger.info("[worker] scheduler + health monitor 已 inline 启动")
def main():
logger.info("[worker] market_data_sync worker 启动")
logger.info(f"[worker] runtime_mode={settings.runtime_mode}")
if settings.runtime_mode == "systemd":
logger.info("[worker] systemd 模式:不启动进程内 scheduler,同步由 systemd timer 触发")
logger.info("[worker] 仅启动健康监控(数据源心跳),按 Ctrl+C 退出")
from app.core.datasource.registry import build_default_registry, seed_datasource_configs, start_health_monitor
build_default_registry()
seed_datasource_configs()
start_health_monitor()
try:
while True:
time.sleep(60)
except KeyboardInterrupt:
logger.info("[worker] 收到 SIGINT,退出")
sys.exit(0)
return
# 1. 注册数据源 + seed config
from app.core.datasource.registry import build_default_registry, seed_datasource_configs
+351
View File
@@ -0,0 +1,351 @@
"""market_sync MCP Server。
提供 5 类工具(tool),让 LLM 能查 market_sync 的同步情况:
1. list_sync_tasks — 列出所有同步任务
2. get_task_status — 查单个任务的状态机 + 最近 run
3. list_datasets — 列出所有数据集(含数据规模)
4. get_dataset_info — 单个数据集的详细范围(日期范围/数据量/列)
5. get_today_sync_summary — 今日同步汇总
传输: stdio(本地启动 Claude Desktop / Claude Code 直连)。
启动:
python -m app.mcp_server
# 或作为 MCP server 注册到客户端
架构:
- MCP 协议层: mcp.server.Server + stdio_server
- 业务层: 复用 app.core.db.ops + app.core.sync.registry 的 helper
- 不直接操作 PG,通过 ORM session(一致的事务/连接管理)
"""
from __future__ import annotations
import asyncio
import json
import sys
from datetime import datetime
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))
# ── MCP server bootstrap ────────────────────────────────────────────
from mcp.server import Server
from mcp.server.sse import SseServerTransport
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
server = Server("market_sync")
# ── helpers ─────────────────────────────────────────────────────────
def _serialize(obj):
"""JSON-friendly: datetime/date/Decimal → str."""
if isinstance(obj, dict):
return {k: _serialize(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_serialize(v) for v in obj]
if isinstance(obj, (datetime,)):
return obj.isoformat()
try:
# handle date
import datetime as _dt
if isinstance(obj, _dt.date):
return obj.isoformat()
except Exception:
pass
return obj
def _ok(data) -> list[TextContent]:
"""Standard success response: JSON dump."""
return [TextContent(
type="text",
text=json.dumps(_serialize(data), ensure_ascii=False, indent=2, default=str),
)]
def _err(msg: str) -> list[TextContent]:
return [TextContent(type="text", text=f"{msg}")]
# ── Tool implementations ─────────────────────────────────────────────
def list_sync_tasks() -> list[dict]:
from app.tasks import TASKS
from app.core.db import ops as db_ops
from app.core.sync import get_registry_status
registry = {r["dataset_id"]: r for r in get_registry_status()}
history = db_ops.list_sync_history(days=7, limit=1000)
last_by_task: dict[str, dict] = {}
for h in history:
if h["dataset_id"] not in last_by_task:
last_by_task[h["dataset_id"]] = h
out = []
for ds_id, cls in TASKS.items():
reg = registry.get(ds_id, {})
last = last_by_task.get(ds_id)
out.append({
"dataset_id": ds_id,
"name": reg.get("name", ""),
"description": reg.get("description", ""),
"source": reg.get("source", ""),
"sort_order": reg.get("sort_order", 0),
"current_status": reg.get("status", "unknown"),
"last_run_at": (last or {}).get("started_at"),
"last_run_status": (last or {}).get("status"),
"last_run_rows": (last or {}).get("rows_written"),
"history_count_7d": sum(1 for h in history if h["dataset_id"] == ds_id),
})
return sorted(out, key=lambda x: x["sort_order"])
def get_task_status(dataset_id: str, days: int = 7) -> dict:
from app.core.db import ops as db_ops
from app.core.sync import get_registry_status
reg = next((r for r in get_registry_status() if r["dataset_id"] == dataset_id), None)
if not reg:
return {"error": f"unknown dataset_id: {dataset_id}"}
history = db_ops.list_sync_history(dataset_id=dataset_id, days=days, limit=50)
return {
"dataset_id": dataset_id,
"current_state": reg,
"recent_runs": history,
}
def list_datasets() -> list[dict]:
"""每个数据集的当前规模(行数 + 日期范围 + 来源)。"""
from sqlalchemy import func, select
from app.core.db.models import (
KlineStock, KlineIndex, Kline5Min, Moneyflow, Share, TickTrade,
MarketRegimeDaily,
LonghubangDaily, LonghubangSeat, Stock,
NodeCategory, Node, StockNodeMap, KlineStockMADaily,
KlineStockMACDDaily, KlineStockKDJDaily, KlineStockBOLLDaily,
)
from app.core.db.orm import SessionLocal
targets = [
("stocks", Stock),
("kline_stock", KlineStock),
("kline_index", KlineIndex),
("kline_5min", Kline5Min),
("kline_stock_ma_daily", KlineStockMADaily),
("moneyflow", Moneyflow),
("share", Share),
("tick_trade", TickTrade),
("market_regime_daily", MarketRegimeDaily),
("longhubang_daily", LonghubangDaily),
("longhubang_seat", LonghubangSeat),
("node_categories", NodeCategory),
("nodes", Node),
("stock_node_map", StockNodeMap),
("kline_stock_macd_daily", KlineStockMACDDaily),
("kline_stock_kdj_daily", KlineStockKDJDaily),
("kline_stock_boll_daily", KlineStockBOLLDaily),
]
out = []
with SessionLocal() as s:
for name, model in targets:
try:
count = int(s.execute(select(func.count()).select_from(model)).scalar() or 0)
dr = None
if hasattr(model, "trade_date"):
mn = s.execute(select(func.min(model.trade_date))).scalar()
mx = s.execute(select(func.max(model.trade_date))).scalar()
dr = {"min": str(mn), "max": str(mx)} if mn else None
out.append({"table": name, "rows": count, "date_range": dr})
except Exception as e:
out.append({"table": name, "rows": -1, "error": str(e)[:100]})
return out
def get_dataset_info(table_name: str) -> dict:
"""单表 schema + 数据范围 + 抽样行。"""
from sqlalchemy import inspect
from app.core.db.orm import SessionLocal
# 表名白名单(避免任意 SQL 注入)
allowed = {
"stocks", "kline_stock", "kline_index", "kline_5min",
"kline_stock_ma_daily", "moneyflow", "share", "tick_trade",
"market_regime_daily",
"longhubang_daily", "longhubang_seat",
"node_categories", "nodes", "stock_node_map",
"kline_stock_macd_daily", "kline_stock_kdj_daily", "kline_stock_boll_daily",
"dataset_registry", "sync_history", "config",
}
if table_name not in allowed:
return {"error": f"unknown table: {table_name}", "allowed": sorted(allowed)}
with SessionLocal() as s:
insp = inspect(s.get_bind())
cols = [{"name": c["name"], "type": str(c["type"]), "nullable": c.get("nullable", True)}
for c in insp.get_columns(table_name, schema="market_data")]
pk = insp.get_pk_constraint(table_name, schema="market_data")
idx = [i["name"] for i in insp.get_indexes(table_name, schema="market_data")]
sample = s.execute(
__import__("sqlalchemy").text(
f'SELECT * FROM market_data."{table_name}" LIMIT 3'
)
).mappings().all()
return {
"table": table_name,
"schema": "market_data",
"primary_key": pk.get("constrained_columns", []),
"indexes": idx,
"columns": cols,
"sample_rows": [dict(r) for r in sample],
}
def get_today_sync_summary() -> dict:
from datetime import date
from app.core.db import ops as db_ops
today = date.today().isoformat()
summary_rows = db_ops.daily_sync_summary(days=1)
today_summary = next((r for r in summary_rows if r["date"] == today), None) or {
"date": today, "ok": 0, "warning": 0, "error": 0, "blocked": 0, "total": 0,
}
history = db_ops.list_sync_history(days=1, limit=500)
return {
"date": today,
"summary": today_summary,
"tasks_today": history,
}
# ── MCP tool 注册 ──────────────────────────────────────────────────
@server.list_tools()
async def list_tools():
return [
Tool(
name="list_sync_tasks",
description="列出 market_sync 项目的所有同步任务(dataset_id + 名称 + 当前状态 + 最近 7 天运行统计)。",
inputSchema={"type": "object", "properties": {}},
),
Tool(
name="get_task_status",
description="查询单个同步任务的详细状态机 + 最近 N 天所有 run 历史。",
inputSchema={
"type": "object",
"properties": {
"dataset_id": {"type": "string", "description": "例如 kline_daily / moneyflow"},
"days": {"type": "integer", "description": "查最近 N 天, 默认 7", "default": 7},
},
"required": ["dataset_id"],
},
),
Tool(
name="list_datasets",
description="列出所有 PG 数据集(含 market_data schema 下 17 张业务表)的行数 + 日期范围。",
inputSchema={"type": "object", "properties": {}},
),
Tool(
name="get_dataset_info",
description="查询单个数据集的表结构(列名 + 类型 + 主键 + 索引)+ 3 行抽样数据。",
inputSchema={
"type": "object",
"properties": {
"table_name": {"type": "string", "description": "如 kline_stock / moneyflow / tick_trade"},
},
"required": ["table_name"],
},
),
Tool(
name="get_today_sync_summary",
description="今日同步情况汇总(OK / Warning / Error / Blocked 计数 + 每个任务的最新 run)。",
inputSchema={"type": "object", "properties": {}},
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
try:
if name == "list_sync_tasks":
return _ok(list_sync_tasks())
elif name == "get_task_status":
ds_id = arguments.get("dataset_id", "")
days = int(arguments.get("days", 7))
return _ok(get_task_status(ds_id, days))
elif name == "list_datasets":
return _ok(list_datasets())
elif name == "get_dataset_info":
return _ok(get_dataset_info(arguments.get("table_name", "")))
elif name == "get_today_sync_summary":
return _ok(get_today_sync_summary())
else:
return _err(f"unknown tool: {name}")
except Exception as e:
import traceback
return _err(f"{type(e).__name__}: {e}\n{traceback.format_exc(limit=3)}")
# ── main ────────────────────────────────────────────────────────────
async def main_sse(host: str = "127.0.0.1", port: int = 8101):
"""启动 MCP 的 SSE (HTTP) 服务,供持久运行。
客户端连接: http://{host}:{port}/sse
"""
sse_transport = SseServerTransport("/messages")
async def app(scope, receive, send):
if scope["type"] != "http":
return
path = scope.get("path", "")
if path == "/sse":
async with sse_transport.connect_sse(scope, receive, send) as (read, write):
await server.run(read, write, server.create_initialization_options())
elif path == "/messages" and scope.get("method") == "POST":
await sse_transport.handle_post_message(scope, receive, send)
import uvicorn
from app.core.utils.logging import get_logger
logger = get_logger("mcp")
logger.info(f"MCP SSE server listening on http://{host}:{port}/sse")
config = uvicorn.Config(app, host=host, port=port, log_level="info")
srv = uvicorn.Server(config)
await srv.serve()
def main():
from app.core.datasource.registry import build_default_registry
build_default_registry()
import sys
if "--sse" in sys.argv:
host = "127.0.0.1"
port = 8101
for i, arg in enumerate(sys.argv):
if arg == "--host" and i + 1 < len(sys.argv):
host = sys.argv[i + 1]
if arg == "--port" and i + 1 < len(sys.argv):
port = int(sys.argv[i + 1])
asyncio.run(main_sse(host, port))
else:
asyncio.run(_stdio_main())
async def _stdio_main():
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options(),
)
if __name__ == "__main__":
main()
+136 -5
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", "indicator_daily"]
requires_credential = True
credential_key = "MAIRUI_LICENCE"
@@ -40,7 +40,7 @@ class MairuiSource(DataSource):
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"Accept": "application/json",
}
_BASE_URL = "https://api.mairuiapi.com"
_BASE_URL = "https://a.mairuiapi.com"
# mairui licence 限速(按 tier):
# 免费版: 1 min/300 次 = 5 RPS
@@ -285,6 +285,68 @@ class MairuiSource(DataSource):
df = df[df["bar_time"] < pd.Timestamp(end) + pd.Timedelta(days=1)]
return df
# ── 技术指标 fetchMACD / KDJ / BOLL)─────────────────────
# mairui 端点:/hsstock/history/{indicator}/{symbol}/d/n/{licence}
# indicator ∈ {macd, kdj, boll};日 K 级别 "/d/";不复权 "/n/"
# 各指标字段(除公共 "t" 外):
# macd → diff, dea, macd, ema12, ema26
# kdj → k, d, j
# boll → u(上轨), d(下轨), m(中轨) ← 注意 mairui 用 u/d/m
_INDICATOR_FIELDS = {
"macd": ["diff", "dea", "macd", "ema12", "ema26"],
"kdj": ["k", "d", "j"],
"boll": ["u", "d", "m"],
}
def fetch_indicator_daily(
self, indicator: str, code6: str, start: str = "", end: str = ""
) -> pd.DataFrame:
"""拉单只股票某个技术指标的日 K 序列。
返回列:trade_date + 该指标字段(macd/kdj/boll 各自的原始字段名)。
start/end 为 "YYYY-MM-DD"(可空 → 全历史)。
"""
indicator = indicator.lower()
fields = self._INDICATOR_FIELDS.get(indicator)
if fields is None:
raise ValueError(f"未知指标: {indicator!r},可选 {list(self._INDICATOR_FIELDS)}")
lic = self._get_licence()
if not lic:
return pd.DataFrame()
symbol = code6_to_mairui(code6)
path = f"/hsstock/history/{indicator}/{symbol}/d/n/{lic}"
params = {
"st": (start or "").replace("-", ""),
"et": (end or "").replace("-", ""),
}
data = self._fetch(path, params)
if not isinstance(data, list):
return pd.DataFrame()
records = []
for item in data:
try:
t = item.get("t", "")
d = t.split(" ")[0] if isinstance(t, str) else ""
if not d:
continue
row = {"trade_date": d}
for f in fields:
v = item.get(f)
row[f] = None if v is None else float(v)
records.append(row)
except (KeyError, ValueError, TypeError):
continue
if not records:
return pd.DataFrame()
df = pd.DataFrame(records)
df["trade_date"] = pd.to_datetime(df["trade_date"], errors="coerce")
df = df.dropna(subset=["trade_date"]).sort_values("trade_date").reset_index(drop=True)
if start:
df = df[df["trade_date"] >= pd.Timestamp(start)]
if end:
df = df[df["trade_date"] <= pd.Timestamp(end)]
return df
def fetch_index_daily(self, index_code: str, start: str, end: str) -> pd.DataFrame:
"""指数日 K。index_code 用 mairui 格式(如 '000300.SH')。"""
lic = self._get_licence()
@@ -432,10 +494,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 +524,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 +553,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()
+235
View File
@@ -0,0 +1,235 @@
"""QMT Bridge 数据源(本地行情桥 HTTP 接口)。
将 xtquant 行情 API 通过 HTTP 暴露的本地服务,提供日 K、5 分钟 K 线和指数日 K。
无需凭证(本地服务),超时短(局域网),不限速。
"""
from __future__ import annotations
import json
import urllib.request
from datetime import datetime
from typing import Any, Optional
import pandas as pd
from app.core.config import settings
from app.core.datasource.base import DataSource
from app.core.datasource.utils import code6_to_mairui, normalize_5min
from app.core.utils.logging import get_logger
logger = get_logger("qmt_bridge")
KLINE_FIELDS = "close,open,high,low,volume"
KLINE_5MIN_FIELDS = "close,open,high,low,volume,amount"
class QmtBridgeSource(DataSource):
key = "datasource_qmt_bridge"
name = "QMT Bridge(本地行情桥)"
provides = ["kline_daily", "kline_5min", "index_daily"]
requires_credential = False
credential_key = ""
def __init__(self) -> None:
self._base_url = (settings.qmt_bridge_url or "http://127.0.0.1:8610").rstrip("/")
# ── 健康检查 / 可用性 ─────────────────────────────────
def is_available(self) -> tuple[bool, str]:
try:
resp = self._get("/health")
if isinstance(resp, dict) and resp.get("status") == "ok":
return True, "就绪"
return False, f"bridge 返回异常: {resp}"
except Exception as e:
return False, f"bridge 不可达: {e}"
def health_check(self) -> dict[str, Any]:
try:
resp = self._get("/health")
if isinstance(resp, dict) and resp.get("status") == "ok":
return {"success": True, "message": f"bridge 连接正常"}
return {"success": False, "message": f"bridge 返回异常: {resp}"}
except Exception as e:
return {"success": False, "message": f"bridge 连接失败: {e}"}
# ── K 线 fetch ─────────────────────────────────────
def fetch_kline_daily(self, code6: str, start: str, end: str) -> pd.DataFrame:
"""日 K 线。
Args:
code6: 6 位股票代码(如 "600519"
start: "YYYY-MM-DD"
end: "YYYY-MM-DD"
Returns:
DataFrame 列: trade_date, open, high, low, close, volume
"""
symbol = code6_to_mairui(code6)
params = {
"code": symbol,
"period": "1d",
"start": start.replace("-", ""),
"end": end.replace("-", ""),
"count": "-1",
"fields": KLINE_FIELDS,
}
data = self._get("/kline", params)
records = self._parse_kline_data(data, is_daily=True)
if not records:
return pd.DataFrame()
df = pd.DataFrame(records)
df["trade_date"] = pd.to_datetime(df["trade_date"])
return self._normalize_kline(df)
def fetch_kline_5min(self, code6: str, start: str, end: str) -> pd.DataFrame:
"""5 分钟 K 线。
Args:
code6: 6 位股票代码(如 "600519"
start: "YYYY-MM-DD"
end: "YYYY-MM-DD"
Returns:
DataFrame 列: bar_time, open, high, low, close, volume, amount, turnover_rate
"""
symbol = code6_to_mairui(code6)
params = {
"code": symbol,
"period": "5m",
"start": start.replace("-", ""),
"end": end.replace("-", ""),
"count": "-1",
"fields": KLINE_5MIN_FIELDS,
}
data = self._get("/kline", params)
records = self._parse_kline_data(data, is_daily=False)
if not records:
return pd.DataFrame()
df = pd.DataFrame(records)
# 用 normalize_5min 标准化
return normalize_5min(df)
def fetch_index_daily(self, index_code: str, start: str, end: str) -> pd.DataFrame:
"""指数日 K 线。
Args:
index_code: 带交易所后缀的指数代码(如 "000300.SH"
start: "YYYY-MM-DD"
end: "YYYY-MM-DD"
Returns:
DataFrame 列: trade_date, open, high, low, close, volume
"""
params = {
"code": index_code,
"period": "1d",
"start": start.replace("-", ""),
"end": end.replace("-", ""),
"count": "-1",
"fields": KLINE_FIELDS,
}
data = self._get("/kline", params)
records = self._parse_kline_data(data, is_daily=True)
if not records:
return pd.DataFrame()
df = pd.DataFrame(records)
df["trade_date"] = pd.to_datetime(df["trade_date"])
return self._normalize_kline(df)
# ── 内部方法 ───────────────────────────────────────
def _get(self, path: str, params: dict[str, str] | None = None) -> Any:
"""GET 请求 bridge,返回解析后的 JSON。"""
url = f"{self._base_url}{path}"
if params:
qs = "&".join(f"{k}={v}" for k, v in params.items() if v)
url = f"{url}?{qs}"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw)
except Exception as e:
logger.warning(f"[qmt_bridge] GET {path} 失败: {e}")
raise
def _parse_kline_data(
self, data: dict[str, Any], is_daily: bool
) -> list[dict[str, Any]]:
"""桥响应解析为统一记录列表。跳过全零记录(停牌/未交易)。"""
records = []
raw_list = data.get("data") if isinstance(data, dict) else None
if not isinstance(raw_list, list):
return records
for item in raw_list:
try:
t = str(item.get("time", "") or "")
if not t:
continue
# 跳过全零记录(停牌/未交易)
o = float(item.get("open") or 0)
h = float(item.get("high") or 0)
l = float(item.get("low") or 0)
c = float(item.get("close") or 0)
v = float(item.get("volume") or 0)
if o <= 0 and h <= 0 and l <= 0 and c <= 0 and v <= 0:
continue
if is_daily:
# 日 K: time = "YYYYMMDD"
if len(t) >= 8:
trade_date = f"{t[:4]}-{t[4:6]}-{t[6:8]}"
else:
continue
records.append({
"trade_date": trade_date,
"open": o,
"high": h,
"low": l,
"close": c,
"volume": v,
})
else:
# 5min K: time = "YYYYMMDDHHmmss"
if len(t) >= 14:
bar_time = f"{t[:4]}-{t[4:6]}-{t[6:8]} {t[8:10]}:{t[10:12]}:{t[12:14]}"
elif len(t) >= 8:
bar_time = f"{t[:4]}-{t[4:6]}-{t[6:8]} 00:00:00"
else:
continue
amt = float(item.get("amount") or 0)
records.append({
"bar_time": bar_time,
"open": o,
"high": h,
"low": l,
"close": c,
"volume": v,
"amount": amt,
"turnover_rate": 0.0, # bridge 不提供
})
except (KeyError, ValueError, TypeError):
continue
return records
@staticmethod
def _normalize_kline(df: pd.DataFrame) -> pd.DataFrame:
"""日 K 标准化:类型转换 + OHLC 校验。"""
if df.empty:
return df
for col in ["open", "high", "low", "close", "volume"]:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
df = df.dropna(subset=["trade_date", "open", "high", "low", "close"])
df["volume"] = df["volume"].fillna(0)
# OHLC 合理性检查
valid = (
(df["open"] > 0) & (df["high"] > 0) & (df["low"] > 0) & (df["close"] > 0)
& (df["volume"] >= 0)
& (df["high"] >= df[["open", "low", "close"]].max(axis=1) - 0.001)
& (df["low"] <= df[["open", "high", "close"]].min(axis=1) + 0.001)
)
df = df.loc[valid].sort_values("trade_date")
return df.drop_duplicates(subset=["trade_date"], keep="last").reset_index(drop=True)
+108 -7
View File
@@ -16,7 +16,7 @@ from typing import Any, Optional
import pandas as pd
from app.core.datasource.base import DataSource
from app.core.datasource.utils import code6_to_xueqiu
from app.core.datasource.utils import call_with_timeout, code6_to_xueqiu, effective_market_date
logger = logging.getLogger("sync.xueqiu")
@@ -94,7 +94,11 @@ class XueqiuSource(DataSource):
try:
ball = self._import_ball()
self._set_token_once()
result = ball.quote_detail("SH600036")
result = call_with_timeout(
ball.quote_detail, "SH600036",
timeout=10.0, on_timeout=None,
description="xueqiu.health_check",
)
if result is None or result.get("error_code") != 0:
return {
"success": False,
@@ -123,12 +127,23 @@ class XueqiuSource(DataSource):
count = min(max((end_dt - start_dt).days + 60, 10), 5000)
# 加重试:雪球风控偶尔返回空/错误
# 用 call_with_timeout 包一层 — 2026-07-08 kline_daily 卡死 4h 根因
# 就是 pysnowball ball.kline() 底层 requests 无 read timeout,雪球
# 服务端卡住时这里永久阻塞。timeout=20s 与 mairui/sina 对齐。
result = None
for attempt in range(3):
result = ball.kline(symbol, period="day", count=count)
result = call_with_timeout(
ball.kline, symbol, period="day", count=count,
timeout=20.0, on_timeout=None,
description=f"xueqiu.kline {symbol}",
)
if result and result.get("error_code") == 0:
break
time.sleep(0.3 * (attempt + 1))
if result is None and attempt < 2:
time.sleep(0.3 * (attempt + 1))
continue
if result and result.get("error_code") != 0:
time.sleep(0.3 * (attempt + 1))
if not result or result.get("error_code") != 0:
logger.warning(
"[kline %s] 雪球 kline 失败: error_code=%s desc=%s",
@@ -163,6 +178,83 @@ class XueqiuSource(DataSource):
logger.exception("[kline %s] 雪球 kline 异常: %s", code6, e)
return pd.DataFrame()
def fetch_kline_5min(self, code6: str, start: str, end: str) -> pd.DataFrame:
"""5 分钟 K 线(雪球 period=5m)。返回标准列:bar_time, open, high, low, close, volume, amount。"""
if not self._read_token():
return pd.DataFrame()
try:
ball = self._import_ball()
self._set_token_once()
self._wait_rps()
symbol = code6_to_xueqiu(code6)
start_dt = datetime.strptime(start, "%Y-%m-%d")
end_dt = datetime.strptime(end, "%Y-%m-%d")
days_needed = max((end_dt - start_dt).days + 2, 1)
count = min(days_needed * 48, 5000)
result = None
for attempt in range(3):
result = call_with_timeout(
ball.kline, symbol, period="5m", count=count,
timeout=20.0, on_timeout=None,
description=f"xueqiu.kline.5m {symbol}",
)
if result and result.get("error_code") == 0:
break
if result is None and attempt < 2:
time.sleep(0.3 * (attempt + 1))
continue
if result and result.get("error_code") != 0:
time.sleep(0.3 * (attempt + 1))
if not result or result.get("error_code") != 0:
logger.warning(
"[kline5m %s] 雪球 5m 失败: error_code=%s desc=%s",
code6,
result.get("error_code") if result else "None",
result.get("error_description") if result else "None",
)
return pd.DataFrame()
data = result.get("data", {})
columns = data.get("column", [])
items = data.get("item", [])
if not columns or not items:
return pd.DataFrame()
idx = {c: i for i, c in enumerate(columns)}
needed = {"timestamp": idx.get("timestamp"), "volume": idx.get("volume"),
"open": idx.get("open"), "high": idx.get("high"),
"low": idx.get("low"), "close": idx.get("close")}
if any(v is None for v in needed.values()):
return pd.DataFrame()
records = []
for row in items:
try:
ts = row[needed["timestamp"]] / 1000
records.append({
"bar_time": datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S"),
"open": float(row[needed["open"]]),
"high": float(row[needed["high"]]),
"low": float(row[needed["low"]]),
"close": float(row[needed["close"]]),
"volume": float(row[needed["volume"]]),
"amount": 0.0,
})
except (IndexError, ValueError, TypeError, OSError):
continue
if not records:
return pd.DataFrame()
df = pd.DataFrame(records)
df["bar_time"] = pd.to_datetime(df["bar_time"], errors="coerce")
df = df.dropna(subset=["bar_time"]).sort_values("bar_time").reset_index(drop=True)
if start:
df = df[df["bar_time"] >= pd.Timestamp(start)]
if end:
df = df[df["bar_time"] < pd.Timestamp(end) + pd.Timedelta(days=1)]
return df
except Exception as e:
logger.exception("[kline5m %s] 雪球 5m 异常: %s", code6, e)
return pd.DataFrame()
def fetch_share_snapshot(self, code6: str) -> Optional[dict[str, Any]]:
"""单只股票的最新股本快照。
@@ -177,7 +269,12 @@ class XueqiuSource(DataSource):
self._wait_rps()
symbol = code6_to_xueqiu(code6)
result = ball.quote_detail(symbol)
# ball.quote_detail 同样有 pysnowball 无 read timeout 风险,包一层
result = call_with_timeout(
ball.quote_detail, symbol,
timeout=15.0, on_timeout=None,
description=f"xueqiu.quote_detail {symbol}",
)
if not result or result.get("error_code") != 0:
logger.warning(
"[share %s] 雪球 quote_detail 失败: error_code=%s desc=%s",
@@ -187,14 +284,18 @@ class XueqiuSource(DataSource):
)
return None
quote = result.get("data", {}).get("quote", {})
today_str = datetime.now().strftime("%Y-%m-%d")
# 雪球 quote_detail 不返回明确"股本变动日",time 字段是 quote 当前
# 时间,不是交易日。股本数据本质是慢变快照,这里标"最近已完成交易日"
# (15:30 前=昨天,15:30 后=今天) — 比 datetime.now() 精确,避免盘前
# 拉到的快照被错标为"今天"。
as_of = effective_market_date()
total = round(float(quote.get("total_shares") or 0) / 1e8, 4)
flt = round(float(quote.get("float_shares") or 0) / 1e8, 4)
if total <= 0 or flt <= 0:
logger.warning("[share %s] 雪球返回成功但 total/float 为 0", code6)
return None
return {
"trade_date": today_str,
"trade_date": as_of,
"total_share": total,
"float_share": flt,
}
+6 -4
View File
@@ -3,15 +3,16 @@
所有 `SyncTask` 子类在此集中注册,外部通过 `get_task(dataset_id)` 或
遍历 `TASKS` 字典使用。
"""
from app.tasks.task_industry_sector import SyncIndustrySector
from app.tasks.task_kline_5min import SyncKline5Min
from app.tasks.task_kline_daily import SyncKlineDaily
from app.tasks.task_kline_index import SyncKlineIndex
from app.tasks.task_longhubang import SyncLonghubang
from app.tasks.task_mairui_ma_daily import SyncMairuiMADaily
from app.tasks.task_mairui_indicators import SyncMairuiIndicators
from app.tasks.task_market_regime import SyncMarketRegime
from app.tasks.task_moneyflow import SyncMoneyflow
from app.tasks.task_sector_features import SyncSectorFeatures
from app.tasks.task_share_snapshot import SyncShareSnapshot
from app.tasks.task_stock_node import SyncStockNode
from app.tasks.task_stocks_basic import SyncStocksBasic
from app.tasks.task_tick_trade import SyncTickTrade
@@ -24,11 +25,12 @@ TASKS: dict[str, type] = {
SyncKline5Min,
SyncTickTrade,
SyncMoneyflow,
SyncIndustrySector,
SyncSectorFeatures,
SyncShareSnapshot,
SyncMarketRegime,
SyncLonghubang,
SyncStockNode,
SyncMairuiMADaily,
SyncMairuiIndicators,
)
}
-121
View File
@@ -1,121 +0,0 @@
"""同步任务:股票-行业映射(Baostock)。
数据流:Baostock query_stock_industry() → 写 industry / sectors / stock_sector_map 三张表。"""
from __future__ import annotations
import time
from typing import Any
from sqlalchemy import update
from app.core.db import ops as db_ops
from app.core.db.models import Stock
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.industry_sector")
def _build_sector_key(taxonomy: str, sector_name: str) -> str:
tax = (taxonomy or "").strip() or "default"
name = (sector_name or "").strip()
return f"{tax}::{name}"
class SyncIndustrySector(SyncTask):
dataset_id = "industry_sector"
def _run(self, *, trigger_source: str = "manual", **kwargs) -> dict[str, Any]:
bs = ds_registry.get("datasource_baostock")
if bs is None:
return {"status": "error", "message": "Baostock 数据源未注册"}
ok, reason = is_source_ready(bs.key)
if not ok:
mark_sync_blocked(self.dataset_id, message=f"Baostock 未就绪: {reason}")
return {"status": "blocked", "message": f"Baostock 未就绪: {reason}"}
self._progress(message="拉取股票-行业映射...")
t0 = time.time()
try:
raw_rows = bs.fetch_industry_map()
except Exception as e:
return {"status": "error", "message": f"拉取失败: {e}"}
if not raw_rows:
return {"status": "warning", "message": "Baostock 未返回行业数据"}
# 合并:sectors + stock_sector_map + industry
sectors_rows: dict[str, dict] = {}
stock_sector_rows: list[dict] = []
industry_rows: list[dict] = []
for r in raw_rows:
code6 = str(r["code"]).zfill(6)
name = (r.get("industry_name") or "").strip()
if not name:
continue
classification = (r.get("industry_classification") or "").strip()
sector_key = _build_sector_key(classification, name)
sectors_rows[sector_key] = {
"sector_key": sector_key,
"sector_name": name,
"taxonomy": classification,
"level": "",
"source": "baostock_query_stock_industry",
"enabled": 1,
}
stock_sector_rows.append({"stock_code": to_hermes(code6), "sector_key": sector_key})
industry_rows.append({
"code": to_hermes(code6),
"industry_name": name,
"industry_classification": classification,
"update_date": r.get("update_date", ""),
})
# 写库(全量替换)
try:
db_ops.replace_all_sectors(list(sectors_rows.values()))
db_ops.replace_all_stock_sector_map(stock_sector_rows)
db_ops.replace_all_industries(industry_rows)
except Exception as e:
return {"status": "error", "message": f"写库失败: {e}"}
# 回填 stocks.industry 字段(让 stocks 表也能直接看到)
# 走 ORM update(Stock) + IN (SHxxx, SZxxx, BJxxx, ...)
try:
# 按 industry_name 分组,对每组用一个 IN 批量更新
by_industry: dict[str, list[str]] = {}
for r in industry_rows:
name = r["industry_name"]
code = r["code"]
if name not in by_industry:
by_industry[name] = []
by_industry[name].append(code)
for ind_name, codes in by_industry.items():
# 6 位 code × 3 个交易所前缀(SH/SZ/BJ)
code_variants = [
f"{prefix}{c}" for c in codes for prefix in ("SH", "SZ", "BJ")
]
with db_ops.get_session() as s:
s.execute(
update(Stock)
.where(Stock.code.in_(code_variants))
.values(industry=ind_name)
)
except Exception as e:
logger.warning(f"回填 stocks.industry 失败(不影响主任务): {e}")
elapsed = round(time.time() - t0, 1)
msg = f"行业 {len(sectors_rows)}个 映射{len(stock_sector_rows)}条, {elapsed}s"
return {
"status": "ok",
"message": msg,
"sectors": len(sectors_rows),
"stock_sector_map": len(stock_sector_rows),
"elapsed_sec": elapsed,
}
+179 -12
View File
@@ -17,7 +17,7 @@ from __future__ import annotations
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeoutError
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
@@ -37,6 +37,10 @@ MAIRUI_5MIN_FLOOR = datetime(2023, 6, 14, tzinfo=timezone.utc)
WINDOW_DAYS = 365
# 增量时往前多取的天数(防交易日历边界漏当天)
INCREMENT_OVERLAP_DAYS = 2
# 2026-07-08 教训: 数据源层有 20s timeout,task 层再硬兜底,
# 防 SDK 升级 / 网络层 bug 让单只股票卡住(7月7日 4.4只/秒 后突然 0 持续 24h)
# 7月9日 mairui/雪球 间歇性 "服务器连接失败" + Broken pipe,拉慢。给 120s 容忍。
FETCH_HARD_TIMEOUT = 600.0 # 5min K 全市场 5200+ 只,按 RPS 预估约 17min,给 10min 硬超时
class SyncKline5Min(SyncTask):
@@ -73,13 +77,25 @@ class SyncKline5Min(SyncTask):
max_workers: int = 5,
**kwargs,
) -> dict[str, Any]:
primary = ds_registry.get("datasource_mairui")
# QMT Bridge 优先,不可用时降级到 mairui
primary = ds_registry.get("datasource_qmt_bridge") or ds_registry.get("datasource_mairui")
if primary is None:
return {"status": "error", "message": "5min 数据源 mairui 未注册"}
return {"status": "error", "message": "5min 数据源 qmt_bridge/mairui 未注册"}
ok, reason = is_source_ready(primary.key)
if not ok:
mark_sync_blocked(self.dataset_id, message=f"{primary.key} 未就绪: {reason}")
return {"status": "blocked", "message": f"{primary.key} 未就绪: {reason}"}
# 如果 bridge 不可用但 mairui 可用,尝试切换
fallback = ds_registry.get("datasource_mairui") if primary.key == "datasource_qmt_bridge" else None
if fallback:
fb_ok, fb_reason = is_source_ready(fallback.key)
if fb_ok:
logger.warning(f"[5min] {primary.key} 不可用 ({reason}), 降级到 {fallback.key}")
primary = fallback
else:
mark_sync_blocked(self.dataset_id, message=f"{primary.key}/{fallback.key} 均不可用")
return {"status": "blocked", "message": f"{primary.key}/{fallback.key} 均不可用"}
else:
mark_sync_blocked(self.dataset_id, message=f"{primary.key} 未就绪: {reason}")
return {"status": "blocked", "message": f"{primary.key} 未就绪: {reason}"}
if codes:
stock_codes = [to_code6(c) for c in codes]
@@ -99,7 +115,12 @@ class SyncKline5Min(SyncTask):
# ── 2) 内存算计划 ──
# PG TIMESTAMPTZ 是 timezone-aware,所以 end 也必须 tz-aware 否则比较会炸
end = datetime.now(timezone.utc)
# 2026-07-12 修复: end 改为上海时区最近交易日 15:00,避免 UTC 与上海时间混用
from app.core.datasource.utils import effective_market_date
end_date_str = effective_market_date()
end = datetime.strptime(f"{end_date_str} 15:00:00", "%Y-%m-%d %H:%M:%S").replace(
tzinfo=timezone(timedelta(hours=8))
)
plans = self._plan(stock_codes, end, snapshots)
if not plans:
msg = f"5min K线 — 全部 {len(stock_codes)} 只都已最新(无需同步)"
@@ -129,12 +150,17 @@ class SyncKline5Min(SyncTask):
t0 = time.time()
ok_cnt = fail_cnt = 0
rows_total = 0
with ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {pool.submit(self._sync_one, primary, p[0], p[1], p[2]): p[0] for p in plans}
for i, future in enumerate(as_completed(futures), 1):
# 不用 `with ThreadPoolExecutor(...) as pool:` — 它的 __exit__ 默认 wait=True,
# 一旦某个 worker 卡在 xueqiu IO,主线程会在 as_completed 触发 60s timeout 后
# 仍被 __exit__ 阻塞等 worker 退出 → 进程挂死(2026-07-09 13:21 教训)。
# 改成手动管理 + shutdown(wait=False),主线程能立刻退出。
pool = ThreadPoolExecutor(max_workers=max_workers)
futures = {pool.submit(self._sync_one, primary, p[0], p[1], p[2]): p[0] for p in plans}
try:
for i, future in enumerate(as_completed(futures, timeout=FETCH_HARD_TIMEOUT), 1):
c6 = futures[future]
try:
res = future.result()
res = future.result(timeout=0.1) # 已被 as_completed 释放
if res["status"] == "ok":
ok_cnt += 1
rows_total += res["rows"]
@@ -142,6 +168,9 @@ class SyncKline5Min(SyncTask):
fail_cnt += 1
if res.get("error"):
logger.warning(f"[5min {c6}] {res['error']}")
except FuturesTimeoutError:
fail_cnt += 1
logger.warning(f"[5min {c6}] 内部 race timeout, 记 fail")
except Exception as e:
fail_cnt += 1
logger.warning(f"[5min {c6}] {e}")
@@ -150,6 +179,64 @@ class SyncKline5Min(SyncTask):
message=f"5min 进度 {i}/{len(plans)} OK:{ok_cnt} FAIL:{fail_cnt}",
current=i, total=len(plans), current_step=c6,
)
except FuturesTimeoutError:
stuck = [c6 for _, c6 in futures.items() if not _.done()]
logger.error(
"[5min] 所有 fetcher 卡死 (>%ss), %d 只股票未完成",
FETCH_HARD_TIMEOUT, len(stuck),
)
for f, c6 in futures.items():
try:
if not f.done() and hasattr(f, "cancel"):
f.cancel()
except Exception as e:
logger.warning(f"[5min] cancel future for {c6} 失败: {e}")
try:
pool.shutdown(wait=False)
except Exception as e:
logger.warning(f"[5min] pool.shutdown(wait=False) 失败: {e}")
# ── 补偿:冷却后重入队列 ──
# mairui 偶发全 hang(中间件重启/网络抖动),等 30s 再试一次。
# 如果还 hang 就放弃,下次调度增量重拉。
if stuck:
cooldown = 30
logger.warning(f"[5min] 冷却 {cooldown}s 后重试 {len(stuck)} 只…")
time.sleep(cooldown)
retry_ok = retry_fail = retry_rows = 0
retry_pool = ThreadPoolExecutor(max_workers=max_workers)
plan_dict = {p[0]: (p[1], p[2]) for p in plans}
retry_futs = {}
for c6 in stuck:
if c6 in plan_dict:
s, e = plan_dict[c6]
# 重试直接用雪球 fallbackMairui 已全 hang
retry_futs[retry_pool.submit(self._fallback_xueqiu_5m_and_write, c6, s, e)] = c6
if retry_futs:
for fut in as_completed(retry_futs, timeout=FETCH_HARD_TIMEOUT):
c6 = retry_futs[fut]
try:
res = fut.result(timeout=0.1)
if res["status"] == "ok":
retry_ok += 1
retry_rows += res["rows"]
else:
retry_fail += 1
except Exception:
retry_fail += 1
try:
retry_pool.shutdown(wait=False)
except Exception:
pass
ok_cnt += retry_ok
fail_cnt -= retry_ok # 从 fail 挪到 ok
rows_total += retry_rows
logger.warning(
f"[5min] 重试结果: {retry_ok}{retry_fail}"
f"{retry_rows}行, 救回 {retry_ok}"
)
else:
pool.shutdown(wait=True)
elapsed = round(time.time() - t0, 1)
msg = (
f"5min K线 {ok_cnt}{fail_cnt}败 共{rows_total}行, "
@@ -192,10 +279,16 @@ class SyncKline5Min(SyncTask):
})
cur = w_end + timedelta(days=1)
except Exception as e:
return {"status": "fail", "error": str(e)}
logger.warning(f"[5min {code6}] mairui 失败, 尝试雪球 5m fallback: {e}")
all_rows = self._fallback_xueqiu_5m(code6, start, end)
if not all_rows:
return {"status": "fail", "error": "no data"}
# Mairui 无数据, 尝试雪球 fallback
logger.info(f"[5min {code6}] mairui 无数据, 尝试雪球 fallback")
all_rows = self._fallback_xueqiu_5m(code6, start, end)
if not all_rows:
return {"status": "fail", "error": "no data (mairui + xueqiu fallback)"}
try:
CHUNK = 5000
for i in range(0, len(all_rows), CHUNK):
@@ -203,3 +296,77 @@ class SyncKline5Min(SyncTask):
except Exception as e:
return {"status": "fail", "error": f"db write: {e}"}
return {"status": "ok", "rows": len(all_rows)}
@staticmethod
def _fallback_xueqiu_5m_and_write(code6: str, start: datetime, end: datetime) -> dict:
"""雪球 5m fallback + 直接写库,对齐 _sync_one 返回格式供 retry 逻辑使用。"""
rows = SyncKline5Min._fallback_xueqiu_5m(code6, start, end)
if not rows:
return {"status": "fail", "error": "no data via xueqiu"}
try:
CHUNK = 5000
for i in range(0, len(rows), CHUNK):
db_ops.upsert_kline_5min(rows[i: i + CHUNK])
except Exception as e:
return {"status": "fail", "error": f"db write: {e}"}
return {"status": "ok", "rows": len(rows)}
@staticmethod
def _fallback_xueqiu_5m(code6: str, start: datetime, end: datetime) -> list[dict]:
"""通过雪球 5m K线 fallback 拉取数据,对齐 _sync_one 返回格式。"""
import os
from datetime import datetime as dt_mod
from app.core.datasource.utils import code6_to_xueqiu, to_hermes
token_raw = os.environ.get("XUEQIU_TOKEN", "").strip()
if not token_raw:
# 从 .env 读
try:
with open("/home/gao/Development/quant_home/market_sync/.env") as f:
for line in f:
if line.startswith("XUEQIU_TOKEN="):
token_raw = line.strip().split("=", 1)[1]
break
except Exception:
pass
if not token_raw:
logger.warning("[xueqiu_5m] 无 XUEQIU_TOKEN, 跳过 fallback")
return []
import pysnowball as ball
ball.set_token(token_raw)
symbol = code6_to_xueqiu(code6)
# 计算需要多少根 5m bar (每天 48 根, 加 buffer)
days_needed = max((end - start).days + 2, 1)
count = days_needed * 48
try:
data = ball.kline(symbol, "5m", min(count, 5000))
except Exception as e:
logger.warning(f"[xueqiu_5m {code6}] 请求失败: {e}")
return []
items = data.get("data", {}).get("item", [])
if not items:
return []
columns = data["data"]["column"] # [timestamp, volume, open, high, low, close, ...]
idx = {c: i for i, c in enumerate(columns)}
hermes = to_hermes(code6)
rows = []
for item in items:
ts = item[idx["timestamp"]] / 1000
bar_time = dt_mod.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
rows.append({
"stock_code": hermes,
"bar_time": bar_time,
"open": float(item[idx["open"]]),
"high": float(item[idx["high"]]),
"low": float(item[idx["low"]]),
"close": float(item[idx["close"]]),
"volume": float(item[idx["volume"]]),
"amount": 0.0,
"turnover_rate": 0.0,
})
return rows
+151 -29
View File
@@ -19,6 +19,7 @@ import os
import queue
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeoutError
from datetime import datetime, timedelta
from typing import Any
@@ -32,15 +33,18 @@ from app.core.utils.logging import get_logger
logger = get_logger("sync.kline_daily")
# 源优先级:mairui5 RPS,免费 + 稳定 + 单接口含5min)→ 雪球 → Baostock → 新浪
# 经验:sina 在单日窗口 (start=end=某天) 会返回空,雪球更可靠
PRIMARY_PRIORITY = ["datasource_xueqiu", "datasource_mairui", "datasource_xinlang"]
# 源优先级:QMT Bridge(本地) → 麦蕊 → 雪球 → 新浪
PRIMARY_PRIORITY = ["datasource_qmt_bridge", "datasource_mairui", "datasource_xueqiu", "datasource_xinlang"]
DEFAULT_START = "2018-01-01"
# 写库批量:攒够 BATCH_SIZE 行就 executemany 一次 + commit
BATCH_SIZE = 2000
# fetcher 并发:实测单线程最稳(5000 只 × 0.2s = 17 分钟,足够)
# 雪球 token 限速 10 RPS,单线程 0.1s/只 已经打满。多 worker 会触发风控 hang
DEFAULT_FETCH_WORKERS = 1
# 2026-07-08 教训: 数据源层虽然加了 fetch timeout (20s), task 层也加硬上限兜底,
# 防止数据源 SDK 升级后又被绕过。60s 对应 xueqiu/mairui 的 15-20s 上限 + DB upsert
# / DataFrame 处理余量 + 7月9日 mairui 间歇慢请求的容忍窗口。
FETCH_HARD_TIMEOUT = 180.0
class SyncKlineDaily(SyncTask):
@@ -57,14 +61,15 @@ class SyncKlineDaily(SyncTask):
max_workers: int = DEFAULT_FETCH_WORKERS,
**kwargs,
) -> dict[str, Any]:
# 1. 选主源
primary = self._pick_primary()
if primary is None:
# 1. 获取所有可用源(按优先级),支持 per-stock fallback
sources = self._get_available_sources()
if not sources:
return {"status": "error", "message": "所有 K 线数据源均不可用"}
source_names = ", ".join(s.name for s in sources)
self._progress(
message=f"使用 {primary.name} 同步日K线 (fetcher={max_workers}, batch={BATCH_SIZE})...",
current_step=primary.key,
message=f"使用 [{source_names}] 同步日K线 (fetcher={max_workers}, batch={BATCH_SIZE})...",
current_step=sources[0].key,
)
# 2. 拉股票列表
@@ -108,7 +113,6 @@ class SyncKlineDaily(SyncTask):
)
# 4. 并发 fetch + 攒 batch 写库(线程安全)
# mairui 等外部 API 无并发问题;DB 写串行加锁
from concurrent.futures import ThreadPoolExecutor, as_completed
t0 = time.time()
batch_lock = threading.Lock()
@@ -116,6 +120,7 @@ class SyncKlineDaily(SyncTask):
pending_codes: dict[str, str] = {}
ok_lock = threading.Lock()
ok_cnt = [0]
fallback_ok_cnt = [0]
fail_cnt = [0]
rows_cnt = [0]
@@ -138,15 +143,7 @@ class SyncKlineDaily(SyncTask):
except Exception as e:
logger.warning(f"update synced_at 失败 {c6}: {e}")
def _fetch_one(code6: str, fetch_start: str) -> tuple[str, list[dict] | None, str | None]:
try:
df = primary.fetch_kline_daily(code6, fetch_start, _end)
except Exception as e:
return (code6, None, str(e)[:120])
if df is None or df.empty:
return (code6, None, "no data")
# 写库用 hermes(带 SH/SZ 前缀)—— 2026-07-01 重构:历史上 kline_stock 用
# 6位 code 与 stocks.code(SH600519) 格式不一致,跨表 JOIN 失败。现统一。
def _rows_from_df(code6: str, df: pd.DataFrame) -> tuple[list[dict], str]:
hermes = to_hermes(code6)
rows = []
for _, r in df.iterrows():
@@ -162,20 +159,56 @@ class SyncKlineDaily(SyncTask):
})
latest = df["trade_date"].max()
latest = latest.strftime("%Y-%m-%d") if hasattr(latest, "strftime") else str(latest)[:10]
return (code6, rows, latest)
return (rows, latest)
def _fetch_one(code6: str, fetch_start: str) -> tuple[str, list[dict] | None, str | None, bool]:
errors = []
for src in sources:
try:
df = src.fetch_kline_daily(code6, fetch_start, _end)
except Exception as e:
errors.append(f"{src.key}: {e}")
continue
if df is None or df.empty:
errors.append(f"{src.key}: no data")
continue
rows, latest = _rows_from_df(code6, df)
is_fallback = src is not sources[0]
return (code6, rows, latest, is_fallback)
return (code6, None, "; ".join(errors)[:200], False)
total = len(jobs)
with ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {pool.submit(_fetch_one, c6, fs): c6 for c6, fs in jobs}
done_cnt = 0
for future in as_completed(futures):
# 不用 `with ThreadPoolExecutor(...) as pool:` — 它的 __exit__ 默认 wait=True,
# 一旦某个 worker 卡在 xueqiu IO,主线程会在 as_completed 触发 timeout 后
# 仍被 __exit__ 阻塞等 worker 退出 → 进程挂死(2026-07-09 13:21 教训)。
# 改成手动管理 + shutdown(wait=False),主线程能立刻退出。
pool = ThreadPoolExecutor(max_workers=max_workers)
futures = {pool.submit(_fetch_one, c6, fs): c6 for c6, fs in jobs}
done_cnt = 0
try:
for future in as_completed(futures, timeout=FETCH_HARD_TIMEOUT):
done_cnt += 1
code6, rows, latest = future.result()
code6 = futures[future]
try:
code6_r, rows, latest, is_fallback = future.result(timeout=0.1)
except FuturesTimeoutError:
with ok_lock:
fail_cnt[0] += 1
logger.warning(f"[kline {code6}] 内部 race timeout, 记 fail")
continue
except Exception as e:
with ok_lock:
fail_cnt[0] += 1
logger.warning(f"[kline {code6}] 异常: {e}")
continue
if rows is None:
with ok_lock:
fail_cnt[0] += 1
logger.warning(f"[kline {code6}] {latest}")
continue
if is_fallback:
with ok_lock:
fallback_ok_cnt[0] += 1
with batch_lock:
batch_rows.extend(rows)
pending_codes[code6] = latest
@@ -194,32 +227,121 @@ class SyncKlineDaily(SyncTask):
message=f"进度 {done_cnt}/{total} OK:{ok_cnt[0]} FAIL:{fail_cnt[0]} {rate:.1f}只/秒",
current=done_cnt, total=total, current_step=code6,
)
except FuturesTimeoutError:
# 30s 内没 future 完成 → 所有 worker 都被卡死(数据源层 timeout 兜底失败)
# 取消 pending futures,标 fail。shutdown(wait=False) 让主线程立刻返回,
# 不被卡住的 worker 拖死。
# 2026-07-11 修复: 防御性 cancel,避免异常处理链里变量被遮蔽导致
# AttributeError: 'str' object has no attribute 'cancel'。
stuck = [(f, c6) for f, c6 in futures.items() if not f.done()]
logger.error(
"所有 fetcher 卡死 (>%ss), %d 只股票未完成, 标 fail 后退出主循环",
FETCH_HARD_TIMEOUT, len(stuck),
)
with ok_lock:
fail_cnt[0] += len(stuck)
for f, c6 in stuck:
try:
if hasattr(f, "cancel"):
f.cancel()
except Exception as cancel_err:
logger.warning(f"[kline] cancel future for {c6} 失败: {cancel_err}")
try:
pool.shutdown(wait=False)
except Exception as shutdown_err:
logger.warning(f"[kline] pool.shutdown(wait=False) 失败: {shutdown_err}")
# ── 补偿:冷却后重入队列 ──
# 雪球/mairui 偶发全 hang(服务端限流/网络抖动),等 30s 再试一次。
# 单线程拉取场景下,卡住通常意味着当前这只股票请求 hang 死;
# 冷却后把未完成的股票重新排队重试,救回临时故障。
if stuck:
cooldown = 30
logger.warning(f"[kline] 冷却 {cooldown}s 后重试 {len(stuck)} 只…")
time.sleep(cooldown)
retry_ok = 0
retry_fb = 0
retry_rows = 0
retry_pool = ThreadPoolExecutor(max_workers=max_workers)
jobs_dict = {c6: fs for c6, fs in jobs}
retry_futs = {}
for _, c6 in stuck:
if c6 in jobs_dict:
retry_futs[retry_pool.submit(_fetch_one, c6, jobs_dict[c6])] = c6
if retry_futs:
try:
for fut in as_completed(retry_futs, timeout=FETCH_HARD_TIMEOUT):
c6 = retry_futs[fut]
try:
code6_r, rows, latest, is_fallback = fut.result(timeout=0.1)
except Exception:
logger.warning(f"[kline {c6}] 重试异常")
continue
if rows is None:
logger.warning(f"[kline {c6}] 重试仍失败: {latest}")
continue
with batch_lock:
batch_rows.extend(rows)
pending_codes[c6] = latest
cur_size = len(batch_rows)
with ok_lock:
retry_ok += 1
retry_rows += len(rows)
if is_fallback:
retry_fb += 1
if cur_size >= BATCH_SIZE:
_flush()
except FuturesTimeoutError:
logger.error(
"[kline] 重试仍卡死 (>%ss), %d 只股票放弃",
FETCH_HARD_TIMEOUT, sum(1 for f in retry_futs if not f.done()),
)
try:
retry_pool.shutdown(wait=False)
except Exception:
pass
with ok_lock:
ok_cnt[0] += retry_ok
fail_cnt[0] -= retry_ok
fallback_ok_cnt[0] += retry_fb
rows_cnt[0] += retry_rows
logger.warning(
f"[kline] 重试结果: {retry_ok}"
f"({retry_fb}只备胎) 共{retry_rows}行, 救回 {retry_ok}"
)
else:
pool.shutdown(wait=True)
# 收尾 flush
_flush()
elapsed = round(time.time() - t0, 1)
msg = f"日K线 {ok_cnt[0]}{fail_cnt[0]}{skipped}跳 共{rows_cnt[0]}行, {elapsed}s"
fb = fallback_ok_cnt[0]
fb_info = f" ({fb}只备胎)" if fb else ""
msg = f"日K线 {ok_cnt[0]}{fail_cnt[0]}{skipped}跳 共{rows_cnt[0]}{fb_info}, {elapsed}s"
return {
"status": "ok" if fail_cnt[0] == 0 else "warning",
"message": msg,
"ok": ok_cnt[0],
"fail": fail_cnt[0],
"fallback_ok": fb,
"skip": skipped,
"rows": rows_cnt[0],
"elapsed_sec": elapsed,
"primary_source": primary.key,
"primary_source": sources[0].key if sources else "",
"sources": [s.key for s in sources],
}
def _pick_primary(self):
def _get_available_sources(self):
from app.core.datasource.registry import is_source_ready, run_health_check, get_health_status
if not get_health_status():
run_health_check()
available = []
for key in PRIMARY_PRIORITY:
ok, _ = is_source_ready(key)
if ok:
return ds_registry.get(key)
return None
available.append(ds_registry.get(key))
return available
def _sync_one(self, primary, code6: str, start: str, end: str) -> dict:
"""保留这个方法以兼容外部调用(已不用,但单只测试可能用到)"""
+26 -12
View File
@@ -1,6 +1,6 @@
"""同步任务:六大指数日 K 线。
数据流:新浪指数日K接口 → 写 kline_index + indices 表。"""
数据流:QMT Bridge(主)→ 麦蕊 → 新浪 逐级 fallback → 写 kline_index + indices 表。"""
from __future__ import annotations
from datetime import datetime
@@ -24,32 +24,46 @@ MAJOR_INDICES = {
"000852": {"name": "中证1000", "market": "CN", "category": "broad_market"},
}
# 数据源优先级:qmt_bridge(免限速免凭证)→ mairui → sina
SOURCE_PRIORITY = [
("datasource_qmt_bridge", True, "qmt_bridge_index_daily"),
("datasource_mairui", True, "mairui_index_daily"),
("datasource_xinlang", False, "sina_index_daily"),
]
class SyncKlineIndex(SyncTask):
dataset_id = "kline_index"
def _run(self, *, trigger_source: str = "manual", **kwargs) -> dict[str, Any]:
# 优先 mairui(指数无单日空窗问题),sina 作为 fallback
primary = ds_registry.get("datasource_mairui")
use_mairui = primary is not None and is_source_ready(primary.key)[0]
if not use_mairui:
primary = ds_registry.get("datasource_xinlang")
# 优先级选择第一个就绪的数据源
primary = None
use_suffix = True
source_label = ""
for key, suffix, label in SOURCE_PRIORITY:
src = ds_registry.get(key)
if src is not None and is_source_ready(src.key)[0]:
primary = src
use_suffix = suffix
source_label = label
break
if primary is None:
return {"status": "error", "message": "指数数据源均不可用(mairui + 新浪)"}
return {"status": "error", "message": "指数数据源均不可用(qmt_bridge + mairui + 新浪)"}
ok, reason = is_source_ready(primary.key)
if not ok:
mark_sync_blocked(self.dataset_id, message=f"{primary.key} 未就绪: {reason}")
return {"status": "blocked", "message": f"{primary.key} 未就绪: {reason}"}
self._progress(message=f"开始同步 {len(MAJOR_INDICES)} 个指数...")
self._progress(message=f"开始同步 {len(MAJOR_INDICES)} 个指数,数据源: {source_label}...")
total = len(MAJOR_INDICES)
ok_cnt = fail_cnt = 0
results = {}
for i, (code, info) in enumerate(MAJOR_INDICES.items(), 1):
try:
# mairui 需带交易所后缀(如 000300.SH),sina 用纯6位
if use_mairui:
# qmt_bridge / mairui 需带交易所后缀(如 000300.SH),sina 用纯6位
if use_suffix:
# 000xxx 上证 → .SH399xxx 深证 → .SZ
if code.startswith("399"):
suffix = ".SZ"
@@ -82,7 +96,7 @@ class SyncKlineIndex(SyncTask):
db_ops.upsert_index(
index_code=code, index_name=info["name"],
market=info["market"], category=info["category"],
source="mairui_index_daily" if use_mairui else "sina_index_daily", enabled=True,
source=source_label, enabled=True,
)
ok_cnt += 1
results[code] = {
@@ -100,7 +114,7 @@ class SyncKlineIndex(SyncTask):
current=i, total=total, current_step=code,
)
msg = f"六大指数 {ok_cnt}{fail_cnt}"
msg = f"六大指数 {ok_cnt}{fail_cnt}{source_label}"
return {
"status": "ok" if fail_cnt == 0 else "warning",
"message": msg,
+261
View File
@@ -0,0 +1,261 @@
"""同步任务:mairui 日 K 级别技术指标 MACD / KDJ / BOLL。
数据源:mairui /hsstock/history/{indicator}/{symbol}/d/n/{licence}
- macd → market_data.kline_stock_macd_daily (diff, dea, macd, ema12, ema26)
- kdj → market_data.kline_stock_kdj_daily (k, d, j)
- boll → market_data.kline_stock_boll_daily (upper, mid, lower ← mairui u/m/d)
设计(对齐 task_kline_daily):
- 全市场 A 股循环,每只票分别拉 3 个指标
- 增量:读各指标表 MAX(trade_date),只补新增区间(回看 5 天防漏)
- 并发 fetch + 攒 BATCH 批量 upsert,写库串行加锁
- stock_code 落库用 hermes 格式(SH600519),与项目其他表一致
参考实现:pre_limit_seeker/production/sync_mairui_indicators.pyparquet 版)。
"""
from __future__ import annotations
import os
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
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.utils import is_a_share_code, to_code6, to_hermes
from app.core.sync.base import SyncTask, _effective_sync_end
from app.core.utils.logging import get_logger
logger = get_logger("sync.mairui_indicators")
DEFAULT_START = "2018-01-01"
BATCH_SIZE = 2000
# mairui 钻石档 100 RPS5 workers × 10 RPS/worker = 50 RPS,留 buffer
DEFAULT_FETCH_WORKERS = 5
# 指标 → (mairui 源字段, 落库列名, upsert 函数)
INDICATORS: dict[str, dict[str, Any]] = {
"macd": {
"src_fields": ["diff", "dea", "macd", "ema12", "ema26"],
"db_cols": ["diff", "dea", "macd", "ema12", "ema26"],
"upsert": db_ops.upsert_kline_stock_macd_daily_rows,
},
"kdj": {
"src_fields": ["k", "d", "j"],
"db_cols": ["k", "d", "j"],
"upsert": db_ops.upsert_kline_stock_kdj_daily_rows,
},
"boll": {
# mairui: u=上轨, m=中轨, d=下轨 → 落库 upper/mid/lower
"src_fields": ["u", "m", "d"],
"db_cols": ["upper", "mid", "lower"],
"upsert": db_ops.upsert_kline_stock_boll_daily_rows,
},
}
class SyncMairuiIndicators(SyncTask):
dataset_id = "mairui_indicators"
def _run(
self,
*,
trigger_source: str = "manual",
indicators: list[str] | None = None,
codes: list[str] | None = None,
start: str | None = None,
end: str | None = None,
incremental: bool = True,
max_workers: int = DEFAULT_FETCH_WORKERS,
**kwargs,
) -> dict[str, Any]:
targets = [i.lower() for i in (indicators or ["macd", "kdj", "boll"])]
for i in targets:
if i not in INDICATORS:
return {"status": "error", "message": f"未知指标 {i!r},可选 {list(INDICATORS)}"}
source = self._pick_source()
if source is None:
return {"status": "error", "message": "mairui 数据源不可用(licence 未配置?)"}
# 股票列表(hermes 格式)
if codes:
hermes_codes = [to_hermes(c) for c in codes]
else:
hermes_codes = [
to_hermes(c) for c in db_ops.iter_stock_codes(active_only=True)
if is_a_share_code(c)
]
limit_raw = os.environ.get("MARKET_DATA_STOCK_LIMIT", "").strip()
if limit_raw.isdigit() and int(limit_raw) > 0:
hermes_codes = hermes_codes[: int(limit_raw)]
if not hermes_codes:
return {"status": "error", "message": "无股票代码(stocks 表为空?)"}
_end = end or _effective_sync_end()
end_date_obj = datetime.strptime(_end, "%Y-%m-%d").date()
t0 = time.time()
agg_ok = 0
agg_fail = 0
agg_rows = 0
per_indicator: dict[str, dict[str, int]] = {}
for ind in targets:
self._progress(message=f"[{ind}] 规划增量区间...", current_step=ind)
jobs = self._plan_jobs(ind, hermes_codes, start, incremental, end_date_obj)
total = len(jobs)
skipped = len(hermes_codes) - total
logger.info(f"[mairui_indicators] {ind}: to_pull={total}, skip={skipped}")
self._progress(
message=f"[{ind}] 开始同步 {total} 只 (跳过 {skipped} 只已最新)",
current=0, total=total, current_step=ind,
)
ok, fail, rows = self._sync_indicator(
source, ind, jobs, _end, max_workers,
)
per_indicator[ind] = {"ok": ok, "fail": fail, "skip": skipped, "rows": rows}
agg_ok += ok
agg_fail += fail
agg_rows += rows
elapsed = round(time.time() - t0, 1)
parts = ", ".join(
f"{k}({v['ok']}成/{v['fail']}败/{v['rows']}行)"
for k, v in per_indicator.items()
)
msg = f"指标 {parts};共 {agg_rows} 行, {elapsed}s"
logger.info(f"[mairui_indicators] {msg}")
return {
"status": "ok" if agg_fail == 0 else "warning",
"message": msg,
"ok": agg_ok,
"fail": agg_fail,
"rows": agg_rows,
"elapsed_sec": elapsed,
"per_indicator": per_indicator,
}
def _plan_jobs(
self, indicator: str, hermes_codes: list[str],
start: str | None, incremental: bool, end_date_obj,
) -> list[tuple[str, str]]:
"""返回 [(hermes_code, fetch_start), ...]。"""
jobs: list[tuple[str, str]] = []
if incremental and not start:
for hc in hermes_codes:
last = db_ops.get_indicator_max_date(indicator, hc)
if last is None:
fetch_start = DEFAULT_START
elif datetime.strptime(last, "%Y-%m-%d").date() >= end_date_obj:
continue
else:
fetch_start = (
datetime.strptime(last, "%Y-%m-%d").date() - timedelta(days=5)
).strftime("%Y-%m-%d")
jobs.append((hc, fetch_start))
else:
fetch_start = start or DEFAULT_START
jobs = [(hc, fetch_start) for hc in hermes_codes]
return jobs
def _sync_indicator(
self, source, indicator: str, jobs: list[tuple[str, str]],
end: str, max_workers: int,
) -> tuple[int, int, int]:
cfg = INDICATORS[indicator]
upsert_fn = cfg["upsert"]
src_fields = cfg["src_fields"]
db_cols = cfg["db_cols"]
batch_lock = threading.Lock()
batch_rows: list[dict] = []
ok_lock = threading.Lock()
ok_cnt = [0]
fail_cnt = [0]
rows_cnt = [0]
def _flush():
with batch_lock:
if not batch_rows:
return
to_write = list(batch_rows)
batch_rows.clear()
try:
upsert_fn(to_write)
except Exception as e:
logger.error(f"[{indicator}] batch upsert 失败 ({len(to_write)} 行): {e}")
def _fetch_one(hermes_code: str, fetch_start: str):
code6 = to_code6(hermes_code)
try:
df = source.fetch_indicator_daily(indicator, code6, fetch_start, end)
except Exception as e:
return (hermes_code, None, str(e)[:120])
if df is None or df.empty:
return (hermes_code, None, "no data")
rows = []
for _, r in df.iterrows():
td = r["trade_date"]
# 2026-07-11 修复: mairui 可能返回整行 NaN/NaT(如 688 早期无指标),
# float(NaT) 会抛 TypeError。用 pandas.isna 统一跳过无效值。
row = {
"stock_code": hermes_code,
"trade_date": td.strftime("%Y-%m-%d") if hasattr(td, "strftime") else str(td)[:10],
"source": "mairui",
}
for src_f, db_c in zip(src_fields, db_cols):
v = r.get(src_f)
if v is None or (hasattr(v, "isna") and v.isna()) or (isinstance(v, float) and v != v):
row[db_c] = None
else:
try:
row[db_c] = float(v)
except Exception:
row[db_c] = None
rows.append(row)
return (hermes_code, rows, None)
total = len(jobs)
t0 = time.time()
with ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {pool.submit(_fetch_one, hc, fs): hc for hc, fs in jobs}
done = 0
for fut in as_completed(futures):
done += 1
hermes_code, rows, err = fut.result()
if rows is None:
with ok_lock:
fail_cnt[0] += 1
continue
with batch_lock:
batch_rows.extend(rows)
cur = len(batch_rows)
with ok_lock:
ok_cnt[0] += 1
rows_cnt[0] += len(rows)
if cur >= BATCH_SIZE:
_flush()
if done % 100 == 0 or done == total:
el = time.time() - t0
rate = done / el if el > 0 else 0
self._progress(
message=f"[{indicator}] {done}/{total} OK:{ok_cnt[0]} FAIL:{fail_cnt[0]} {rate:.1f}只/秒",
current=done, total=total, current_step=indicator,
)
_flush()
return ok_cnt[0], fail_cnt[0], rows_cnt[0]
def _pick_source(self):
from app.core.datasource.registry import (
get_health_status, is_source_ready, run_health_check,
)
if not get_health_status():
run_health_check()
ok, _ = is_source_ready("datasource_mairui")
if ok:
return ds_registry.get("datasource_mairui")
return None
+118
View File
@@ -0,0 +1,118 @@
"""同步任务:mairui 历史分时 MA(日 K 级别)。
需求来源:mairui.club/hsdata 提供分时 K + MA 端点(/hsstock/history/{code}.{ex}/d/maN/)。
本任务计算每只股票日 K 级别 MA5/10/20/60 指标,存入 market_data.kline_stock_ma_daily。
算法:
- 输入:market_data.kline_stock(已有 ~1170w 行日 K 线)
- 按 stock_code 分组,对 close 做 rolling(5/10/20/60).mean()
- 首部不足 N 天的行 → MA 留 NULL(不外推)
- 全量重算(数据量 5213 只 × 6288 天 ≈ 3300w 行,pandas 处理 ~30s
数据来源标注:
- 本地计算 → source='local_kline_proxy'
- mairui API(要付费 licence,当前不可用)→ 未来若升级可走 source='mairui'
"""
from __future__ import annotations
import time
from typing import Any
import pandas as pd
from sqlalchemy import create_engine
from app.core.config import settings
from app.core.db import ops as db_ops
from app.core.sync.base import SyncTask
from app.core.utils.logging import get_logger
logger = get_logger("sync.mairui_ma_daily")
class SyncMairuiMADaily(SyncTask):
dataset_id = "mairui_ma_daily"
# MA 窗口集合(标准日 K 级别常用 4 个)
MA_WINDOWS = [5, 10, 20, 60]
def _run(
self,
*,
trigger_source: str = "manual",
codes: list[str] | None = None,
**kwargs,
) -> dict[str, Any]:
t0 = time.time()
logger.info("[mairui_ma_daily] 启动日 K MA 计算")
# ── 1) 拉 kline_stock ──
engine = create_engine(settings.pg_sqlalchemy_url())
df = pd.read_sql(
'SELECT stock_code, trade_date, "close" '
'FROM market_data.kline_stock ORDER BY stock_code, trade_date',
engine,
)
if df.empty:
return {"status": "error", "message": "kline_stock 为空"}
# ── 2) 按 stock_code 分组算 rolling MA ──
df["stock_code"] = df["stock_code"].astype(str)
df["trade_date"] = pd.to_datetime(df["trade_date"], errors="coerce")
df = df.dropna(subset=["trade_date"])
# 只过滤指定 codes(可选,用于增量)
if codes:
df = df[df["stock_code"].isin(codes)]
# 关键步骤:每只股票独立 rolling
out_pieces = []
for stock_code, group in df.groupby("stock_code", sort=False):
g = group.sort_values("trade_date").copy()
for w in self.MA_WINDOWS:
g[f"ma{w}"] = g["close"].rolling(window=w, min_periods=w).mean()
out_pieces.append(g)
out = pd.concat(out_pieces, ignore_index=True)
logger.info(f"[mairui_ma_daily] 计算完成: {len(out):,} 行, "
f"{out['stock_code'].nunique()} 只, "
f"{out['trade_date'].min().date()} ~ {out['trade_date'].max().date()}")
# ── 3) 整理为 upsert 行 ──
out["trade_date"] = out["trade_date"].dt.strftime("%Y-%m-%d")
rows = []
for _, r in out.iterrows():
rows.append({
"stock_code": str(r["stock_code"]),
"trade_date": r["trade_date"],
"ma5": None if pd.isna(r["ma5"]) else float(r["ma5"]),
"ma10": None if pd.isna(r["ma10"]) else float(r["ma10"]),
"ma20": None if pd.isna(r["ma20"]) else float(r["ma20"]),
"ma60": None if pd.isna(r["ma60"]) else float(r["ma60"]),
"source": "local_kline_proxy",
})
# ── 4) 分批 upsert(每批 5000 行,避免 SQL 太长)──
BATCH = 5000
for i in range(0, len(rows), BATCH):
db_ops.upsert_kline_stock_ma_daily_rows(rows[i:i + BATCH])
if (i // BATCH) % 10 == 0:
self._progress(
message=f"upsert {i + BATCH}/{len(rows)}",
current=min(i + BATCH, len(rows)),
total=len(rows),
)
elapsed = round(time.time() - t0, 1)
msg = (
f"MA {self.MA_WINDOWS}{len(rows):,} 行, "
f"{out['stock_code'].nunique()} 只, {elapsed}s"
)
logger.info(f"[mairui_ma_daily] {msg}")
return {
"status": "ok",
"message": msg,
"rows": len(rows),
"stocks": int(out["stock_code"].nunique()),
"windows": self.MA_WINDOWS,
"elapsed_sec": elapsed,
}
+11 -3
View File
@@ -3,7 +3,6 @@
基于本地 kline_stock 数据聚合:advancers / decliners / turnover / advance_ratio。"""
from __future__ import annotations
from datetime import datetime
from typing import Any
import numpy as np
@@ -12,7 +11,7 @@ from sqlalchemy import text
from app.core.db import ops as db_ops
from app.core.db.orm import engine as pg_engine
from app.core.sync.base import SyncTask
from app.core.sync.base import SyncTask, _effective_sync_end
from app.core.utils.logging import get_logger
logger = get_logger("sync.market_regime")
@@ -34,7 +33,16 @@ class SyncMarketRegime(SyncTask):
dataset_id = "market_regime"
def _run(self, *, trigger_source: str = "manual", start: str = DEFAULT_START, **kwargs) -> dict[str, Any]:
end = datetime.now().strftime("%Y-%m-%d")
# 2026-07-12 修复: end 用 kline_stock 最大 trade_date,而不是 datetime.now()
# 避免节假日/周末跑时把非交易日当 end,导致 SQL 条件不精确
try:
with pg_engine.connect() as conn:
max_date = conn.execute(
text("SELECT MAX(trade_date) FROM market_data.kline_stock")
).scalar()
end = str(max_date) if max_date else _effective_sync_end()
except Exception:
end = _effective_sync_end()
self._progress(message=f"构建 market_regime {start} ~ {end}...")
# 1. 取所有非退市股票代码
-183
View File
@@ -1,183 +0,0 @@
"""同步任务:行业聚合特征(衍生计算)。
输入:kline_stock(日 K 线)+ industry(股票→行业映射)
输出:
- sector_indices :行业日线(trade_date × sector_name × close × sector_amplitude
- sector_features_daily :行业特征(+ sector_ret + ema10/20/200 + score
算法(参考 dashboard/api/services/sync/sector.py 重写,持久层改为 PG):
1) 每只股票日涨跌幅 pct_chg = (close/prev_close - 1) * 100
2) 按行业聚合 sector_ret = 该行业所有股票当日 pct_chg 的均值
3) 行业指数 close = 100 × ∏(1 + sector_ret/100) (基点 100,复合收益)
4) 行业振幅 sector_amplitude = 该行业所有股票当日 (high-low)/close 的均值
5) EMA10/20/200 = close 的指数移动平均(adjust=False
6) score: 0/1/2 — close>ema200 + 1ema10>ema20 + 1
无外部 API 调用,纯本地计算;增量逻辑以"全量重算"实现(数据量小 ~75K 行)。
"""
from __future__ import annotations
import time
from datetime import datetime, timedelta
from typing import Any
import pandas as pd
from sqlalchemy import create_engine
from app.core.config import settings
from app.core.db import ops as db_ops
from app.core.sync.base import SyncTask
from app.core.utils.logging import get_logger
logger = get_logger("sync.sector_features")
# 起算点:DB 里 kline_stock 最早一天 往前 5 年,或固定一个默认起点
# (不用写死 2021-04-24,让数据自己决定;下面自动算)
DEFAULT_START_YEARS_BACK = 5
# 输出基点(行业指数 close 从多少开始)
INDEX_BASE = 100.0
class SyncSectorFeatures(SyncTask):
dataset_id = "sector_features"
def _run(
self,
*,
trigger_source: str = "manual",
codes: list[str] | None = None,
max_workers: int = 1, # 本任务纯本地计算,单线程足够
**kwargs,
) -> dict[str, Any]:
t0 = time.time()
logger.info("[sector] 启动行业聚合特征计算")
# ── 1) 拉 kline_stock(日线)只取需要的列 ──
# 走 SQLAlchemy engine + PG URLpd.read_sql 一次性读 1.1kw 行 → pandas DataFrame
engine = create_engine(settings.pg_sqlalchemy_url())
df_kline = pd.read_sql(
'SELECT stock_code, trade_date, open, high, low, "close", volume '
'FROM market_data.kline_stock ORDER BY stock_code, trade_date',
engine,
)
if df_kline.empty:
return {"status": "error", "message": "kline_stock 为空"}
df_kline["stock_code"] = df_kline["stock_code"].astype(str).str.zfill(6)
df_kline["trade_date"] = pd.to_datetime(df_kline["trade_date"], errors="coerce")
logger.info(f"[sector] kline_stock: {len(df_kline):,} 行, {df_kline['stock_code'].nunique()} 只, "
f"{df_kline['trade_date'].min().date()} ~ {df_kline['trade_date'].max().date()}")
# ── 2) 拉 industry(股票→行业映射)──
df_ind = pd.read_sql(
"SELECT code, industry_name FROM market_data.industry WHERE industry_name IS NOT NULL",
engine,
)
if df_ind.empty:
return {"status": "error", "message": "industry 表为空"}
df_ind["code"] = df_ind["code"].astype(str).str.zfill(6)
df_ind = df_ind.drop_duplicates(subset=["code"], keep="first")
logger.info(f"[sector] industry 映射: {len(df_ind):,} 行, {df_ind['industry_name'].nunique()} 个行业")
# ── 3) 算每只股票日涨跌幅 + 振幅 ──
df = df_kline.merge(df_ind, left_on="stock_code", right_on="code", how="inner")
df = df.sort_values(["stock_code", "trade_date"])
df["prev_close"] = df.groupby("stock_code")["close"].shift(1)
df["stock_amplitude"] = (df["high"] - df["low"]) / df["close"]
df = df.dropna(subset=["prev_close", "industry_name"])
df["pct_chg"] = (df["close"] / df["prev_close"] - 1.0) * 100.0
logger.info(f"[sector] 合并后: {len(df):,} 行, 行业 {df['industry_name'].nunique()}")
# ── 4) 按行业 + 日期聚合 ──
sector_daily = (
df.groupby(["industry_name", "trade_date"], as_index=False).agg(
sector_ret=("pct_chg", "mean"),
sector_amplitude=("stock_amplitude", "mean"),
)
.rename(columns={"industry_name": "sector_name"})
.sort_values(["sector_name", "trade_date"])
.reset_index(drop=True)
)
logger.info(f"[sector] 行业日聚合: {len(sector_daily):,}")
# ── 5) 算行业指数 close(基点 100,复合)──
sector_index = self._build_index(sector_daily)
# ── 6) EMA + score ──
sector_index = self._calc_ema(sector_index)
sector_index["score"] = sector_index.apply(
lambda r: self._calc_score(r["close"], r["ema10"], r["ema20"], r["ema200"]),
axis=1,
)
# 整理列名
out_cols = ["trade_date", "sector_name", "sector_ret", "sector_amplitude",
"close", "ema10", "ema20", "ema200", "score"]
sector_index = sector_index[out_cols]
sector_index["trade_date"] = sector_index["trade_date"].dt.strftime("%Y-%m-%d")
# NaN → None(让 MySQL 接受 NULL
sector_index = sector_index.where(pd.notnull(sector_index), None)
# ── 7) 写入两张表 ──
# 7a. sector_indices (4 列)
si_rows = sector_index[["trade_date", "sector_name", "close", "sector_amplitude"]].to_dict("records")
db_ops.replace_all_sector_indices(si_rows)
# 7b. sector_features_daily (9 列)
sf_rows = sector_index.to_dict("records")
db_ops.replace_all_sector_features(sf_rows)
elapsed = round(time.time() - t0, 1)
msg = (
f"行业聚合 {sector_index['sector_name'].nunique()} 个行业 "
f"× {sector_index['trade_date'].nunique()} 天, "
f"{len(sector_index):,} 行, {elapsed}s"
)
logger.info(f"[sector] {msg}")
return {
"status": "ok",
"message": msg,
"sectors": int(sector_index["sector_name"].nunique()),
"days": int(sector_index["trade_date"].nunique()),
"rows": int(len(sector_index)),
"elapsed_sec": elapsed,
}
@staticmethod
def _build_index(sector_daily: pd.DataFrame) -> pd.DataFrame:
"""行业指数 close = INDEX_BASE × ∏(1 + sector_ret/100)"""
out = []
for sector_name, group in sector_daily.groupby("sector_name", sort=False):
g = group.sort_values("trade_date").copy()
close_vals = []
cur_base = INDEX_BASE
for ret in g["sector_ret"].to_numpy(dtype=float):
if pd.isna(ret):
close_vals.append(cur_base)
else:
cur_base = cur_base * (1 + float(ret) / 100.0)
close_vals.append(cur_base)
g["close"] = close_vals
out.append(g)
return pd.concat(out, ignore_index=True)
@staticmethod
def _calc_ema(sector_index: pd.DataFrame) -> pd.DataFrame:
"""每个行业分别算 EMA10/20/200"""
out = []
for sector_name, group in sector_index.groupby("sector_name", sort=False):
g = group.sort_values("trade_date").copy()
g["ema10"] = g["close"].ewm(span=10, adjust=False).mean()
g["ema20"] = g["close"].ewm(span=20, adjust=False).mean()
g["ema200"] = g["close"].ewm(span=200, adjust=False).mean()
out.append(g)
return pd.concat(out, ignore_index=True)
@staticmethod
def _calc_score(close, ema10, ema20, ema200) -> int:
score = 0
if pd.notna(close) and pd.notna(ema200) and close > ema200:
score += 1
if pd.notna(ema10) and pd.notna(ema20) and ema10 > ema20:
score += 1
return int(score)
+11 -2
View File
@@ -91,16 +91,25 @@ class SyncShareSnapshot(SyncTask):
# 与 stocks.code 保持一致,方便 JOIN。
# 历史 bug:之前 share 表写的是裸 6 位 c6,导致 share JOIN stocks 失败。
# 一次性 UPDATE2026-07-01)已把存量 6.3 万行加上前缀。
# trade_date 必须用源数据返回的交易日(数据源层负责"最近已完成交易日"
# 兜底),不允许用 today 兜底 — 7月9日 早盘跑任务时 today=7月9日,
# 但股本快照实际对应 7月8日 收盘。早期代码用 today 会让 share 表里
# 同一股本被重复写多行,统计/回溯出错。
trade_date = r.get("trade_date")
if not trade_date:
fail_cnt += 1
logger.warning(f"[share {c6}] 源数据未返回 trade_date,跳过(避免用运行日兜底)")
continue
hermes = self._to_hermes(c6)
db_ops.update_stock_share_snapshot(
code=hermes,
total_share=r["total_share"],
float_share=r["float_share"],
trade_date=r.get("trade_date", today),
trade_date=trade_date,
)
rows_to_share_table.append({
"stock_code": hermes,
"trade_date": r.get("trade_date", today),
"trade_date": trade_date,
"total_share": r["total_share"],
"float_share": r["float_share"],
})
+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
+9 -2
View File
@@ -104,7 +104,6 @@ class SyncStocksBasic(SyncTask):
exchange=old.get("exchange", ""),
list_date=old.get("list_date", ""),
listing_status="delisted",
industry=old.get("industry", ""),
)
delisted += 1
@@ -197,11 +196,19 @@ class SyncStocksBasic(SyncTask):
if r is None:
fail += 1
else:
# trade_date 必须是源数据返回的交易日(数据源层用 effective_market_date
# 兜底为"最近已完成交易日"),绝不允许用 today 兜底 — 7月9日 早盘跑
# 时 today=7月9日,但股本快照实际对应 7月8日 收盘。
trade_date = r.get("trade_date")
if not trade_date:
fail += 1
logger.warning(f"[share {c6}] 源数据未返回 trade_date,跳过(避免用运行日兜底)")
continue
db_ops.update_stock_share_snapshot(
code=self._to_hermes(c6),
total_share=r["total_share"],
float_share=r["float_share"],
trade_date=r.get("trade_date", today),
trade_date=trade_date,
)
ok += 1
if done % 100 == 0:
+44 -12
View File
@@ -12,12 +12,15 @@
trade_date 可能比 wall-clock 早一天;重跑靠 PK + ON CONFLICT 幂等
4) 单只股票日均 5w-10w tickCHUNK=2000 upsert 防 driver 撑爆
5) 数据是"当天 only",无 start/end 窗口;不存增量概念
6) 2026-07-13 加固: 加上外层 FETCH_HARD_TIMEOUT,避免 mairui 挂起时
as_completed 无 timeout 导致整个 task 永久卡住(systemd 2h 超时杀)。
改用手动 pool 管理 + shutdown(wait=False),与 kline_daily 对齐。
"""
from __future__ import annotations
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeoutError
from datetime import datetime
from typing import Any
@@ -36,6 +39,10 @@ MAIRUI_TICK_TRADE_PUBLISH_HOUR = 21
MAIRUI_TICK_TRADE_PUBLISH_MIN = 0
# 21:00 之后再跑(留 5 分钟缓冲,等 mairui 完整入库)
TICK_TRADE_RUN_GATE = (MAIRUI_TICK_TRADE_PUBLISH_HOUR, MAIRUI_TICK_TRADE_PUBLISH_MIN + 5)
# 外层超时: mairui _fetch 有 20s timeout,但 as_completed 无 timeout 的话
# 若所有 worker 同时被 mairui 挂起(罕见),task 会永久卡住等 systemd 2h 杀。
# 120s 内无任何 future 完成 → 判全部失败退出。
FETCH_HARD_TIMEOUT = 120.0
def _passes_publish_gate(now: Optional[datetime] = None, *, force: bool = False) -> tuple[bool, str]:
@@ -108,15 +115,18 @@ class SyncTickTrade(SyncTask):
)
# ── 执行 ──
# 不用 `with ThreadPoolExecutor` — 与外层 as_completed timeout 配合,
# 超时后 `with` 的 shutdown(wait=True) 会阻塞等卡死线程 → 进程挂死(同 kline_daily 教训)。
t0 = time.time()
ok_cnt = fail_cnt = 0
rows_total = 0
with ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {pool.submit(self._sync_one, mr, c6): c6 for c6 in stock_codes}
for i, future in enumerate(as_completed(futures), 1):
pool = ThreadPoolExecutor(max_workers=max_workers)
futures = {pool.submit(self._sync_one, mr, c6): c6 for c6 in stock_codes}
try:
for i, future in enumerate(as_completed(futures, timeout=FETCH_HARD_TIMEOUT), 1):
c6 = futures[future]
try:
res = future.result()
res = future.result(timeout=0.1)
if res["status"] == "ok":
ok_cnt += 1
rows_total += res["rows"]
@@ -124,6 +134,9 @@ class SyncTickTrade(SyncTask):
fail_cnt += 1
if res.get("error"):
logger.warning(f"[tick_trade {c6}] {res['error']}")
except FuturesTimeoutError:
fail_cnt += 1
logger.warning(f"[tick_trade {c6}] 内部 race timeout, 记 fail")
except Exception as e:
fail_cnt += 1
logger.warning(f"[tick_trade {c6}] {e}")
@@ -132,6 +145,25 @@ class SyncTickTrade(SyncTask):
message=f"tick_trade 进度 {i}/{total} OK:{ok_cnt} FAIL:{fail_cnt} 行:{rows_total}",
current=i, total=total, current_step=c6,
)
except FuturesTimeoutError:
stuck = [(f, c6) for f, c6 in futures.items() if not f.done()]
logger.error(
"[tick_trade] 所有 fetcher 卡死 (>%ss), %d 只股票未完成, 标 fail 后退出",
FETCH_HARD_TIMEOUT, len(stuck),
)
fail_cnt += len(stuck)
for f, c6 in stuck:
try:
if hasattr(f, "cancel"):
f.cancel()
except Exception as e:
logger.warning(f"[tick_trade] cancel future for {c6} 失败: {e}")
try:
pool.shutdown(wait=False)
except Exception as e:
logger.warning(f"[tick_trade] pool.shutdown(wait=False) 失败: {e}")
else:
pool.shutdown(wait=True)
elapsed = round(time.time() - t0, 1)
msg = f"逐笔 {ok_cnt}{fail_cnt}败 共{rows_total}行, {elapsed}s"
return {
@@ -148,20 +180,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:
+122
View File
@@ -0,0 +1,122 @@
"""清理 dataset_registry 中已废弃的 MySQL/SQLite-era 旧 task 行。
⚠️ 一次性脚本(2026-07-03):PG 迁移后 6 个旧 task 行 enabled=0 但未删除,
长期占位把 daily_check 报告撑到 18 行(噪音 1/3)。
清理策略(**保守** — 只删明确孤儿):
1. enabled=0
2. storage_layer IN ('sqlite', 'parquet', 'mysql', '') ← 新 PG-only 体系只有 'pg'
3. sync_script 路径不在 app.tasks 下(即非 app.tasks.task_xxx:run 形式)
执行:
.venv/bin/python -m bin.archive.clean_legacy_registry_rows --dry-run
.venv/bin/python -m bin.archive.clean_legacy_registry_rows # 真删
安全:
- 脚本默认 --dry-run,必须显式 --apply 才会 DELETE
- 删之前 dump 被删行的 dataset_id / name / storage_uri / sync_script 到 stdout
- 事务:先 SELECT WHERE 命中 → 再 DELETE 同 WHERE,单次 commit;失败全回滚
不在清理范围(保留):
- enabled=0 但 storage_layer='pg' 的行(可能临时禁用,不是孤儿)
- enabled=1 的所有行(哪怕 sync_script 路径奇怪)
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from typing import Any
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
from sqlalchemy import delete, select # noqa: E402
from app.core.db import ops as db_ops # noqa: E402
from app.core.db.models import DatasetRegistry # noqa: E402
# 新 PG-only 体系下"合法" task 的 sync_script 路径前缀
_NEW_SCRIPT_PREFIX = "app.tasks."
# 旧 storage_layer 值(PG 迁移前用过)
_LEGACY_STORAGE_LAYERS = {"sqlite", "parquet", "mysql", ""}
def find_legacy_rows() -> list[dict[str, Any]]:
"""找出所有 enabled=0 且 storage_layer 旧 或 sync_script 不在新体系下的行。"""
db_ops._ensure_schema() # noqa: SLF001
with db_ops.get_session() as s:
all_rows = s.execute(
select(DatasetRegistry).order_by(DatasetRegistry.sort_order)
).scalars().all()
legacy: list[dict[str, Any]] = []
for r in all_rows:
if (r.enabled or 0) != 0:
continue
storage_layer = (r.storage_layer or "").lower()
script = (r.sync_script or "").strip()
# 命中条件 1: storage_layer 是旧值
hit_storage = storage_layer in _LEGACY_STORAGE_LAYERS
# 命中条件 2: sync_script 不在新体系下
hit_script = not script.startswith(_NEW_SCRIPT_PREFIX)
if hit_storage or hit_script:
legacy.append({
"dataset_id": r.dataset_id,
"name": r.name or "",
"storage_layer": storage_layer,
"storage_uri": r.storage_uri or "",
"sync_script": script,
"sort_order": r.sort_order,
"status": r.status or "idle",
})
return legacy
def delete_legacy_rows(rows: list[dict[str, Any]]) -> int:
"""事务里 DELETE 同 WHERE;返回删除行数。"""
if not rows:
return 0
ids = [r["dataset_id"] for r in rows]
db_ops._ensure_schema() # noqa: SLF001
with db_ops.get_session() as s:
result = s.execute(
delete(DatasetRegistry).where(DatasetRegistry.dataset_id.in_(ids))
)
s.commit()
return result.rowcount or 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="清理 dataset_registry 中废弃的旧 task 行")
parser.add_argument("--apply", action="store_true", help="真删(默认 dry-run")
args = parser.parse_args(argv)
rows = find_legacy_rows()
print(f"扫描到 {len(rows)} 行废弃行:")
for r in rows:
print(
f" - dataset_id={r['dataset_id']:<20} "
f"storage_layer={r['storage_layer']:<8} "
f"sync_script={r['sync_script']!r}"
)
if not args.apply:
print(f"\n[DRY-RUN] 不执行 DELETE。确认无误后加 --apply 重跑。")
return 0
if not rows:
print("没有可清理的行。")
return 0
n = delete_legacy_rows(rows)
print(f"\n✅ 已删除 {n} 行。")
return 0
if __name__ == "__main__":
sys.exit(main())
+409
View File
@@ -0,0 +1,409 @@
#!/usr/bin/env python3
"""历史股本回填脚本。
目标:把 2023-01-01 ~ 2026-07-15 期间每只 A 股的股本,按“每周一条”
写入 market_data.share 表。
数据源:
1. akshare stock_share_change_cninfo(巨潮资讯-公司股本变动)
覆盖 2023 及更早的历史股本变动记录,单位:万股。
2. 现有 market_data.share 快照(2026 年雪球 quote_detail 已写入)
作为 2024 年底之后最新股本的补充。
策略:
- 每周五作为本周代表日(若周五休市则仍取周五,因为股本变动是事件日,
不依赖当天是否交易;最后一周如果结束日不是周五,额外补一个结束日)。
- 对每只股票,把历史股本变动 + 现有快照合并成一条时间线,按日期前向填充,
得到每个采样日的 total_share / float_share。
- 最终批量 upsert 到 share 表。
- 默认使用巨潮“总股本 / 已流通股份”口径;加 --a-share-only 时只取 A 股部分
total_share = 人民币普通股,float_share = 人民币普通股 - 流通受限股份),
与雪球 quote_detail 的 float_shares 口径更一致。
用法:
.venv/bin/python bin/backfill_share_history.py
.venv/bin/python bin/backfill_share_history.py --dry-run --limit 50
.venv/bin/python bin/backfill_share_history.py --max-workers 10 --start 2023-01-01 --end 2026-07-15
.venv/bin/python bin/backfill_share_history.py --a-share-only
"""
from __future__ import annotations
import argparse
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import date, datetime, timedelta
from typing import Any, Optional
import pandas as pd
from sqlalchemy import text
# 项目根目录导入
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core.db import ops as db_ops
from app.core.db.orm import get_session
from app.core.datasource.utils import is_a_share_code, to_hermes
from app.core.utils.logging import get_logger
logger = get_logger("backfill_share_history")
# 默认回填区间
DEFAULT_START = "2023-01-01"
DEFAULT_END = "2026-07-15"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="回填历史股本到 market_data.share")
parser.add_argument("--start", default=DEFAULT_START, help="开始日期 (YYYY-MM-DD)")
parser.add_argument("--end", default=DEFAULT_END, help="结束日期 (YYYY-MM-DD)")
parser.add_argument("--max-workers", type=int, default=5, help="并发 worker 数")
parser.add_argument("--limit", type=int, default=0, help="仅处理前 N 只股票(测试用)")
parser.add_argument(
"--codes",
default="",
help="指定股票代码,逗号分隔(如 000001,600519);空则处理全市场",
)
parser.add_argument("--dry-run", action="store_true", help="只统计不写入数据库")
parser.add_argument(
"--a-share-only",
action="store_true",
help="只取 A 股股本口径(去掉 H 股 / B 股)",
)
parser.add_argument(
"--upsert-chunk",
type=int,
default=2000,
help="每次 upsert 行数",
)
return parser.parse_args()
def load_stock_codes(limit: int = 0, codes_str: str = "") -> list[str]:
"""返回 6 位代码列表(已过滤 A 股)。"""
if codes_str:
raw = [c.strip() for c in codes_str.split(",") if c.strip()]
return [c for c in raw if is_a_share_code(c)]
codes = [
c
for c in db_ops.iter_stock_codes(active_only=True)
if is_a_share_code(c)
]
if limit > 0:
codes = codes[:limit]
return codes
def load_existing_share_snapshots(code6s: list[str]) -> dict[str, dict[str, float]]:
"""读取每只股票的最新 share 快照( trade_date -> total_share/float_share )。
返回:{code6: {trade_date_str: {"total_share": float, "float_share": float}}}
"""
if not code6s:
return {}
hermes_codes = [to_hermes(c) for c in code6s]
out: dict[str, dict[str, dict[str, float]]] = {c: {} for c in code6s}
# 按 hermes 前缀分组查询,避免 SQL IN 子句过长
batch_size = 500
for i in range(0, len(hermes_codes), batch_size):
batch = hermes_codes[i : i + batch_size]
placeholders = ",".join([f"'{c}'" for c in batch])
sql = f"""
SELECT stock_code, trade_date, total_share, float_share
FROM market_data.share
WHERE stock_code IN ({placeholders})
AND trade_date >= '2023-01-01'
ORDER BY stock_code, trade_date
"""
with get_session() as s:
rows = s.execute(text(sql)).fetchall()
for r in rows:
hermes = r[0]
code6 = hermes[2:] if hermes[:2] in ("SH", "SZ", "BJ") else hermes
if code6 not in out:
out[code6] = {}
out[code6][r[1].strftime("%Y-%m-%d")] = {
"total_share": float(r[2] or 0),
"float_share": float(r[3] or 0),
}
return out
def fetch_cninfo_changes(code6: str, a_share_only: bool = False) -> pd.DataFrame:
"""调用 akshare 巨潮接口,返回标准化后的股本变动 DataFrame。
列:change_date, total_share, float_share(单位:亿股)
Args:
a_share_only: True 时只取 A 股口径:
total_share = 人民币普通股(缺失时用 总股本 - H股 - B股)
float_share = 人民币普通股 - 流通受限股份
"""
try:
import akshare as ak
df = ak.stock_share_change_cninfo(symbol=code6)
except Exception as e:
logger.warning("[%s] 巨潮接口调用失败: %s", code6, e)
return pd.DataFrame()
if df is None or df.empty:
return pd.DataFrame()
# 只保留我们需要的列
if a_share_only:
rename_map = {
"变动日期": "change_date",
"总股本": "total_share_all",
"人民币普通股": "a_share_total",
"流通受限股份": "restricted",
"境外上市外资股-H股": "h_share",
"境内上市外资股-B股": "b_share",
}
else:
rename_map = {
"变动日期": "change_date",
"总股本": "total_share",
"已流通股份": "float_share",
}
missing = [k for k in rename_map if k not in df.columns]
if missing:
logger.warning("[%s] 巨潮返回缺少字段: %s", code6, missing)
return pd.DataFrame()
df = df[list(rename_map.keys())].rename(columns=rename_map)
df["change_date"] = pd.to_datetime(df["change_date"], errors="coerce")
for col in rename_map.values():
if col != "change_date":
df[col] = pd.to_numeric(df[col], errors="coerce")
# 万股 -> 亿股
for col in rename_map.values():
if col != "change_date":
df[col] = df[col] / 10000.0
if a_share_only:
# A 股总股本:优先用“人民币普通股”,缺失时从总股本中扣除 H 股 / B 股
a_total = df["a_share_total"].where(
df["a_share_total"].notna(),
df["total_share_all"] - df["h_share"].fillna(0) - df["b_share"].fillna(0),
)
# A 股流通股本:A 股总股本 - 流通受限股份
a_float = a_total - df["restricted"].fillna(0)
df["total_share"] = a_total.round(4)
df["float_share"] = a_float.round(4)
df = df.dropna(subset=["change_date", "total_share", "float_share"])
if df.empty:
return pd.DataFrame()
# 剔除异常:A 股流通股本不能为负,也不能超过 A 股总股本
df = df[df["float_share"] >= 0]
df = df[df["float_share"] <= df["total_share"] * 1.001]
if df.empty:
return pd.DataFrame()
df = df[["change_date", "total_share", "float_share"]].sort_values("change_date").reset_index(drop=True)
return df
def build_weekly_snapshots(
code6: str,
cninfo_df: pd.DataFrame,
existing: dict[str, dict[str, float]],
sample_dates: list[date],
) -> list[dict[str, Any]]:
"""合并历史变动 + 现有快照,生成该股票每周股本记录。"""
# 构建时间线:change_date -> 最新股本
timeline: dict[str, dict[str, float]] = {}
# 1) 巨潮历史变动
for _, row in cninfo_df.iterrows():
d = row["change_date"].strftime("%Y-%m-%d")
timeline[d] = {
"total_share": float(row["total_share"]),
"float_share": float(row["float_share"]),
}
# 2) 现有 share 快照(仅用于补充巨潮未覆盖的 2025 年及之后最新日期,
# 避免旧口径数据覆盖 2024 及更早的历史回填)
if existing:
for d, v in existing.items():
if d >= "2025-01-01":
timeline[d] = v
if not timeline:
return []
# 按日期排序并做前向填充
dates = sorted(timeline.keys())
rows = []
for sd in sample_dates:
sd_str = sd.strftime("%Y-%m-%d")
# 找到 <= sd 的最新一条
latest = None
for d in dates:
if d > sd_str:
break
latest = d
if latest is None:
continue
v = timeline[latest]
if v["total_share"] <= 0 or v["float_share"] <= 0:
continue
rows.append(
{
"stock_code": to_hermes(code6),
"trade_date": sd_str,
"total_share": v["total_share"],
"float_share": v["float_share"],
}
)
return rows
def generate_sample_dates(start: str, end: str) -> list[date]:
"""生成每周五采样日,若结束日不是周五则额外包含结束日。"""
start_dt = datetime.strptime(start, "%Y-%m-%d").date()
end_dt = datetime.strptime(end, "%Y-%m-%d").date()
# pandas 每周五
fridays = pd.date_range(start=start, end=end, freq="W-FRI")
dates = [d.date() for d in fridays]
# 若结束日不是周五,补一个结束日本身
if end_dt not in dates:
dates.append(end_dt)
dates.sort()
return dates
def process_one_stock(
code6: str,
existing_map: dict[str, dict[str, float]],
sample_dates: list[date],
a_share_only: bool = False,
) -> list[dict[str, Any]]:
cninfo_df = fetch_cninfo_changes(code6, a_share_only=a_share_only)
existing = existing_map.get(code6, {})
return build_weekly_snapshots(code6, cninfo_df, existing, sample_dates)
def main() -> int:
args = parse_args()
logger.info("开始回填历史股本: %s ~ %s", args.start, args.end)
logger.info(
"参数: workers=%s, dry_run=%s, limit=%s, a_share_only=%s",
args.max_workers, args.dry_run, args.limit, args.a_share_only,
)
# 1. 股票代码
code6s = load_stock_codes(limit=args.limit, codes_str=args.codes)
if not code6s:
logger.error("无可用股票代码")
return 1
logger.info("%s 只股票待处理", len(code6s))
# 2. 采样日期
sample_dates = generate_sample_dates(args.start, args.end)
logger.info("采样日期: %s 个(%s ~ %s", len(sample_dates), sample_dates[0], sample_dates[-1])
# 3. 预读现有 share 快照
logger.info("预读现有 share 快照...")
t0 = time.time()
existing_map = load_existing_share_snapshots(code6s)
logger.info("预读完成,耗时 %.1fs", time.time() - t0)
# 4. 多线程拉取 + 合并
all_rows: list[dict[str, Any]] = []
ok = fail = 0
t0 = time.time()
with ThreadPoolExecutor(max_workers=args.max_workers) as pool:
futures = {
pool.submit(
process_one_stock, c, existing_map, sample_dates, args.a_share_only
): c
for c in code6s
}
for i, future in enumerate(as_completed(futures), 1):
c6 = futures[future]
try:
rows = future.result()
except Exception as e:
fail += 1
logger.warning("[%s] 处理异常: %s", c6, e)
continue
if rows:
all_rows.extend(rows)
ok += 1
else:
fail += 1
if i % 100 == 0 or i == len(code6s):
logger.info(
"进度 %s/%s, OK=%s, FAIL=%s, rows=%s",
i,
len(code6s),
ok,
fail,
len(all_rows),
)
elapsed = time.time() - t0
logger.info(
"拉取完成: OK=%s, FAIL=%s, 总条数=%s, 耗时=%.1fs",
ok,
fail,
len(all_rows),
elapsed,
)
if not all_rows:
logger.warning("无数据可写入")
return 0
# 5. 写入
if args.dry_run:
logger.info("DRY RUN: 本应写入 %s", len(all_rows))
# 打印几只样本(首尾各 3 行,方便看后段是否被现有快照覆盖)
df = pd.DataFrame(all_rows)
sample_codes = df["stock_code"].unique()[:3]
for sc in sample_codes:
sub = df[df["stock_code"] == sc]
preview = pd.concat([sub.head(3), sub.tail(3)])
logger.info("样本 %s:\n%s", sc, preview.to_string(index=False))
return 0
# 5. 写入前先清理目标区间内非采样日的旧记录,保证“每周一条”
keep_dates = {d.strftime("%Y-%m-%d") for d in sample_dates}
keep_str = ",".join([f"'{d}'" for d in keep_dates])
delete_sql = f"""
DELETE FROM market_data.share
WHERE trade_date BETWEEN '{args.start}' AND '{args.end}'
AND trade_date NOT IN ({keep_str})
"""
logger.info("清理非采样日旧记录...")
with get_session() as s:
deleted = s.execute(text(delete_sql)).rowcount
s.commit()
logger.info("清理完成: 删除 %s", deleted)
logger.info("开始写入 share 表,chunk=%s", args.upsert_chunk)
t0 = time.time()
total_written = 0
for i in range(0, len(all_rows), args.upsert_chunk):
chunk = all_rows[i : i + args.upsert_chunk]
total_written += db_ops.upsert_share(chunk)
logger.info("已写入 %s/%s", total_written, len(all_rows))
logger.info("写入完成: %s 行, 耗时 %.1fs", total_written, time.time() - t0)
return 0
if __name__ == "__main__":
sys.exit(main())
+659
View File
@@ -0,0 +1,659 @@
"""每日同步任务巡检。
读 PG `dataset_registry`,对比今日预期调度窗口,给出每个 task 的健康状态,
写 `logs/daily_check_YYYYMMDD_HHMMSS.json` 报告,仅在有失败/漏跑时 POST webhook。
用法:
.venv/bin/python bin/daily_sync_check.py # 默认检查"今天"
.venv/bin/python bin/daily_sync_check.py --dry-run # 只生成报告,不 POST
.venv/bin/python bin/daily_sync_check.py --report-only # 完全不读 secrets / 不 POST
.venv/bin/python bin/daily_sync_check.py --date 2026-07-02 # 检查指定日期
为什么是 stand-alone(不放在 app.tasks 下):
- 巡检逻辑独立于 sync 业务,混在 app.tasks 会让注册表臃肿
- 独立脚本便于 cron / systemd timer / /loop 三种触发方式共用
"""
from __future__ import annotations
import argparse
import hashlib
import hmac
import json
import logging
import os
import re
import sys
import time
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any, Optional
import requests
import sqlalchemy as sa
# ── 路径与日志 ─────────────────────────────────────────────────────────────
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
LOG_DIR = PROJECT_ROOT / "logs"
SECRETS_FILE = PROJECT_ROOT / "config" / "daily_check_secrets.env"
LOG = logging.getLogger("daily-sync-check")
# ── 运行模式 ───────────────────────────────────────────────────────────────
def _runtime_mode() -> str:
"""读取当前运行模式:docker | systemd。
优先读环境变量 RUNTIME_MODE,未设置时尝试从 .env 读取,默认 docker。
"""
mode = os.environ.get("RUNTIME_MODE", "")
if not mode:
env_file = PROJECT_ROOT / ".env"
if env_file.exists():
try:
for line in env_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith("RUNTIME_MODE="):
mode = line.split("=", 1)[1].strip().strip('"').strip("'")
break
except Exception:
pass
return (mode or "docker").lower()
# ── 预期调度表 ─────────────────────────────────────────────────────────────
# 与 [[market-data-overview]] 保持一致;同步触发点(HH:MM)按 Asia/Shanghai 解释。
# weekend_only=True 表示只在周六/周日期望运行(实际仅 stock_node 周六一次)。
SCHEDULE: dict[str, dict[str, Any]] = {
"stock_basic": {"window_end": "09:30", "weekend_only": False},
"kline_index": {"window_end": "15:35", "weekend_only": False},
"kline_daily": {"window_end": "15:45", "weekend_only": False},
"kline_5min": {"window_end": "16:05", "weekend_only": False},
"market_regime": {"window_end": "16:20", "weekend_only": False},
"share_snapshot": {"window_end": "12:00", "weekend_only": True}, # 周六
"moneyflow": {"window_end": "21:40", "weekend_only": False},
"longhubang": {"window_end": "22:05", "weekend_only": False},
"stock_node": {"window_end": "12:00", "weekend_only": True}, # 周六
"mairui_ma_daily": {"window_end": "17:00", "weekend_only": False}, # 本地派生
"mairui_indicators": {"window_end": "17:10", "weekend_only": False}, # mairui MACD/KDJ/BOLL
}
# ── 状态判定 ───────────────────────────────────────────────────────────────
def _parse_ts(s: Optional[str]) -> Optional[datetime]:
"""dataset_registry 时间字段是 '%Y-%m-%d %H:%M:%S' 或 None。"""
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S%z"):
try:
return datetime.strptime(s, fmt)
except ValueError:
continue
return None
def _classify_task(
row: dict[str, Any],
check_date: date,
now: datetime,
) -> dict[str, Any]:
"""判定单个 task 在 check_date 的健康状态。
返回字段:
status: ok | missed | failed | running | never_run | not_expected
reason: 简短中文原因
last_success_at / last_failure_at / last_error / needs_resync: 原值透传
"""
dataset_id = row["dataset_id"]
enabled = (row.get("enabled") or 0) == 1
spec = SCHEDULE.get(dataset_id)
is_weekend = check_date.weekday() >= 5
if not enabled:
return {
"dataset_id": dataset_id,
"name": row.get("name", ""),
"status": "disabled",
"reason": "enabled=0",
"last_success_at": row.get("last_success_at"),
"last_failure_at": row.get("last_failure_at"),
"last_error": row.get("last_error"),
"needs_resync": row.get("needs_resync", 0),
"status_field": row.get("status", "idle"),
}
# weekend 期望判定
if spec and spec["weekend_only"] and not is_weekend:
return {
"dataset_id": dataset_id,
"name": row.get("name", ""),
"status": "not_expected",
"reason": "周末专属任务,今天不期望",
"last_success_at": row.get("last_success_at"),
"last_failure_at": row.get("last_failure_at"),
"last_error": row.get("last_error"),
"needs_resync": row.get("needs_resync", 0),
"status_field": row.get("status", "idle"),
}
if spec and not spec["weekend_only"] and is_weekend:
return {
"dataset_id": dataset_id,
"name": row.get("name", ""),
"status": "not_expected",
"reason": "周末不调度",
"last_success_at": row.get("last_success_at"),
"last_failure_at": row.get("last_failure_at"),
"last_error": row.get("last_error"),
"needs_resync": row.get("needs_resync", 0),
"status_field": row.get("status", "idle"),
}
last_success = _parse_ts(row.get("last_success_at"))
last_failure = _parse_ts(row.get("last_failure_at"))
status_field = row.get("status", "idle")
# 期望窗口是否已过?未到窗口不应判 missed
if spec and spec.get("window_end") and not status_field == "running":
try:
hh, mm = spec["window_end"].split(":")
window_end_dt = datetime.combine(check_date, datetime.min.time()).replace(
hour=int(hh), minute=int(mm)
)
if now < window_end_dt:
return {
"dataset_id": dataset_id,
"name": row.get("name", ""),
"status": "not_yet",
"reason": f"期望窗口 {spec['window_end']} 未到(now={now.strftime('%H:%M')}",
"last_success_at": row.get("last_success_at"),
"last_failure_at": row.get("last_failure_at"),
"last_error": row.get("last_error"),
"needs_resync": row.get("needs_resync", 0),
"status_field": status_field,
}
except (ValueError, AttributeError):
pass # 解析失败回落到旧逻辑
# 还在跑(卡死检测)
if status_field == "running":
started = _parse_ts(row.get("started_at"))
if started and (now - started) > timedelta(hours=2):
return {
"dataset_id": dataset_id,
"name": row.get("name", ""),
"status": "stuck",
"reason": f"running > 2h, started_at={row.get('started_at')}",
"last_success_at": row.get("last_success_at"),
"last_failure_at": row.get("last_failure_at"),
"last_error": row.get("last_error"),
"needs_resync": row.get("needs_resync", 0),
"status_field": status_field,
}
return {
"dataset_id": dataset_id,
"name": row.get("name", ""),
"status": "running",
"reason": f"status=running, started_at={row.get('started_at')}",
"last_success_at": row.get("last_success_at"),
"last_failure_at": row.get("last_failure_at"),
"last_error": row.get("last_error"),
"needs_resync": row.get("needs_resync", 0),
"status_field": status_field,
}
# 从未跑过
if last_success is None and last_failure is None:
return {
"dataset_id": dataset_id,
"name": row.get("name", ""),
"status": "never_run",
"reason": "从未同步过",
"last_success_at": row.get("last_success_at"),
"last_failure_at": row.get("last_failure_at"),
"last_error": row.get("last_error"),
"needs_resync": row.get("needs_resync", 0),
"status_field": status_field,
}
# 今日是否有成功?
today_start = datetime.combine(check_date, datetime.min.time())
if last_success and last_success >= today_start:
return {
"dataset_id": dataset_id,
"name": row.get("name", ""),
"status": "ok",
"reason": f"今日已成功 @ {last_success.strftime('%H:%M:%S')}",
"last_success_at": row.get("last_success_at"),
"last_failure_at": row.get("last_failure_at"),
"last_error": row.get("last_error"),
"needs_resync": row.get("needs_resync", 0),
"status_field": status_field,
}
# 今日失败过?
if last_failure and last_failure >= today_start and (
not last_success or last_failure > last_success
):
return {
"dataset_id": dataset_id,
"name": row.get("name", ""),
"status": "failed",
"reason": f"今日失败 @ {last_failure.strftime('%H:%M:%S')}: "
f"{(row.get('last_error') or '')[:120]}",
"last_success_at": row.get("last_success_at"),
"last_failure_at": row.get("last_failure_at"),
"last_error": row.get("last_error"),
"needs_resync": row.get("needs_resync", 0),
"status_field": status_field,
}
# 期望窗口已过但今日没成功
return {
"dataset_id": dataset_id,
"name": row.get("name", ""),
"status": "missed",
"reason": (
f"今日未成功(last_success={last_success.strftime('%Y-%m-%d %H:%M') if last_success else 'never'}"
),
"last_success_at": row.get("last_success_at"),
"last_failure_at": row.get("last_failure_at"),
"last_error": row.get("last_error"),
"needs_resync": row.get("needs_resync", 0),
"status_field": status_field,
}
# ── systemd unit drift 检测 ────────────────────────────────────────────────
SYSTEMD_REPO_DIR = PROJECT_ROOT / "deploy" / "systemd" / "units"
SYSTEMD_LEGACY_REPO_DIR = PROJECT_ROOT / "bin" / "systemd"
SYSTEMD_ETC_DIR = Path("/etc/systemd/system")
def _check_systemd_drift() -> dict[str, Any]:
"""对比 deploy/systemd/units/ 与 /etc/systemd/system/ 下的 market-sync* unit。
兼容旧路径 bin/systemd/:如果 deploy/systemd/units/ 不存在则回退。
返回:
missing_in_etc: repo 有但 /etc 没装(drift 1:未部署)
drifted: 两边都有但内容不一致(drift 2:部署的版本过期)
orphan_in_etc: /etc 有但 repo 没有(drift 3:临时/僵尸 unit
"""
repo_dir = SYSTEMD_REPO_DIR if SYSTEMD_REPO_DIR.exists() else SYSTEMD_LEGACY_REPO_DIR
missing_in_etc: list[str] = []
drifted: list[dict[str, str]] = []
orphan_in_etc: list[str] = []
repo_units: set[str] = set()
if repo_dir.exists():
for f in repo_dir.iterdir():
if f.suffix in {".service", ".timer"} and f.name.startswith("market-sync"):
repo_units.add(f.name)
etc_units: set[str] = set()
if SYSTEMD_ETC_DIR.exists():
for f in SYSTEMD_ETC_DIR.iterdir():
if f.name.startswith("market-sync") and f.suffix in {".service", ".timer"}:
etc_units.add(f.name)
# drift 1: repo 有 /etc 没
for name in sorted(repo_units - etc_units):
missing_in_etc.append(name)
# drift 2: 两边都有但内容不一致
for name in sorted(repo_units & etc_units):
repo_path = repo_dir / name
etc_path = SYSTEMD_ETC_DIR / name
# 跳过被 mask 的 unit/etc 下是 /dev/null 的符号链接,表示 systemd 故意禁用
# 这是 deploy.sh 对 market-sync-worker.service 的标准操作,不应报 drift
if etc_path.is_symlink() and etc_path.resolve() == Path("/dev/null"):
continue
try:
if repo_path.read_bytes() != etc_path.read_bytes():
drifted.append({
"unit": name,
"repo_sha": hashlib.sha256(repo_path.read_bytes()).hexdigest()[:12],
"etc_sha": hashlib.sha256(etc_path.read_bytes()).hexdigest()[:12],
})
except OSError:
drifted.append({"unit": name, "repo_sha": "?", "etc_sha": "?"})
# drift 3: /etc 有 repo 没(孤儿)
for name in sorted(etc_units - repo_units):
orphan_in_etc.append(name)
has_drift = bool(missing_in_etc or drifted or orphan_in_etc)
return {
"schema_version": 1,
"checked_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"repo_units": sorted(repo_units),
"etc_units": sorted(etc_units),
"missing_in_etc": missing_in_etc,
"drifted": drifted,
"orphan_in_etc": orphan_in_etc,
"has_drift": has_drift,
}
# ── 数据一致性检查 ─────────────────────────────────────────────────────────
def _check_data_consistency() -> dict[str, Any]:
"""检查上游 kline_stock 与下游衍生表是否同步。
当前覆盖:
- kline_stock_ma_daily 是否比 kline_stock 缺行/缺日期
- market_regime_daily 是否比 kline_stock 缺最近交易日
返回:
alerts: 可读告警列表
details: 原始指标
"""
from app.core.config import settings
alerts: list[dict[str, str]] = []
details: dict[str, Any] = {"checked": False}
try:
engine = sa.create_engine(settings.pg_sqlalchemy_url())
with engine.connect() as conn:
# 1) kline_stock vs kline_stock_ma_daily
kline_max = conn.execute(
sa.text("SELECT MAX(trade_date) FROM market_data.kline_stock")
).scalar()
ma_max = conn.execute(
sa.text("SELECT MAX(trade_date) FROM market_data.kline_stock_ma_daily")
).scalar()
missing_rows = conn.execute(
sa.text("""
SELECT COUNT(*) FROM (
SELECT stock_code, trade_date FROM market_data.kline_stock
EXCEPT
SELECT stock_code, trade_date FROM market_data.kline_stock_ma_daily
) t
""")
).scalar() or 0
details["kline_stock_max_date"] = str(kline_max) if kline_max else None
details["kline_stock_ma_daily_max_date"] = str(ma_max) if ma_max else None
details["kline_ma_missing_rows"] = missing_rows
if kline_max and ma_max and ma_max < kline_max:
alerts.append({
"dataset_id": "data_consistency/kline_ma_daily",
"status": "stale",
"reason": f"kline_stock_ma_daily 最新日期 {ma_max} 落后于 kline_stock {kline_max}",
})
elif missing_rows and missing_rows > 0:
alerts.append({
"dataset_id": "data_consistency/kline_ma_daily",
"status": "gap",
"reason": f"kline_stock 有 {missing_rows} 行未覆盖到 kline_stock_ma_daily",
})
# 2) kline_stock vs market_regime_daily(最近 3 个交易日)
regime_max = conn.execute(
sa.text("SELECT MAX(trade_date) FROM market_data.market_regime_daily")
).scalar()
details["market_regime_daily_max_date"] = str(regime_max) if regime_max else None
if kline_max and regime_max and regime_max < kline_max:
alerts.append({
"dataset_id": "data_consistency/market_regime",
"status": "stale",
"reason": f"market_regime_daily 最新日期 {regime_max} 落后于 kline_stock {kline_max}",
})
details["checked"] = True
except Exception as e:
details["error"] = str(e)
alerts.append({
"dataset_id": "data_consistency/check_failed",
"status": "error",
"reason": f"数据一致性检查异常: {e}",
})
return {"alerts": alerts, "details": details}
# ── 报告生成 ───────────────────────────────────────────────────────────────
def build_report(check_date: date) -> dict[str, Any]:
"""生成当日巡检报告。"""
from app.core.db import ops as db_ops # 延迟导入:避免单独跑这个脚本时加载 .env
now = datetime.now()
rows = db_ops.fetch_dataset_registry_rows()
classified = [_classify_task(r, check_date, now) for r in rows]
# 汇总计数
counts: dict[str, int] = {}
for c in classified:
counts[c["status"]] = counts.get(c["status"], 0) + 1
# 告警列表:只挑非 ok / non-expected 的
alerts = [
{"dataset_id": c["dataset_id"], "status": c["status"], "reason": c["reason"]}
for c in classified
if c["status"] in {"missed", "failed", "stuck", "never_run"}
]
# systemd drift 单独段(不是 dataset 状态,但是 sync 完整性的关键)
drift = _check_systemd_drift()
if drift["has_drift"]:
# drift 也算告警(影响同步运行)
if drift["missing_in_etc"]:
for u in drift["missing_in_etc"]:
alerts.append({
"dataset_id": f"systemd/{u}",
"status": "not_deployed",
"reason": f"unit {u} 在 deploy/systemd/units/ 有但 /etc/systemd/system/ 没装",
})
if drift["drifted"]:
for d in drift["drifted"]:
alerts.append({
"dataset_id": f"systemd/{d['unit']}",
"status": "drifted",
"reason": f"unit {d['unit']} /etc 版过期(repo={d['repo_sha']}, etc={d['etc_sha']}",
})
if drift["orphan_in_etc"]:
for u in drift["orphan_in_etc"]:
alerts.append({
"dataset_id": f"systemd/{u}",
"status": "orphan",
"reason": f"unit {u} 在 /etc/systemd/system/ 但 repo 没维护",
})
# 数据一致性检查(上游回填后下游未跟进)
consistency = _check_data_consistency()
alerts.extend(consistency["alerts"])
overall = "ok" if not alerts else (
"warning" if any(a["status"] in {"missed", "never_run", "not_deployed", "drifted"} for a in alerts) else "error"
)
return {
"schema_version": 2,
"generated_at": now.strftime("%Y-%m-%d %H:%M:%S"),
"check_date": check_date.isoformat(),
"runtime_mode": _runtime_mode(),
"overall": overall,
"counts": counts,
"alerts": alerts,
"tasks": classified,
"systemd_drift": drift,
"data_consistency": consistency["details"],
}
def write_report(report: dict[str, Any]) -> Path:
"""把报告写到 logs/daily_check_YYYYMMDD_HHMMSS.json。"""
LOG_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = LOG_DIR / f"daily_check_{ts}.json"
path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
return path
def print_summary(report: dict[str, Any]) -> None:
"""stdout 一眼看懂的汇总。"""
print("=" * 78)
print(f"每日同步巡检 date={report['check_date']} runtime_mode={report.get('runtime_mode', 'unknown')} overall={report['overall']}")
print("=" * 78)
print(f"counts: {report['counts']}")
if report["alerts"]:
print(f"\n告警 ({len(report['alerts'])}):")
for a in report["alerts"]:
print(f" - [{a['status']:>9}] {a['dataset_id']:<20} {a['reason']}")
else:
print("\n✅ 所有任务今日已成功")
# systemd drift 段
drift = report.get("systemd_drift", {})
if drift:
print(f"\nsystemd unit 漂移:")
print(f" repo units ({len(drift['repo_units'])}): {drift['repo_units']}")
print(f" /etc units ({len(drift['etc_units'])}): {drift['etc_units']}")
if drift["missing_in_etc"]:
print(f" ❌ 未部署 ({len(drift['missing_in_etc'])}): {drift['missing_in_etc']}")
if drift["drifted"]:
print(f" ⚠️ 内容不一致 ({len(drift['drifted'])}):")
for d in drift["drifted"]:
print(f" - {d['unit']} repo={d['repo_sha']} etc={d['etc_sha']}")
if drift["orphan_in_etc"]:
print(f" 👻 /etc 孤儿 ({len(drift['orphan_in_etc'])}): {drift['orphan_in_etc']}")
if not drift["has_drift"]:
print(f" ✅ 无漂移")
# 完整列表
print(f"\n全部 {len(report['tasks'])} 个任务:")
for t in report["tasks"]:
icon = {
"ok": "",
"missed": "",
"failed": "",
"stuck": "⚠️",
"running": "🔄",
"never_run": "",
"not_expected": "",
"not_yet": "",
"disabled": "🚫",
}.get(t["status"], "·")
print(f" {icon} [{t['status']:>13}] {t['dataset_id']:<20} {t['reason']}")
# ── Webhook ────────────────────────────────────────────────────────────────
SIG_HEADER_CANDIDATES: list[tuple[str, str]] = [
("X-Hub-Signature-256", "sha256={sig}"),
("X-Webhook-Signature", "{sig}"),
("X-Gitlab-Token", "{secret}"),
]
HTTP_TIMEOUT = 10
def _load_secrets() -> dict[str, str]:
out: dict[str, str] = {}
if not SECRETS_FILE.exists():
return out
try:
for raw in SECRETS_FILE.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
v = v.strip().strip('"').strip("'")
if k and v:
out[k] = v
except Exception as e:
LOG.error("failed to parse %s: %s", SECRETS_FILE, e)
return out
def post_webhook(url: str, secret: str, payload: dict[str, Any], *, dry_run: bool = False) -> int:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
sig = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
if dry_run:
print(f"[webhook DRY-RUN] {url}")
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 200
for hdr_name, value_fmt in SIG_HEADER_CANDIDATES:
sig_value = value_fmt.format(sig=sig, secret=secret)
headers = {"Content-Type": "application/json", hdr_name: sig_value}
try:
r = requests.post(url, data=body, headers=headers, timeout=HTTP_TIMEOUT)
if r.status_code in (401, 403):
LOG.info("auth header %s rejected (%s) — trying next", hdr_name, r.status_code)
continue
return r.status_code
except requests.RequestException as e:
LOG.error("POST %s failed with %s: %s", url, hdr_name, e)
return 0
return 0
def maybe_alert(report: dict[str, Any], secrets: dict[str, str], *, dry_run: bool) -> None:
"""只在 overall != ok 时 POST。"""
if report["overall"] == "ok":
LOG.info("全部 ok,跳过 webhook")
return
url = secrets.get("FAILURE_WEBHOOK_URL")
secret = secrets.get("FAILURE_WEBHOOK_SECRET")
if not url or not secret:
LOG.warning("FAILURE_WEBHOOK_URL/SECRET 未配置,跳过 POST")
return
payload = {
"task": "daily_check",
"service": "bin/daily_sync_check.py",
"status": report["overall"],
"check_date": report["check_date"],
"counts": report["counts"],
"alerts": report["alerts"],
"generated_at": report["generated_at"],
}
status = post_webhook(url, secret, payload, dry_run=dry_run)
LOG.info("webhook POST → %s", status)
# ── CLI ────────────────────────────────────────────────────────────────────
def main(argv: Optional[list] = None) -> int:
parser = argparse.ArgumentParser(description="每日同步巡检")
parser.add_argument("--date", help="检查日期 YYYY-MM-DD(默认今天)")
parser.add_argument("--dry-run", action="store_true", help="webhook 走 dry-run(仍写报告)")
parser.add_argument("--report-only", action="store_true", help="不读 secrets、不 POST")
args = parser.parse_args(argv)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
if args.date:
check_date = datetime.strptime(args.date, "%Y-%m-%d").date()
else:
check_date = date.today()
report = build_report(check_date)
path = write_report(report)
print_summary(report)
LOG.info("报告已写: %s", path)
if not args.report_only:
secrets = _load_secrets()
maybe_alert(report, secrets, dry_run=args.dry_run)
return 0 if report["overall"] == "ok" else 1
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -0,0 +1 @@
deploy/systemd/deploy.sh
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# systemd 调度入口:单跑 kline_5min(评分核心依赖)
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/kline_5min_${TS}.log"
echo "[market-sync-kline-5min] start ts=$TS log=$LOG" | tee -a "$LOG"
cd "$PROJECT_ROOT"
.venv/bin/python -m app.entrypoints.cli sync kline_5min 2>&1 | tee -a "$LOG"
EXIT_CODE=${PIPESTATUS[0]}
echo "[market-sync-kline-5min] done exit_code=$EXIT_CODE" | tee -a "$LOG"
exit $EXIT_CODE
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# systemd 调度入口:单跑 kline_daily(评分核心依赖)
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/kline_daily_${TS}.log"
echo "[market-sync-kline-daily] start ts=$TS log=$LOG" | tee -a "$LOG"
cd "$PROJECT_ROOT"
.venv/bin/python -m app.entrypoints.cli sync kline_daily 2>&1 | tee -a "$LOG"
EXIT_CODE=${PIPESTATUS[0]}
echo "[market-sync-kline-daily] done exit_code=$EXIT_CODE" | tee -a "$LOG"
exit $EXIT_CODE
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# systemd 调度入口:单跑 kline_index
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/kline_index_${TS}.log"
echo "[market-sync-kline-index] start ts=$TS log=$LOG" | tee -a "$LOG"
cd "$PROJECT_ROOT"
.venv/bin/python -m app.entrypoints.cli sync kline_index 2>&1 | tee -a "$LOG"
EXIT_CODE=${PIPESTATUS[0]}
echo "[market-sync-kline-index] done exit_code=$EXIT_CODE" | tee -a "$LOG"
exit $EXIT_CODE
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# systemd 调度的入口(专跑 mairui_indicators
#
# 设计:
# - 交易日 16:40 触发,从 mairui 拉 MACD/KDJ/BOLL 增量
# - 数据量大(3 指标 × 全市场),单独跑避免拖垮 runall 链
# - 单独日志到 logs/mairui_indicators_<ts>.log
# - exit code 透传
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/mairui_indicators_${TS}.log"
echo "[market-sync-mairui-indicators] start ts=$TS log=$LOG" | tee -a "$LOG"
# ── 跑 mairui_indicators ──
cd "$PROJECT_ROOT"
.venv/bin/python -m app.entrypoints.cli sync mairui_indicators 2>&1 | tee -a "$LOG"
EXIT_CODE=${PIPESTATUS[0]}
echo "[market-sync-mairui-indicators] done exit_code=$EXIT_CODE" | tee -a "$LOG"
exit $EXIT_CODE
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# systemd 调度的入口(专跑 mairui_ma_daily
#
# 设计:
# - 交易日 16:30 触发,基于 kline_stock 计算全市场 MA5/10/20/60
# - 本地派生任务,无外部 API 依赖
# - 单独日志到 logs/mairui_ma_daily_<ts>.log
# - exit code 透传
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/mairui_ma_daily_${TS}.log"
echo "[market-sync-mairui-ma-daily] start ts=$TS log=$LOG" | tee -a "$LOG"
# ── 跑 mairui_ma_daily ──
cd "$PROJECT_ROOT"
.venv/bin/python -m app.entrypoints.cli sync mairui_ma_daily 2>&1 | tee -a "$LOG"
EXIT_CODE=${PIPESTATUS[0]}
echo "[market-sync-mairui-ma-daily] done exit_code=$EXIT_CODE" | tee -a "$LOG"
exit $EXIT_CODE
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# systemd 调度入口:单跑 market_regime(本地计算,基于 kline_stock
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/market_regime_${TS}.log"
echo "[market-sync-market-regime] start ts=$TS log=$LOG" | tee -a "$LOG"
cd "$PROJECT_ROOT"
.venv/bin/python -m app.entrypoints.cli sync market_regime 2>&1 | tee -a "$LOG"
EXIT_CODE=${PIPESTATUS[0]}
echo "[market-sync-market-regime] done exit_code=$EXIT_CODE" | tee -a "$LOG"
exit $EXIT_CODE
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# 早盘前同步:stock_basic(仅)
# - stock_basic:雪球 quote_detail 拉全市场股本快照(~9min @ 5 RPS
# - 与 runall_once.py 区别:不拉 kline_daily / kline_5min 等耗时长任务
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/morning_run_${TS}.log"
cd "$PROJECT_ROOT"
exec .venv/bin/python -c "
import sys, time
sys.path.insert(0, '$PROJECT_ROOT')
from app.core.datasource.registry import build_default_registry
from app.core.sync.registry import seed_sync_registry
build_default_registry()
seed_sync_registry()
from app.tasks import get_task
results = []
for tid in ['stock_basic']:
print(f'[morning-run] >>> 开始 {tid}', flush=True)
t0 = time.time()
try:
r = get_task(tid).run(trigger_source='morning_runall')
elapsed = round(time.time() - t0, 1)
results.append({'task': tid, 'status': r.get('status'), 'elapsed_sec': elapsed, 'message': r.get('message', '')})
print(f'[morning-run] <<< {tid} status={r.get(\"status\")} elapsed={elapsed}s msg={r.get(\"message\", \"\")}', flush=True)
except Exception as e:
elapsed = round(time.time() - t0, 1)
results.append({'task': tid, 'status': 'error', 'elapsed_sec': elapsed, 'message': str(e)})
print(f'[morning-run] !!! {tid} 异常: {e}', flush=True)
time.sleep(5)
import json
print('[morning-run] summary:', json.dumps(results, ensure_ascii=False))
" > "$LOG" 2>&1
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# systemd 调度的入口(专跑股本快照)
#
# 设计:
# - 与 market_sync_stock_node_run.sh 同构,只跑 share_snapshot 一个 task
# - 每周六 11:30 触发(周末收盘后股本数据稳定,与 stock_node 同时段)
# - 单独日志到 logs/share_<ts>.log
# - exit code 透传
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/share_${TS}.log"
echo "[market-sync-share] start ts=$TS log=$LOG" | tee -a "$LOG"
cd "$PROJECT_ROOT"
.venv/bin/python -m app.entrypoints.cli sync share_snapshot 2>&1 | tee -a "$LOG"
EXIT_CODE=${PIPESTATUS[0]}
echo "[market-sync-share] done exit_code=$EXIT_CODE" | tee -a "$LOG"
exit $EXIT_CODE
+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
+3 -2
View File
@@ -1,5 +1,6 @@
#!/bin/bash
# 跑剩余任务:tick_trade → moneyflow → share_snapshot → market_regime
# 跑剩余任务:tick_trade → moneyflow → market_regime
# share_snapshot 已改为每周单独 timermarket-sync-share.timer),不在此处触发
# 跳过 kline_5min(全量回填 6 年太慢,下次有空再补)
# tick_trade 用 --force 跳过 21:00 门控(人工补跑场景)
set -e
@@ -10,7 +11,7 @@ mkdir -p logs
# 先清理上次可能留下的卡死记录(recover_interrupted_syncs 启动时自动处理,但显式更稳)
echo "=== 启动时间: $(date) ===" | tee logs/runall_remaining.log
for TASK in "tick_trade --force" moneyflow share_snapshot market_regime; do
for TASK in "tick_trade --force" moneyflow market_regime; do
echo "" | tee -a logs/runall_remaining.log
echo "=== [$TASK] 开始 $(date) ===" | tee -a logs/runall_remaining.log
T0=$(date +%s)
+118 -31
View File
@@ -9,13 +9,26 @@
注:以下 task **不**在 runall 链中 —— 各自有独立的 systemd timer
- tick_trade : mairui 21:00 发布 → market-sync-tick.timer (21:05)
- moneyflow : mairui 21:30 发布 → market-sync-moneyflow.timer (21:35)
2026-07-08 教训: 之前 runall 是顺序串行,任一 task 永久卡住(kline_daily 在
5200/5204 卡死 4h)→ 后面 5 个 task 全部没机会跑,runall 进程被 systemd 4h
超时杀, dataset_registry 卡在 running 状态无人清理。修复:
1. 启动时调 recover_interrupted_dataset_registry() — 清上次被中断的卡死
2. 每个 task 用 multiprocessing.Process 跑 + 硬上限 PER_TASK_TIMEOUT_SEC,
超时直接 kill 子进程,继续下一个 task (不卡整条 runall)
3. 数据源/任务层也加了 timeout(xueqiu 20s + kline_daily as_completed 30s),
这里是最后一道防线
"""
from __future__ import annotations
import json
import multiprocessing as mp
import os
import signal
import sys
import time
from pathlib import Path
from typing import Any
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(_PROJECT_ROOT) not in sys.path:
@@ -30,8 +43,19 @@ from app.core.sync.registry import seed_sync_registry
build_default_registry()
seed_sync_registry()
from app.core.db import ops as db_ops
from app.tasks import get_task
# 启动时清理上一轮被中断的卡死状态(2026-07-08 教训)
# 注意: 这里清的是"上次 runall 被杀时的 running",如果当前 runall 自己卡死,
# 子进程被 kill 后 recover 仍要再调一次 — 见末尾的 finally。
try:
n_recovered = db_ops.recover_interrupted_dataset_registry()
if n_recovered:
logger.warning("[runall] 启动时清掉 %d 个上轮卡死的 running 任务", n_recovered)
except Exception as e:
logger.warning("[runall] 启动清理失败(非致命): %s", e)
# 顺序执行,按 sort_order 走(便于排查 + 避免外部 API 限流)
# 注:tick_trade / moneyflow 由独立 timer 跑(21:05 / 21:35
TASKS = [
@@ -39,48 +63,111 @@ TASKS = [
"kline_index",
"kline_daily",
"kline_5min",
"industry_sector",
"sector_features",
"share_snapshot",
"market_regime",
]
# 不跳任何 task — 全量跑
SKIP: set[str] = set()
results = {}
t_all = time.time()
for tid in TASKS:
if tid in SKIP:
results[tid] = {"status": "skipped", "message": "已 ok"}
logger.info(f"[runall] {tid} 跳过(已 ok")
continue
logger.info(f"[runall] >>> 开始 {tid}")
t0 = time.time()
# 每个 task 的硬上限(秒)。覆盖数据源/任务层都失败的最坏情况:
# kline_daily: 35 min (正常 18 min, 留余量)
# kline_5min: 75 min (全量首次跑很久, 增量 ~30 min)
# share_snapshot: 15 min
# 其他小表: 10 min
PER_TASK_TIMEOUT_SEC = {
"kline_daily": 35 * 60,
"kline_5min": 75 * 60,
"share_snapshot": 15 * 60,
"market_regime": 5 * 60,
"stock_basic": 15 * 60,
"kline_index": 5 * 60,
}
DEFAULT_TASK_TIMEOUT = 20 * 60
def _run_task_in_subprocess(tid: str, result_queue: mp.Queue) -> None:
"""子进程入口: 跑 task.run() 并把 result 通过 queue 返回。
单独进程是为了父进程能用 SIGKILL 干掉它(timeout 时)而不污染主 runall 状态。
"""
try:
task = get_task(tid)
# 大表 task 限小批量股票,避免跑爆;其他 task 跑全量
kwargs = {"max_workers": 5}
if tid in {"kline_daily", "kline_5min", "moneyflow", "share_snapshot"}:
# 这些 task 支持 MARKET_DATA_STOCK_LIMIT 环境变量,但通过 cli 跑可 --codes 限
# 测全量费时,先跑全量
pass
r = task.run(trigger_source="runall", **kwargs)
elapsed = round(time.time() - t0, 1)
r["elapsed_sec"] = elapsed
results[tid] = r
logger.info(f"[runall] <<< {tid} 完成 status={r.get('status')} elapsed={elapsed}s msg={r.get('message')}")
result_queue.put(r)
except Exception as e:
result_queue.put({"status": "error", "message": f"{type(e).__name__}: {e}"})
results: dict[str, Any] = {}
t_all = time.time()
# 注意: 主执行块必须在 __name__ == '__main__' 内部,否则 multiprocessing spawn
# 子进程重新 import 本模块时会再次执行进程启动逻辑,导致 RuntimeError:
# "An attempt has been made to start a new process before the current process
# has finished its bootstrapping phase." (2026-07-13 15:30 runall 全部失败)
def _run_tasks():
global results
for tid in TASKS:
if tid in SKIP:
results[tid] = {"status": "skipped", "message": "已 ok"}
logger.info(f"[runall] {tid} 跳过(已 ok")
continue
timeout_sec = PER_TASK_TIMEOUT_SEC.get(tid, DEFAULT_TASK_TIMEOUT)
logger.info(f"[runall] >>> 开始 {tid} (hard timeout {timeout_sec}s)")
t0 = time.time()
# 子进程跑 task,父进程用 .join(timeout) 守门
ctx = mp.get_context("spawn")
result_queue: mp.Queue = ctx.Queue()
proc = ctx.Process(target=_run_task_in_subprocess, args=(tid, result_queue), name=f"runall-{tid}")
proc.start()
proc.join(timeout=timeout_sec)
elapsed = round(time.time() - t0, 1)
logger.exception(f"[runall] !!! {tid} 异常: {e}")
results[tid] = {"status": "error", "message": str(e), "elapsed_sec": elapsed}
# 每个 task 之间 sleep 5s 让健康监控跑
time.sleep(5)
if proc.is_alive():
logger.error(
f"[runall] !!! {tid} 超时 (>{timeout_sec}s, elapsed={elapsed}s), 强制 kill"
)
proc.terminate()
proc.join(5)
if proc.is_alive():
proc.kill()
proc.join(2)
results[tid] = {
"status": "error",
"message": f"runall timeout (>={timeout_sec}s), killed",
"elapsed_sec": elapsed,
}
try:
db_ops.recover_interrupted_dataset_registry()
except Exception as e:
logger.warning("[runall] 超时后清理 dataset_registry 失败: %s", e)
else:
try:
r = result_queue.get(timeout=5)
except Exception as e:
r = {"status": "error", "message": f"无法获取子进程结果: {e}"}
r["elapsed_sec"] = elapsed
results[tid] = r
logger.info(
f"[runall] <<< {tid} 完成 status={r.get('status')} elapsed={elapsed}s msg={r.get('message')}"
)
elapsed_total = round(time.time() - t_all, 1)
summary = {"total_elapsed_sec": elapsed_total, "tasks": results}
out = _PROJECT_ROOT / "logs" / "runall_summary.json"
out.write_text(json.dumps(summary, ensure_ascii=False, indent=2))
logger.info(f"[runall] 全部完成 total={elapsed_total}s summary={out}")
print(json.dumps(summary, ensure_ascii=False, indent=2))
time.sleep(5)
if __name__ == '__main__':
_run_tasks()
# 全部完成清一次 stuck 状态
try:
db_ops.recover_interrupted_dataset_registry()
except Exception as e:
logger.warning("[runall] 末尾清理失败(非致命): %s", e)
elapsed_total = round(time.time() - t_all, 1)
summary: dict[str, Any] = {"total_elapsed_sec": elapsed_total, "tasks": results}
out = _PROJECT_ROOT / "logs" / "runall_summary.json"
out.write_text(json.dumps(summary, ensure_ascii=False, indent=2))
logger.info(f"[runall] 全部完成 total={elapsed_total}s summary={out}")
print(json.dumps(summary, ensure_ascii=False, indent=2))
+78
View File
@@ -0,0 +1,78 @@
#!/bin/sh
# service_run.sh — Docker 入口
#
# 单进程跑:FastAPI dashboarduvicorn+ 进程内 schedulerworker
# 用 uvicorn 主进程 + 一个 daemon thread 跑 worker 的 scheduler 循环。
#
# 设计原因:docker-compose 一容器一服务最简单,systemd timer 的事交给
# 进程内 scheduler(避免 docker 里跑 systemd)。原来 .env 里的 schedule
# 表就是为这个准备的。
set -eu
RUNTIME_MODE="${RUNTIME_MODE:-docker}"
if [ "$RUNTIME_MODE" != "docker" ]; then
echo "[service] ❌ ERROR: service_run.sh 是 Docker 入口,只在 RUNTIME_MODE=docker 时可用。" >&2
echo "[service] 当前 RUNTIME_MODE=$RUNTIME_MODE" >&2
echo "[service] Linux 宿主机部署请使用 systemd timer/service,不要运行此脚本。" >&2
exit 1
fi
# 等 PG 就绪(避免 racemarket_sync 比 postgres 早启)
if [ -n "${PG_HOST:-}" ]; then
echo "[service] wait for PG ${PG_HOST}:${PG_PORT:-5432}..."
for i in $(seq 1 30); do
if python -c "
import socket, sys
s = socket.socket()
s.settimeout(2)
try:
s.connect(('${PG_HOST}', ${PG_PORT:-5432}))
print('pg ready')
except Exception as e:
sys.exit(1)
" >/dev/null 2>&1; then
echo "[service] PG up after ${i}s"
break
fi
sleep 1
done
fi
# seed datasource + sync registry + schedule(幂等)
python -c "
from app.core.datasource.registry import build_default_registry, seed_datasource_configs
from app.core.sync.registry import seed_sync_registry, recover_interrupted_syncs
from app.core.scheduler.scheduler import seed_schedule_configs
build_default_registry()
seed_datasource_configs()
seed_sync_registry()
recover_interrupted_syncs()
seed_schedule_configs()
print('[service] seed 完成')
"
# 后台启动 scheduler(独立线程)
python -c "
from app.entrypoints.worker import start_scheduler_thread
start_scheduler_thread()
print('[service] scheduler thread started')
import time
while True:
time.sleep(60)
" &
SCHEDULER_PID=$!
# trap SIGTERM 优雅退出
trap "echo '[service] stopping scheduler'; kill ${SCHEDULER_PID} 2>/dev/null || true; wait" TERM INT
# 前台跑 uvicorn(主进程)
echo "[service] starting uvicorn on ${API_HOST:-0.0.0.0}:${API_PORT:-8100}"
exec uvicorn app.api.main:app \
--host "${API_HOST:-0.0.0.0}" \
--port "${API_PORT:-8100}" \
--workers 1 \
--log-level "${LOG_LEVEL:-info}" \
--no-access-log
@@ -0,0 +1,31 @@
[Unit]
Description=Market sync daily check — reads dataset_registry, writes report, alerts on failures
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/daily_sync_check.py
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/home/gao/Development/quant_home/market_sync
User=gao
Group=gao
# 巡检:读 PG dataset_registry,对比今日预期,写 logs/daily_check_*.json
# 失败时 POST webhook 到 FAILURE_WEBHOOK_URLHMAC-SHA256 签名)
ExecStart=/home/gao/Development/quant_home/market_sync/.venv/bin/python /home/gao/Development/quant_home/market_sync/bin/daily_sync_check.py
# 巡检应 < 30s 完成;超时 = PG 不通或代码 bug,不让卡死
TimeoutStartSec=120
# 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑)
# 失败时 exit code = 1,可被 OnFailure= 钩住发通知
# 日志走 journaldjournalctl -u market-sync-daily-check -f
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-daily-check
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Schedule market sync daily check — Daily 23:00 Asia/Shanghai
# market-sync-daily-check.service 是这个 timer 的执行单元
# 23:00 触发:周一~五最后一个任务是 longhubang @ 22:00,留 1h buffer
# 周六 23:00 给 stock_node @ 11:30 留 11h+ buffer
[Timer]
# 每天 23:00 触发;Persistent=true 确保关机/错过时下次开机补跑
OnCalendar=*-*-* 23:00:00 Asia/Shanghai
Persistent=true
Unit=market-sync-daily-check.service
# 不要 AccuracySec(默认 1min 漂移够用)
[Install]
WantedBy=timers.target
@@ -0,0 +1,32 @@
[Unit]
Description=Market data MACD/KDJ/BOLL indicators sync (mairui, daily 16:40)
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_mairui_indicators_run.sh
After=network-online.target market-sync.service
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/home/gao/Development/quant_home/market_sync
User=gao
Group=gao
# 入口脚本:单独跑 mairui_indicators 一个 task
# mairui 16:30 后发布指标数据 → 16:40 触发留 buffer
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_mairui_indicators_run.sh
# 硬上限 3h(5204 只 × 3 指标,正常 ~20min,全量时可能更久)
TimeoutStartSec=10800
# 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑)
# Restart=no 是 oneshot 默认值
# 日志走 journald
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-mairui-indicators
# 环境
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,14 @@
[Unit]
Description=Schedule MACD/KDJ/BOLL indicators sync — Mon..Fri 16:40 Asia/Shanghai
# market-sync-mairui-indicators.service 是这个 timer 的执行单元
[Timer]
# mairui 16:30 后发布指标数据 → 16:40 触发留 buffer
OnCalendar=Mon..Fri 16:40:00 Asia/Shanghai
# 系统关机 / 错过执行时,下次开机补跑一次(避免漏数据)
Persistent=true
# 单位(service)的精确名称
Unit=market-sync-mairui-indicators.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,32 @@
[Unit]
Description=Market data MA daily sync (local derivative, daily 16:30)
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_mairui_ma_daily_run.sh
After=network-online.target market-sync.service
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/home/gao/Development/quant_home/market_sync
User=gao
Group=gao
# 入口脚本:单独跑 mairui_ma_daily 一个 task
# 本地派生任务,依赖 kline_stock 已更新
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_mairui_ma_daily_run.sh
# 硬上限 2h(正常 ~30min,留 buffer
TimeoutStartSec=7200
# 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑)
# Restart=no 是 oneshot 默认值
# 日志走 journald
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-mairui-ma-daily
# 环境
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,14 @@
[Unit]
Description=Schedule MA daily sync — Mon..Fri 16:30 Asia/Shanghai
# market-sync-mairui-ma-daily.service 是这个 timer 的执行单元
[Timer]
# kline_daily 15:40 / kline_index 15:30 跑完后,16:30 计算 MA
OnCalendar=Mon..Fri 16:30:00 Asia/Shanghai
# 系统关机 / 错过执行时,下次开机补跑一次(避免漏数据)
Persistent=true
# 单位(service)的精确名称
Unit=market-sync-mairui-ma-daily.service
[Install]
WantedBy=timers.target
+29
View File
@@ -0,0 +1,29 @@
[Unit]
Description=Market sync morning pre-market — stock_basic
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_morning_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_basic(确保最新股票列表+股本快照)
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_morning_run.sh
# stock_basic ~9min + buffer = 1h 够用
TimeoutStartSec=3600
# 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑)
# 日志走 journaldjournalctl -u market-sync-morning -f
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-morning
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=Schedule market sync morning — Mon..Fri 09:00 Asia/Shanghai
# market-sync-morning.service 是这个 timer 的执行单元
# 早盘前 09:00 触发:跑 stock_basic,给 09:30 开盘留 30min buffer
[Timer]
# A 股开盘 09:3009:00 跑 stock_basic (~9min) 即可
OnCalendar=Mon..Fri 09:00:00 Asia/Shanghai
# 关机/错过时下次开机补跑
Persistent=true
Unit=market-sync-morning.service
[Install]
WantedBy=timers.target
+31
View File
@@ -0,0 +1,31 @@
[Unit]
Description=Market data 股本快照 sync (雪球源, weekly Sat 11:30)
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_share_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
# 入口脚本:单独跑 share_snapshot 一个 task
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_share_run.sh
# 硬上限 30min(全市场 5200 只雪球 quote_detail10 worker 约 3-8min,留 buffer
TimeoutStartSec=1800
# 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑)
# Restart=no 是 oneshot 默认值
# 日志走 journaldjournalctl -u market-sync-share.service -f
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-share
# 环境(不读 /etc/environment,只带这几个;.env 由 cli 内部 load_dotenv
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Schedule share_snapshot sync — Sat 11:30 Asia/Shanghai
# market-sync-share.service 是这个 timer 的执行单元
# 股本快照每周跑一次即可(雪球 quote_detail,周末收盘后数据稳定)
[Timer]
# 周六 11:30(和 stock_node 同时段)
OnCalendar=Sat *-*-* 11:30:00 Asia/Shanghai
# 系统关机 / 错过执行时,下次开机补跑一次(避免漏数据)
Persistent=true
# 单位(service)的精确名称
Unit=market-sync-share.service
# 不要 AccuracySec(默认 1min 漂移够用)
[Install]
WantedBy=timers.target
@@ -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 market-sync-morning.service
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
+25
View File
@@ -0,0 +1,25 @@
[Unit]
Description=Market Data Sync Worker (PostgreSQL, scheduler + health monitor)
Documentation=file:///home/gao/Development/quant_home/market_sync/app/entrypoints/worker.py
# 依赖 network 和 PG
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/gao/Development/quant_home/market_sync
User=gao
Group=gao
# 入口:启动进程内 scheduler + health monitor + recover 卡死任务
# 注意:实际入口是 app/entrypoints/worker.py,不是 app/worker.py
# 2026-07-12 修复: 把 python -m app.worker 改为 python -m app.entrypoints.worker
ExecStart=/home/gao/Development/quant_home/market_sync/.venv/bin/python -m app.entrypoints.worker
# 明确标记运行模式,让 worker.py 知道不要启动 scheduler
# systemd 模式下同步由 timer 触发,避免双调度器重叠)
Environment=PYTHONUNBUFFERED=1
Environment="RUNTIME_MODE=systemd"
[Install]
WantedBy=multi-user.target
+5 -2
View File
@@ -13,8 +13,11 @@ Group=gao
# 入口脚本(runall_once.py + structured_watch.sh 二合一)
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_run.sh
# 硬上限 2h(实际 ~80min,留 buffer 给 retry / 慢任务
TimeoutStartSec=7200
# 硬上限 3h(实际 ~80min + kline_5min 增量 70min, 总 ~2.5h, 留 30min buffer
# 2026-07-08 教训: 之前 4h 让 kline_daily 单点卡死拖 4h,runall 链全部受影响。
# runall_once.py 已加 per-task multiprocessing timeout(单 task 最长 75min),
# 这里 3h 是 systemd 层兜底。
TimeoutStartSec=10800
# 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑——
# 重跑会撞外部 API 限流窗口,浪费 1.5h)
+1
View File
@@ -0,0 +1 @@
deploy/systemd/deploy.sh
+218
View File
@@ -0,0 +1,218 @@
"""自适应任务跟踪监视器。
用法:
.venv/bin/python bin/task_watch.py # 单次检查
.venv/bin/python bin/task_watch.py --watch # 持续监视(自动调整检查间隔)
设计:
1. 查询 dataset_registry 中 status='running' 的任务
2. 从 config 表中解析历史运行耗时(解析 lastMessage 中的 elapsed_sec
3. 估算剩余时间 = 历史平均耗时 - 已用时间
4. 自适应下次检查间隔 = clamp(剩余 * 0.15, 15s, 300s)
无历史数据时默认 60s
5. 持续监视模式下,每次检查后动态调整下一次的 sleep 时间
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
from sqlalchemy import create_engine, text
from app.core.config import settings
from app.core.utils.logging import setup_logging, get_logger
setup_logging()
logger = get_logger("task_watch")
# ── 历史耗时解析 ─────────────────────────────────────────────────────
_ELAPSED_RE = re.compile(r"elapsed[=_ ]sec[= ]?(\d+\.?\d*)", re.IGNORECASE)
_ELAPSED_SUFFIX_RE = re.compile(r"(\d+\.?\d*)s")
def _parse_elapsed_sec(message: str) -> float | None:
for pattern in (_ELAPSED_RE,):
m = pattern.search(message)
if m:
return float(m.group(1))
return None
# ── 历史耗时数据库(从 config 表 lastMessage 解析)───────────────────
def _load_history(conn: Any) -> dict[str, list[float]]:
history: dict[str, list[float]] = {}
rows = conn.execute(
text("""
SELECT key, value
FROM market_data.config
WHERE category = 'schedule'
""")
).fetchall()
for key, value_json in rows:
try:
val = json.loads(value_json)
except (json.JSONDecodeError, TypeError):
continue
job = val.get("job") or key.removeprefix("schedule_")
msg = val.get("lastMessage") or ""
elapsed = _parse_elapsed_sec(msg)
if elapsed and elapsed > 0:
history.setdefault(job, []).append(elapsed)
return history
# ── 活跃任务查询 ──────────────────────────────────────────────────────
def _fetch_running_tasks(conn: Any) -> list[dict[str, Any]]:
rows = conn.execute(
text("""
SELECT dataset_id, status, started_at, message
FROM market_data.dataset_registry
WHERE status = 'running'
ORDER BY started_at NULLS LAST
""")
).fetchall()
tasks = []
for row in rows:
tasks.append({
"dataset_id": row[0],
"status": row[1],
"started_at": row[2],
"message": row[3] or "",
})
return tasks
# ── 估算 ──────────────────────────────────────────────────────────────
def _estimate(
task: dict[str, Any],
history: dict[str, list[float]],
) -> tuple[float | None, float | None, float | None]:
"""返回 (历史平均耗时秒, 已用秒, 预估剩余秒)。"""
tid = task["dataset_id"]
starts = task["started_at"]
if starts is None:
return None, None, None
started_wall = starts.replace(tzinfo=None)
elapsed = (datetime.now() - started_wall).total_seconds()
elapsed = max(round(elapsed, 1), 0)
durations = history.get(tid, [])
if not durations:
return None, elapsed, None
avg_duration = sum(durations) / len(durations)
remaining = max(round(avg_duration - elapsed, 1), 0)
return round(avg_duration, 1), elapsed, remaining
# ── 自适应检查间隔 ────────────────────────────────────────────────────
def _next_interval(remaining: float | None) -> float:
if remaining is None:
return 60.0
interval = remaining * 0.15
return max(15.0, min(interval, 300.0))
# ── 输出 ──────────────────────────────────────────────────────────────
def _fmt_sec(sec: float | None) -> str:
if sec is None:
return "N/A"
if sec < 60:
return f"{sec:.0f}s"
if sec < 3600:
return f"{sec/60:.1f}min"
return f"{sec/3600:.1f}h"
def _print_report(
tasks: list[dict[str, Any]],
history: dict[str, list[float]],
next_interval: float,
) -> None:
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
header = f"── Task Watch [{now}] ──"
print(header)
if not tasks:
print(" 无运行中的任务")
print(f" 下次检查: {_fmt_sec(next_interval)}")
return
lines = []
for t in tasks:
avg, elapsed, remaining = _estimate(t, history)
durations = history.get(t["dataset_id"], [])
progress = ""
if avg and elapsed:
pct = min(elapsed / avg * 100, 99.9)
progress = f" {pct:.0f}%"
line = (
f" {t['dataset_id']:20s}"
f" elapsed={_fmt_sec(elapsed):>8s}"
f" avg={_fmt_sec(avg):>8s}"
f" remain={_fmt_sec(remaining):>8s}"
f"{progress}"
f" (历史 {len(durations)} 次)"
)
lines.append(line)
for l in lines:
print(l)
print(f" 下次检查: {_fmt_sec(next_interval)}")
print("" * len(header))
# ── 主循环 ────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(description="自适应任务跟踪监视器")
parser.add_argument("--watch", action="store_true", help="持续监视模式(自动调整检查间隔)")
args = parser.parse_args()
engine = create_engine(settings.pg_sqlalchemy_url())
while True:
with engine.connect() as conn:
history = _load_history(conn)
tasks = _fetch_running_tasks(conn)
interval = 60.0
if tasks:
intervals = []
for t in tasks:
_, _, remaining = _estimate(t, history)
intervals.append(_next_interval(remaining))
interval = min(intervals) if intervals else 60.0
_print_report(tasks, history, interval)
if not args.watch:
break
time.sleep(interval)
if __name__ == "__main__":
main()
+6
View File
@@ -0,0 +1,6 @@
# 每日巡检 webhook 配置(HMAC-SHA256 签名)
# 复制为 daily_check_secrets.env 后填入真实值,文件权限 600
# 失败 / 漏跑时调用(必填,否则只在本地报告)
FAILURE_WEBHOOK_URL=
FAILURE_WEBHOOK_SECRET=
+65
View File
@@ -0,0 +1,65 @@
# syntax=docker/dockerfile:1.6
# ─────────────────────────────────────────────────────────────
# market_sync 数据同步服务
# 单镜像同时跑:FastAPI dashboard + 进程内 schedulerworker
# 部署:docker compose up -d
# ─────────────────────────────────────────────────────────────
# --- builder stage: 装依赖到 venv ---
FROM python:3.11-slim AS builder
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# 仅装编译期需要的系统包(很多 wheel 已预编译,但 psycopg2 / cryptography 可能要)
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 先 copy requirements 单独一层(利用 Docker 缓存:依赖不变就不重装)
COPY requirements.txt .
RUN python -m venv /app/.venv \
&& /app/.venv/bin/pip install --upgrade pip \
&& /app/.venv/bin/pip install -r requirements.txt
# --- runtime stage: 极简基础镜像 + 仅复制 venv 与代码 ---
FROM python:3.11-slim AS runtime
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/app/.venv/bin:${PATH}" \
PYTHONPATH=/app \
# service 模式默认监听
API_HOST=0.0.0.0 \
API_PORT=8100 \
# Docker 模式:单容器内使用进程内 scheduler
RUNTIME_MODE=docker
# runtime 只需要 psycopg2 的运行时库 + tzdatapandas tz aware 需要)
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 tzdata curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 从 builder 复制 venv(已编译好的依赖)
COPY --from=builder /app/.venv /app/.venv
# 复制应用代码
COPY app/ ./app/
COPY bin/ ./bin/
# 默认启动 service 入口(FastAPI + 进程内 scheduler
# 也可覆盖为 cli / worker 单跑某个 task
# docker compose run --rm market_sync python -m app.entrypoints.cli sync kline_daily
EXPOSE 8100
HEALTHCHECK --interval=60s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost:8100/api/health || exit 1
ENTRYPOINT ["/app/bin/service_run.sh"]
+86
View File
@@ -0,0 +1,86 @@
# docker-compose.yml — 一键起 market_sync 数据同步服务
#
# 包含:
# - postgres : PostgreSQL 16market_data schema 的主库)
# - market_sync : 数据同步 + dashboard + scheduler(单进程)
#
# 用法:
# docker compose up -d # 后台启动
# docker compose logs -f market_sync # 看同步日志
# docker compose exec market_sync \
# python -m app.entrypoints.cli sync stock_node --type2 2,3
#
version: "3.9"
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: market_sync
POSTGRES_PASSWORD: market_sync
POSTGRES_DB: market_data
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U market_sync -d market_data"]
interval: 10s
timeout: 3s
retries: 5
market_sync:
build:
context: .
dockerfile: Dockerfile
image: market_sync:latest
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
# 运行模式:Docker 模式使用进程内 scheduler
RUNTIME_MODE: docker
# PG 连接(指向同 compose 的 postgres 服务)
PG_HOST: postgres
PG_PORT: "5432"
PG_USER: market_sync
PG_PASSWORD: market_sync
PG_DB_NAME: market_data
DB_BACKEND: pg
# 调度
SCHEDULER_TIMEZONE: Asia/Shanghai
SCHEDULER_TICK_SECONDS: "5"
SCHEDULER_AUTO_SEED: "true"
# API
API_HOST: 0.0.0.0
API_PORT: "8100"
# 日志
LOG_DIR: /app/logs
LOG_LEVEL: INFO
# 数据源开关
DS_BAOSTOCK_ENABLED: "true"
DS_SINA_ENABLED: "true"
# 凭证(可放进 .env / secrets 文件,避免明文)
MAIRUI_LICENCE: ${MAIRUI_LICENCE:-}
XUEQIU_TOKEN: ${XUEQIU_TOKEN:-}
# 麦蕊限速(钻石 100 RPS 留 buffer;服务化后保守点)
MAIRUI_RPS_LIMIT: "10"
# 节假日
TRADING_HOLIDAYS: ${TRADING_HOLIDAYS:-}
ports:
- "8100:8100"
volumes:
# 持久化日志(任务失败时方便排查)
- sync_logs:/app/logs
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8100/api/health"]
interval: 60s
timeout: 5s
retries: 3
start_period: 30s
volumes:
pgdata:
sync_logs:
+211
View File
@@ -0,0 +1,211 @@
#!/bin/bash
# market_sync systemd 一键部署脚本
#
# 用途:把 deploy/systemd/units/*.service 和 *.timer 同步到 /etc/systemd/system/
# 并自动启用 timer、处理 worker 服务状态。
#
# 运行:sudo bash deploy/systemd/deploy.sh
# sudo bash deploy/systemd/deploy.sh --yes # 跳过确认
#
# 设计原则:
# - 必须 sudo(写 /etc/systemd/system/ + daemon-reload
# - 幂等:重复运行无副作用
# - 显示 plan → 等待确认 → 执行
# - 失败立即中止,不留半套状态
set -e
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
REPO_UNITS_DIR="$PROJECT_ROOT/deploy/systemd/units"
ETC_UNITS_DIR="/etc/systemd/system"
SKIP_CONFIRM=false
# ── 0. 前置检查 ─────────────────────────────────────────────────────────────
if [ "$(id -u)" -ne 0 ]; then
echo "❌ 必须 sudo 运行:sudo bash $0"
exit 1
fi
if [ "${1:-}" = "--yes" ]; then
SKIP_CONFIRM=true
fi
if [ ! -d "$REPO_UNITS_DIR" ]; then
echo "❌ repo unit 目录不存在: $REPO_UNITS_DIR"
exit 1
fi
# ── 1. 计算 plan ────────────────────────────────────────────────────────────
declare -a TO_COPY=() # repo→etc 拷贝
declare -a TO_REMOVE=() # etc 中孤儿(repo 已删)
declare -a TO_RESTART_SVC=() # service 文件变了需要 daemon-reload + restart
# repo 中所有 market-sync* unit
mapfile -t REPO_UNITS < <(find "$REPO_UNITS_DIR" -maxdepth 1 -type f \( -name "*.service" -o -name "*.timer" \) -printf '%f\n' | sort)
# /etc 中所有 market-sync* unit
mapfile -t ETC_UNITS < <(find "$ETC_UNITS_DIR" -maxdepth 1 -type f \( -name "market-sync*.service" -o -name "market-sync*.timer" \) -printf '%f\n' | sort 2>/dev/null || true)
# drift 1: repo 有 /etc 没装
for u in "${REPO_UNITS[@]}"; do
if [ ! -f "$ETC_UNITS_DIR/$u" ]; then
TO_COPY+=("$u")
fi
done
# drift 2: 两边都有但内容不一致
for u in "${REPO_UNITS[@]}"; do
if [ -f "$ETC_UNITS_DIR/$u" ]; then
if ! diff -q "$REPO_UNITS_DIR/$u" "$ETC_UNITS_DIR/$u" > /dev/null 2>&1; then
TO_COPY+=("$u")
if [[ "$u" == *.service ]]; then
TO_RESTART_SVC+=("${u%.service}")
fi
fi
fi
done
# drift 3: /etc 有 repo 没维护(孤儿)
for u in "${ETC_UNITS[@]}"; do
found=0
for r in "${REPO_UNITS[@]}"; do
if [ "$r" = "$u" ]; then found=1; break; fi
done
if [ $found -eq 0 ]; then
TO_REMOVE+=("$u")
fi
done
# ── 2. 显示 plan ────────────────────────────────────────────────────────────
echo "============================================================"
echo "market_sync systemd deploy plan"
echo "============================================================"
echo "Repo units dir: $REPO_UNITS_DIR"
echo
echo "Repo units: ${#REPO_UNITS[@]}"
echo "/etc units: ${#ETC_UNITS[@]}"
echo
if [ ${#TO_COPY[@]} -eq 0 ] && [ ${#TO_REMOVE[@]} -eq 0 ]; then
echo "✅ 无 drift 需要修复。"
else
if [ ${#TO_COPY[@]} -gt 0 ]; then
echo "📋 将要拷贝 (${#TO_COPY[@]} 个):"
for u in "${TO_COPY[@]}"; do
echo " cp deploy/systemd/units/$u → /etc/systemd/system/$u"
done
echo
fi
if [ ${#TO_RESTART_SVC[@]} -gt 0 ]; then
echo "🔄 将要处理 service (${#TO_RESTART_SVC[@]} 个):"
for s in "${TO_RESTART_SVC[@]}"; do
echo " systemctl reset-failed $s.service"
done
echo
fi
if [ ${#TO_REMOVE[@]} -gt 0 ]; then
echo "🗑️ 将要删除 etc 中的孤儿 (${#TO_REMOVE[@]} 个):"
for u in "${TO_REMOVE[@]}"; do
echo " rm /etc/systemd/system/$u"
done
echo
fi
fi
# ── 3. 等待确认 ────────────────────────────────────────────────────────────
if [ "$SKIP_CONFIRM" = false ]; then
read -p "确认执行?[y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "已取消。"
exit 0
fi
fi
# ── 4. 执行 ─────────────────────────────────────────────────────────────────
echo
echo "=== 执行 ==="
# 4a. cp 漂移的 unit
for u in "${TO_COPY[@]}"; do
cp -v "$REPO_UNITS_DIR/$u" "$ETC_UNITS_DIR/$u"
done
# 4b. 删孤儿
for u in "${TO_REMOVE[@]}"; do
rm -v "$ETC_UNITS_DIR/$u"
done
# 4c. reload systemd
echo
echo "=== systemctl daemon-reload ==="
systemctl daemon-reload
# 4d. 停用并 mask worker 服务(systemd 模式下不需要进程内 scheduler)
if systemctl is-enabled market-sync-worker.service >/dev/null 2>&1 || \
systemctl is-active market-sync-worker.service >/dev/null 2>&1; then
echo "=== 关闭 market-sync-worker.servicesystemd 模式下由 timer 触发任务)==="
systemctl stop market-sync-worker.service || true
systemctl disable market-sync-worker.service || true
systemctl mask market-sync-worker.service || true
fi
# 4e. reset-failed + restart 修改过的 service
for s in "${TO_RESTART_SVC[@]}"; do
if systemctl is-enabled "${s}.service" 2>/dev/null | grep -q enabled; then
systemctl reset-failed "${s}.service" || true
echo " reset-failed ${s}.service (由 timer 自然触发)"
fi
done
# 4f. 启用所有 timer(幂等)
echo
echo "=== 启用 timers ==="
for u in "${REPO_UNITS[@]}"; do
if [[ "$u" == *.timer ]]; then
timer="${u%.timer}"
if ! systemctl is-enabled "${timer}.timer" >/dev/null 2>&1; then
systemctl enable "${timer}.timer"
echo " enable ${timer}.timer"
else
echo " ${timer}.timer 已启用"
fi
fi
done
# ── 5. 验证 ────────────────────────────────────────────────────────────────
echo
echo "=== 验证 ==="
echo "Repo units: ${#REPO_UNITS[@]}"
echo "/etc units: $(find "$ETC_UNITS_DIR" -maxdepth 1 -type f \( -name 'market-sync*.service' -o -name 'market-sync*.timer' \) | wc -l)"
drift_count=0
for u in "${REPO_UNITS[@]}"; do
if [ -f "$ETC_UNITS_DIR/$u" ]; then
if ! diff -q "$REPO_UNITS_DIR/$u" "$ETC_UNITS_DIR/$u" > /dev/null 2>&1; then
echo "$u 仍有 drift"
drift_count=$((drift_count + 1))
fi
else
echo "$u 仍未部署"
drift_count=$((drift_count + 1))
fi
done
# 验证 worker 已被 mask
if systemctl is-enabled market-sync-worker.service 2>/dev/null | grep -q masked; then
echo " ✅ market-sync-worker.service 已 masksystemd 模式下不启用进程内 scheduler"
else
echo " ⚠️ market-sync-worker.service 未被 masksystemd 模式下可能双调度器重叠"
fi
echo
if [ $drift_count -eq 0 ]; then
echo "✅ 所有 unit 已对齐,0 drift"
echo
echo "下一步:跑 .venv/bin/python bin/daily_sync_check.py --report-only 验证"
else
echo "❌ 还有 $drift_count 处 drift,请检查"
exit 1
fi
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# ── 停止旧 market-sync ──
sudo systemctl stop market-sync.timer
sudo systemctl disable market-sync.timer
sudo systemctl stop market-sync.service
sudo systemctl disable market-sync.service
# ── 安装新独立 timer+service (4组) ──
cd /home/gao/Development/quant_home/market_sync/deploy/systemd/units
for f in market-sync-kline-index market-sync-kline-daily market-sync-kline-5min market-sync-market-regime; do
sudo cp ${f}.service /etc/systemd/system/
sudo cp ${f}.timer /etc/systemd/system/
done
# ── 更新 morning service:去掉已废弃的 industry_sector ──
# (只用 morning 跑 stock_basic,不再跑 industry_sector
# 修改 morning run 脚本只跑 stock_basic
sudo systemctl daemon-reload
# ── 启动新 timer ──
for f in market-sync-kline-index market-sync-kline-daily market-sync-kline-5min market-sync-market-regime; do
sudo systemctl enable ${f}.timer
sudo systemctl start ${f}.timer
done
echo "=== 完成 ==="
systemctl list-timers --all 2>/dev/null | grep market-sync
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
# 部署 market-sync systemd units 到 /etc/systemd/system/
# 2026-07-09 新增: market-sync-worker.service(长驻进程, 调度 schedule_* 任务)
#
# 用法: sudo bash bin/deploy_systemd.sh
#
# 跑这个脚本会:
# 1. 复制 bin/systemd/market-sync*.{service,timer} 到 /etc/systemd/system/
# 2. systemctl daemon-reload
# 3. enable + start market-sync-worker.service
# 4. 不动已存在的 .timer (它们都已 enable, 重复 enable 无副作用)
set -euo pipefail
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SYSTEMD_SRC="$REPO_DIR/bin/systemd"
SYSTEMD_DST="/etc/systemd/system"
if [ "$(id -u)" -ne 0 ]; then
echo "❌ 必须用 sudo 跑: sudo bash $0" >&2
exit 1
fi
echo "=== 1. 复制 unit files ==="
for f in "$SYSTEMD_SRC"/market-sync*.{service,timer}; do
[ -f "$f" ] || continue
name="$(basename "$f")"
cp -v "$f" "$SYSTEMD_DST/$name"
chmod 644 "$SYSTEMD_DST/$name"
done
echo ""
echo "=== 2. daemon-reload ==="
systemctl daemon-reload
echo ""
echo "=== 3. 启用 + 启动 worker 长驻进程(关键!) ==="
if ! systemctl is-enabled --quiet market-sync-worker.service; then
systemctl enable market-sync-worker.service
fi
# 如果已通过 nohup 启动了, 先 kill 掉避免双跑
if pgrep -f "app\.entrypoints\.worker" >/dev/null; then
echo " ⚠️ 检测到手动启动的 worker 进程, 先 kill"
pkill -TERM -f "app\.entrypoints\.worker" || true
sleep 2
pkill -KILL -f "app\.entrypoints\.worker" 2>/dev/null || true
fi
systemctl restart market-sync-worker.service
sleep 2
systemctl status market-sync-worker.service --no-pager | head -10
echo ""
echo "=== 4. 列出所有 market-sync timer (验证) ==="
systemctl list-timers market-sync* --no-pager 2>&1 | head -20
echo ""
echo "✅ 部署完成"
echo " - 长驻 worker: systemctl status market-sync-worker.service"
echo " - 看任务执行: tail -f logs/worker_manual_*.log (本进程) 或 journalctl -u market-sync-worker -f (systemd 接管后)"
echo " - 巡检: .venv/bin/python bin/daily_sync_check.py"
+185
View File
@@ -0,0 +1,185 @@
#!/bin/bash
# market_sync systemd 一键部署脚本
#
# 用途:把 bin/systemd/*.service 和 *.timer 同步到 /etc/systemd/system/
# 解决 work #04 检测到的 5 处 drift。
#
# 运行:sudo bash bin/systemd_deploy.sh
#
# 设计原则:
# - 必须 sudo(写 /etc/systemd/system/ + daemon-reload
# - 幂等:重复运行无副作用
# - 显示 plan → 等待确认 → 执行
# - 失败立即中止,不留半套状态
set -e
PROJECT_ROOT="/home/gao/Development/quant_home/market_sync"
REPO_UNITS_DIR="$PROJECT_ROOT/bin/systemd"
ETC_UNITS_DIR="/etc/systemd/system"
# ── 0. 前置检查 ─────────────────────────────────────────────────────────────
if [ "$(id -u)" -ne 0 ]; then
echo "❌ 必须 sudo 运行:sudo bash $0"
exit 1
fi
if [ ! -d "$REPO_UNITS_DIR" ]; then
echo "❌ repo unit 目录不存在: $REPO_UNITS_DIR"
exit 1
fi
# ── 1. 计算 plan ────────────────────────────────────────────────────────────
declare -a TO_COPY=() # repo→etc 拷贝
declare -a TO_REMOVE=() # etc 中孤儿(repo 已删)
declare -a TO_RESTART_SVC=() # service 文件变了需要 daemon-reload + restart
# repo 中所有 market-sync* unit
mapfile -t REPO_UNITS < <(find "$REPO_UNITS_DIR" -maxdepth 1 -type f \( -name "*.service" -o -name "*.timer" \) -printf '%f\n' | sort)
# /etc 中所有 market-sync* unit
mapfile -t ETC_UNITS < <(find "$ETC_UNITS_DIR" -maxdepth 1 -type f \( -name "market-sync*.service" -o -name "market-sync*.timer" \) -printf '%f\n' | sort 2>/dev/null || true)
# drift 1: repo 有 /etc 没装
for u in "${REPO_UNITS[@]}"; do
if [ ! -f "$ETC_UNITS_DIR/$u" ]; then
TO_COPY+=("$u")
fi
done
# drift 2: 两边都有但内容不一致
for u in "${REPO_UNITS[@]}"; do
if [ -f "$ETC_UNITS_DIR/$u" ]; then
if ! diff -q "$REPO_UNITS_DIR/$u" "$ETC_UNITS_DIR/$u" > /dev/null 2>&1; then
TO_COPY+=("$u")
if [[ "$u" == *.service ]]; then
TO_RESTART_SVC+=("${u%.service}")
fi
fi
fi
done
# drift 3: /etc 有 repo 没维护(孤儿)
for u in "${ETC_UNITS[@]}"; do
found=0
for r in "${REPO_UNITS[@]}"; do
if [ "$r" = "$u" ]; then found=1; break; fi
done
if [ $found -eq 0 ]; then
TO_REMOVE+=("$u")
fi
done
# ── 2. 显示 plan ────────────────────────────────────────────────────────────
echo "============================================================"
echo "market_sync systemd deploy plan"
echo "============================================================"
echo
echo "Repo units: ${#REPO_UNITS[@]}"
echo "/etc units: ${#ETC_UNITS[@]}"
echo
if [ ${#TO_COPY[@]} -eq 0 ] && [ ${#TO_REMOVE[@]} -eq 0 ]; then
echo "✅ 无 drift 需要修复。无需执行。"
exit 0
fi
if [ ${#TO_COPY[@]} -gt 0 ]; then
echo "📋 将要拷贝 ($(echo "${TO_COPY[@]}" | tr ' ' '\n' | wc -l) 个):"
for u in "${TO_COPY[@]}"; do
echo " cp bin/systemd/$u → /etc/systemd/system/$u"
done
echo
fi
if [ ${#TO_RESTART_SVC[@]} -gt 0 ]; then
echo "🔄 将要重启 service ($(echo "${TO_RESTART_SVC[@]}" | tr ' ' '\n' | wc -l) 个):"
for s in "${TO_RESTART_SVC[@]}"; do
echo " systemctl reset-failed $s.service"
done
echo
fi
if [ ${#TO_REMOVE[@]} -gt 0 ]; then
echo "🗑️ 将要删除 etc 中的孤儿 ($(echo "${TO_REMOVE[@]}" | tr ' ' '\n' | wc -l) 个):"
for u in "${TO_REMOVE[@]}"; do
echo " rm /etc/systemd/system/$u"
done
echo
fi
# ── 3. 等待确认 ────────────────────────────────────────────────────────────
read -p "确认执行?[y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "已取消。"
exit 0
fi
# ── 4. 执行 ─────────────────────────────────────────────────────────────────
echo
echo "=== 执行 ==="
# 4a. cp 漂移的 unit
for u in "${TO_COPY[@]}"; do
cp -v "$REPO_UNITS_DIR/$u" "$ETC_UNITS_DIR/$u"
done
# 4b. 删孤儿
for u in "${TO_REMOVE[@]}"; do
rm -v "$ETC_UNITS_DIR/$u"
done
# 4c. reload systemd
echo
echo "=== systemctl daemon-reload ==="
systemctl daemon-reload
# 4d. reset-failed + restart 修改过的 service
for s in "${TO_RESTART_SVC[@]}"; do
if systemctl is-enabled "${s}.service" 2>/dev/null | grep -q enabled; then
systemctl reset-failed "${s}.service" || true
echo " reset-failed ${s}.service (没自动 restart — 由 timer 自然触发)"
fi
done
# 4e. 启用新 timer(如果有)
for u in "${TO_COPY[@]}"; do
if [[ "$u" == *.timer ]]; then
timer="${u%.timer}"
if systemctl list-unit-files "${timer}.timer" 2>/dev/null | grep -q "${timer}.timer"; then
if ! systemctl is-enabled "${timer}.timer" 2>/dev/null | grep -q enabled; then
systemctl enable "${timer}.timer"
echo " enable ${timer}.timer"
fi
fi
fi
done
# ── 5. 验证 ────────────────────────────────────────────────────────────────
echo
echo "=== 验证 ==="
echo "Repo units: ${#REPO_UNITS[@]}"
echo "/etc units: $(find "$ETC_UNITS_DIR" -maxdepth 1 -type f -name 'market-sync*.service' -o -name 'market-sync*.timer' | wc -l)"
drift_count=0
for u in "${REPO_UNITS[@]}"; do
if [ -f "$ETC_UNITS_DIR/$u" ]; then
if ! diff -q "$REPO_UNITS_DIR/$u" "$ETC_UNITS_DIR/$u" > /dev/null 2>&1; then
echo "$u 仍有 drift"
drift_count=$((drift_count + 1))
fi
else
echo "$u 仍未部署"
drift_count=$((drift_count + 1))
fi
done
echo
if [ $drift_count -eq 0 ]; then
echo "✅ 所有 unit 已对齐,0 drift"
echo
echo "下一步:跑 .venv/bin/python bin/daily_sync_check.py --report-only 验证"
else
echo "❌ 还有 $drift_count 处 drift,请检查"
exit 1
fi
@@ -0,0 +1,31 @@
[Unit]
Description=Market sync daily check — reads dataset_registry, writes report, alerts on failures
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/daily_sync_check.py
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/home/gao/Development/quant_home/market_sync
User=gao
Group=gao
# 巡检:读 PG dataset_registry,对比今日预期,写 logs/daily_check_*.json
# 失败时 POST webhook 到 FAILURE_WEBHOOK_URLHMAC-SHA256 签名)
ExecStart=/home/gao/Development/quant_home/market_sync/.venv/bin/python /home/gao/Development/quant_home/market_sync/bin/daily_sync_check.py
# 巡检应 < 30s 完成;超时 = PG 不通或代码 bug,不让卡死
TimeoutStartSec=120
# 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑)
# 失败时 exit code = 1,可被 OnFailure= 钩住发通知
# 日志走 journaldjournalctl -u market-sync-daily-check -f
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-daily-check
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,15 @@
[Unit]
Description=Schedule market sync daily check — Daily 23:00 Asia/Shanghai
# market-sync-daily-check.service 是这个 timer 的执行单元
# 23:00 触发:周一~五最后一个任务是 longhubang @ 22:00,留 1h buffer
# 周六 23:00 给 stock_node @ 11:30 留 11h+ buffer
[Timer]
# 每天 23:00 触发;Persistent=true 确保关机/错过时下次开机补跑
OnCalendar=*-*-* 23:00:00 Asia/Shanghai
Persistent=true
Unit=market-sync-daily-check.service
# 不要 AccuracySec(默认 1min 漂移够用)
[Install]
WantedBy=timers.target
@@ -0,0 +1,20 @@
[Unit]
Description=Market sync kline_5min — 5分钟K (评分核心)
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_kline_5min_run.sh
After=network-online.target market-sync-kline-daily.service
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/home/gao/Development/quant_home/market_sync
User=gao
Group=gao
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_kline_5min_run.sh
TimeoutStartSec=7200
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-kline-5min
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,10 @@
[Unit]
Description=Schedule kline_5min sync — Mon..Fri 16:00 Asia/Shanghai
[Timer]
OnCalendar=Mon..Fri 16:00:00 Asia/Shanghai
Persistent=true
Unit=market-sync-kline-5min.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,11 @@
[Unit]
Description=Market sync kline_daily — 15:10 麦蕊优先抢跑
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_kline_daily_run.sh
[Timer]
OnCalendar=Mon..Fri 15:10:00
Persistent=false
RandomizedDelaySec=0
[Install]
WantedBy=timers.target
@@ -0,0 +1,20 @@
[Unit]
Description=Market sync kline_daily — 个股日K (评分核心)
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_kline_daily_run.sh
After=network-online.target market-sync-kline-index.service
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/home/gao/Development/quant_home/market_sync
User=gao
Group=gao
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_kline_daily_run.sh
TimeoutStartSec=3600
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-kline-daily
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,10 @@
[Unit]
Description=Schedule kline_daily sync — Mon..Fri 15:40 Asia/Shanghai
[Timer]
OnCalendar=Mon..Fri 15:40:00 Asia/Shanghai
Persistent=true
Unit=market-sync-kline-daily.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,20 @@
[Unit]
Description=Market sync kline_index — 指数日K (独立, 非评分依赖)
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_kline_index_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
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_kline_index_run.sh
TimeoutStartSec=600
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-kline-index
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,10 @@
[Unit]
Description=Schedule kline_index sync — Mon..Fri 15:30 Asia/Shanghai
[Timer]
OnCalendar=Mon..Fri 15:30:00 Asia/Shanghai
Persistent=true
Unit=market-sync-kline-index.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,33 @@
[Unit]
Description=Market data 龙虎榜 sync (akshare 源, daily 22:00)
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_lhb_run.sh
After=network-online.target market-sync.service
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/home/gao/Development/quant_home/market_sync
User=gao
Group=gao
# 入口脚本:单独跑 longhubang 一个 task
# 龙虎榜数据 19:00~21:00 陆续出齐 → 22:00 触发(与 moneyflow 21:35 错开避免并发)
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_lhb_run.sh
# 硬上限 1h(每日 ~200 次 akshare 调用 + DB upsert3-5 分钟内完成,留 buffer
TimeoutStartSec=3600
# 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑——
# 重跑会撞外部 API 限流窗口,浪费资源)
# Restart=no 是 oneshot 默认值
# 日志走 journaldjournalctl -u market-sync-lhb.service -f
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-lhb
# 环境(不读 /etc/environment,只带这几个;.env 由 cli 内部 load_dotenv
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,16 @@
[Unit]
Description=Schedule 龙虎榜 sync — Mon..Fri 22:00 Asia/Shanghai
# market-sync-lhb.service 是这个 timer 的执行单元
# 龙虎榜数据 19:00~21:00 陆续出齐 → 22:00 触发(与 moneyflow 21:35 错开)
[Timer]
# 龙虎榜出齐 → 22:00 触发
OnCalendar=Mon..Fri 22:00:00 Asia/Shanghai
# 系统关机 / 错过执行时,下次开机补跑一次(避免漏数据)
Persistent=true
# 单位(service)的精确名称
Unit=market-sync-lhb.service
# 不要 AccuracySec(默认 1min 漂移够用,避免 22:00:00 整点打堆)
[Install]
WantedBy=timers.target
@@ -0,0 +1,32 @@
[Unit]
Description=Market data MACD/KDJ/BOLL indicators sync (mairui, daily 16:40)
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_mairui_indicators_run.sh
After=network-online.target market-sync.service
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/home/gao/Development/quant_home/market_sync
User=gao
Group=gao
# 入口脚本:单独跑 mairui_indicators 一个 task
# mairui 16:30 后发布指标数据 → 16:40 触发留 buffer
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_mairui_indicators_run.sh
# 硬上限 3h(5204 只 × 3 指标,正常 ~20min,全量时可能更久)
TimeoutStartSec=10800
# 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑)
# Restart=no 是 oneshot 默认值
# 日志走 journald
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-mairui-indicators
# 环境
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,14 @@
[Unit]
Description=Schedule MACD/KDJ/BOLL indicators sync — Mon..Fri 16:40 Asia/Shanghai
# market-sync-mairui-indicators.service 是这个 timer 的执行单元
[Timer]
# mairui 16:30 后发布指标数据 → 16:40 触发留 buffer
OnCalendar=Mon..Fri 16:40:00 Asia/Shanghai
# 系统关机 / 错过执行时,下次开机补跑一次(避免漏数据)
Persistent=true
# 单位(service)的精确名称
Unit=market-sync-mairui-indicators.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,32 @@
[Unit]
Description=Market data MA daily sync (local derivative, daily 16:30)
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_mairui_ma_daily_run.sh
After=network-online.target market-sync.service
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/home/gao/Development/quant_home/market_sync
User=gao
Group=gao
# 入口脚本:单独跑 mairui_ma_daily 一个 task
# 本地派生任务,依赖 kline_stock 已更新
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_mairui_ma_daily_run.sh
# 硬上限 2h(正常 ~30min,留 buffer
TimeoutStartSec=7200
# 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑)
# Restart=no 是 oneshot 默认值
# 日志走 journald
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-mairui-ma-daily
# 环境
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,14 @@
[Unit]
Description=Schedule MA daily sync — Mon..Fri 16:30 Asia/Shanghai
# market-sync-mairui-ma-daily.service 是这个 timer 的执行单元
[Timer]
# kline_daily 15:40 / kline_index 15:30 跑完后,16:30 计算 MA
OnCalendar=Mon..Fri 16:30:00 Asia/Shanghai
# 系统关机 / 错过执行时,下次开机补跑一次(避免漏数据)
Persistent=true
# 单位(service)的精确名称
Unit=market-sync-mairui-ma-daily.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,20 @@
[Unit]
Description=Market sync market_regime — 市场情绪 (本地计算, 非评分依赖)
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_market_regime_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
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_market_regime_run.sh
TimeoutStartSec=600
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-market-regime
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,10 @@
[Unit]
Description=Schedule market_regime sync — Mon..Fri 16:30 Asia/Shanghai
[Timer]
OnCalendar=Mon..Fri 16:30:00 Asia/Shanghai
Persistent=true
Unit=market-sync-market-regime.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,22 @@
[Unit]
Description=market_sync MCP Server — Model Context Protocol for sync data
Documentation=file:///home/gao/Development/quant_home/market_sync/app/mcp_server.py
After=network-online.target postgres-container.service
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/gao/Development/quant_home/market_sync
User=gao
Group=gao
ExecStart=/home/gao/Development/quant_home/market_sync/.venv/bin/python -m app.mcp_server --sse --host 127.0.0.1 --port 8101
Restart=on-failure
RestartSec=5
TimeoutStartSec=30
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-mcp
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,33 @@
[Unit]
Description=Market data moneyflow sync (mairui transaction, daily 21:35)
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_moneyflow_run.sh
After=network-online.target market-sync.service
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/home/gao/Development/quant_home/market_sync
User=gao
Group=gao
# 入口脚本:单独跑 moneyflow 一个 task
# mairui 文档:「更新:每日 21:30」—— 21:35 触发留 5min buffer
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_moneyflow_run.sh
# 硬上限 2h5206 只 × 50 RPS = 104s 理论上限,留 buffer 给重试 / 慢响应)
TimeoutStartSec=7200
# 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑——
# 重跑会撞外部 API 限流窗口,浪费 1.5h)
# Restart=no 是 oneshot 默认值
# 日志走 journaldjournalctl -u market-sync-moneyflow.service -f
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-moneyflow
# 环境(不读 /etc/environment,只带这几个;.env 由 cli 内部 load_dotenv
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,16 @@
[Unit]
Description=Schedule moneyflow sync — Mon..Fri 21:35 Asia/Shanghai
# market-sync-moneyflow.service 是这个 timer 的执行单元
# mairui 文档:「更新:每日 21:30」—— 21:35 触发留 5 分钟 buffer
[Timer]
# mairui 21:30 发布数据 → 21:35 触发
OnCalendar=Mon..Fri 21:35:00 Asia/Shanghai
# 系统关机 / 错过执行时,下次开机补跑一次(避免漏数据)
Persistent=true
# 单位(service)的精确名称
Unit=market-sync-moneyflow.service
# 不要 AccuracySec(默认 1min 漂移够用,避免 21:35:00 整点打堆)
[Install]
WantedBy=timers.target
@@ -0,0 +1,29 @@
[Unit]
Description=Market sync morning pre-market — stock_basic
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_morning_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_basic(确保最新股票列表+股本快照)
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_morning_run.sh
# stock_basic ~9min + buffer = 1h 够用
TimeoutStartSec=3600
# 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑)
# 日志走 journaldjournalctl -u market-sync-morning -f
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-morning
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,14 @@
[Unit]
Description=Schedule market sync morning — Mon..Fri 09:00 Asia/Shanghai
# market-sync-morning.service 是这个 timer 的执行单元
# 早盘前 09:00 触发:跑 stock_basic,给 09:30 开盘留 30min buffer
[Timer]
# A 股开盘 09:3009:00 跑 stock_basic (~9min) 即可
OnCalendar=Mon..Fri 09:00:00 Asia/Shanghai
# 关机/错过时下次开机补跑
Persistent=true
Unit=market-sync-morning.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,31 @@
[Unit]
Description=Market data 股本快照 sync (雪球源, weekly Sat 11:30)
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_share_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
# 入口脚本:单独跑 share_snapshot 一个 task
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_share_run.sh
# 硬上限 30min(全市场 5200 只雪球 quote_detail10 worker 约 3-8min,留 buffer
TimeoutStartSec=1800
# 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑)
# Restart=no 是 oneshot 默认值
# 日志走 journaldjournalctl -u market-sync-share.service -f
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-share
# 环境(不读 /etc/environment,只带这几个;.env 由 cli 内部 load_dotenv
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,16 @@
[Unit]
Description=Schedule share_snapshot sync — Sat 11:30 Asia/Shanghai
# market-sync-share.service 是这个 timer 的执行单元
# 股本快照每周跑一次即可(雪球 quote_detail,周末收盘后数据稳定)
[Timer]
# 周六 11:30(和 stock_node 同时段)
OnCalendar=Sat *-*-* 11:30:00 Asia/Shanghai
# 系统关机 / 错过执行时,下次开机补跑一次(避免漏数据)
Persistent=true
# 单位(service)的精确名称
Unit=market-sync-share.service
# 不要 AccuracySec(默认 1min 漂移够用)
[Install]
WantedBy=timers.target
@@ -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 market-sync-morning.service
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

Some files were not shown because too many files have changed in this diff Show More