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
This commit is contained in:
gao
2026-07-21 16:37:00 +08:00
parent 91e83a3b0b
commit 8f016f25df
94 changed files with 4117 additions and 1176 deletions
+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` | 原始源 | 麦蕊 `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` |
-63
View File
@@ -1,63 +0,0 @@
# 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
# 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"]
Symlink
+1
View File
@@ -0,0 +1 @@
deploy/docker/Dockerfile
+99 -28
View File
@@ -2,7 +2,7 @@
A 股市场数据**定时同步与管理**框架。 A 股市场数据**定时同步与管理**框架。
`akshare` / `baostock` / 新浪 / 雪球 / 麦蕊智数 等多个数据源接入到 MySQL 库,提供: `akshare` / `baostock` / 新浪 / 雪球 / 麦蕊智数 等多个数据源接入到 PostgreSQL 库,提供:
- 后台线程调度器(time-based + interval-based,交易日感知) - 后台线程调度器(time-based + interval-based,交易日感知)
- 多数据源 + 自动降级 + 健康度监控 - 多数据源 + 自动降级 + 健康度监控
- `dataset_registry` 同步状态机(运行中 / 成功 / 失败 / 中断恢复) - `dataset_registry` 同步状态机(运行中 / 成功 / 失败 / 中断恢复)
@@ -11,15 +11,69 @@ A 股市场数据**定时同步与管理**框架。
> 📖 详细架构说明见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) > 📖 详细架构说明见 [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 ```bash
# 1. 准备环境(首次) # 1. 准备环境(首次)
cp .env.example .env # 编辑 .env 填写真实 MySQL 凭据 + XUEQIU_TOKEN cp .env.example .env # 编辑 .env 填写真实 PG 凭据 + XUEQIU_TOKEN + MAIRUI_LICENCE
chmod +x start.sh
./start.sh # 自动创建 .venv + 装依赖 + 启动 APIhttp://localhost:8100
# 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 worker # 仅启动调度器(无 web
./start.sh list # 列出所有同步任务 ./start.sh list # 列出所有同步任务
./start.sh sync kline_daily # 手动触发一次(dataset_id 即可) ./start.sh sync kline_daily # 手动触发一次(dataset_id 即可)
@@ -27,39 +81,54 @@ chmod +x start.sh
./start.sh datasources # 查看数据源健康度 ./start.sh datasources # 查看数据源健康度
./start.sh reseed # 重新 seed 默认配置到 config 表 ./start.sh reseed # 重新 seed 默认配置到 config 表
# 3. 一键全量同步(首次接入) # 4. 一键全量同步(首次接入)
python bin/runall_once.py python bin/runall_once.py
# 4. 跑测试 # 5. 跑测试
.venv/bin/pytest tests/ -v .venv/bin/pytest tests/ -v
``` ```
## 项目结构 ## 项目结构
``` ```
app/ app/ # 应用源码
├── core/ # 核心抽象(config / db / sync / scheduler / datasource base ├── core/ # 核心抽象(config / db / sync / scheduler / datasource base
├── sources/ # 数据源实现5 个) ├── sources/ # 数据源实现
├── tasks/ # 同步任务实现(8 个) ├── tasks/ # 同步任务实现(14 个)
├── api/ # FastAPI 管理接口 ├── api/ # FastAPI 管理接口
└── entrypoints/ # 进程入口(cli + worker └── entrypoints/ # 进程入口(cli + worker
bin/ # 一次性脚本 bin/ # 通用脚本(运行入口、CLI、一次性任务)
docs/ # 架构文档 config/ # 配置文件、secrets
tests/ # 单元测试 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 | 触发时间 | 说明 | | dataset_id | systemd 触发时间 | Docker scheduler 时间 | 说明 |
|---|---|---| |---|---|---|---|
| `stock_basic` | 交易日 09:00 | 全市场股票基础信息 + 股本 | | `stock_basic` | 工作日 09:00 | 09:00 | 全市场股票基础信息 + 股本 |
| `kline_daily` | 交易日 15:40 | 增量日 K 线(OHLCV,多源交叉) | | `kline_index` | 工作日 15:30 | 15:30 | 六大指数日线 |
| `kline_index` | 交易日 15:50 | 六大指数日线 | | `kline_daily` | 工作日 15:30runall| 15:40 | 增量日 K 线(OHLCV,多源交叉) |
| `kline_5min` | 交易日 16:00 | 增量 5 分钟 K 线 | | `kline_5min` | 工作日 15:30runall| 16:00 | 增量 5 分钟 K 线 |
| `moneyflow` | 交易日 16:30 | 资金流(主力/大/中/小单净额 | | `market_regime` | 工作日 15:30runall| 16:15 | 市场情绪(衍生源 |
| `industry_sector` | 周一 09:30 | 股票-行业映射(Baostock | | `share_snapshot` | 工作日 15:30runall| 16:20 | 股本快照(雪球 |
| `share_snapshot` | 交易日 09:30 | 股本快照(雪球 | | `mairui_ma_daily` | 工作日 16:30 | 16:30 | 日 K MA5/10/20/60(本地派生 |
| `market_regime` | 交易日 16:00 | 市场情绪(衍生源 | | `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` 1.`app/tasks/` 新建 `task_xxx.py`,继承 `SyncTask`,设置 `dataset_id`
2.`app/tasks/__init__.py``TASKS` 字典注册 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 ## License
+7 -6
View File
@@ -84,14 +84,15 @@ def data_stats():
from sqlalchemy import func, select from sqlalchemy import func, select
from app.core.db.models import ( from app.core.db.models import (
KlineStock, KlineIndex, Kline5Min, Moneyflow, Share, TickTrade, KlineStock, KlineIndex, Kline5Min, Moneyflow, Share, TickTrade,
SectorIndices, SectorFeaturesDaily, MarketRegimeDaily, MarketRegimeDaily,
LonghubangDaily, LonghubangSeat, Stocks, Industry, LonghubangDaily, LonghubangSeat, Stock,
NodeCategory, Node, StockNodeMap, KlineStockMADaily, NodeCategory, Node, StockNodeMap, KlineStockMADaily,
KlineStockMACDDaily, KlineStockKDJDaily, KlineStockBOLLDaily,
) )
from app.core.db.orm import SessionLocal from app.core.db.orm import SessionLocal
targets = [ targets = [
("stocks", Stocks), ("stocks", Stock),
("kline_stock", KlineStock), ("kline_stock", KlineStock),
("kline_index", KlineIndex), ("kline_index", KlineIndex),
("kline_5min", Kline5Min), ("kline_5min", Kline5Min),
@@ -99,15 +100,15 @@ def data_stats():
("moneyflow", Moneyflow), ("moneyflow", Moneyflow),
("share", Share), ("share", Share),
("tick_trade", TickTrade), ("tick_trade", TickTrade),
("sector_indices", SectorIndices),
("sector_features_daily", SectorFeaturesDaily),
("market_regime_daily", MarketRegimeDaily), ("market_regime_daily", MarketRegimeDaily),
("longhubang_daily", LonghubangDaily), ("longhubang_daily", LonghubangDaily),
("longhubang_seat", LonghubangSeat), ("longhubang_seat", LonghubangSeat),
("industry", Industry),
("node_categories", NodeCategory), ("node_categories", NodeCategory),
("nodes", Node), ("nodes", Node),
("stock_node_map", StockNodeMap), ("stock_node_map", StockNodeMap),
("kline_stock_macd_daily", KlineStockMACDDaily),
("kline_stock_kdj_daily", KlineStockKDJDaily),
("kline_stock_boll_daily", KlineStockBOLLDaily),
] ]
out = [] out = []
with SessionLocal() as s: with SessionLocal() as s:
+6
View File
@@ -30,6 +30,12 @@ if _HAS_DOTENV:
class Settings(BaseSettings): class Settings(BaseSettings):
"""应用配置。所有字段都可以通过环境变量或 .env 文件覆盖。""" """应用配置。所有字段都可以通过环境变量或 .env 文件覆盖。"""
# Runtime mode: "docker" | "systemd"
# docker : 单容器运行,依赖进程内 scheduler 触发所有同步任务
# systemd : Linux 宿主机运行,由 systemd timer/service 触发任务,
# 进程内 scheduler 必须关闭,避免双调度器重叠
runtime_mode: str = "docker"
# Scheduler # Scheduler
scheduler_tick_seconds: int = 5 scheduler_tick_seconds: int = 5
scheduler_timezone: str = "Asia/Shanghai" scheduler_timezone: str = "Asia/Shanghai"
+132 -1
View File
@@ -1,15 +1,146 @@
"""数据源公共工具:代码转换、K 线标准化、日期过滤。 """数据源公共工具:代码转换、K 线标准化、日期过滤。
参考 dashboard/api/services/datasource/fetch_kline.py,但适配新项目。""" 参考 dashboard/api/services/datasource/fetch_kline.py,但适配新项目。
"""
from __future__ import annotations from __future__ import annotations
import logging
import re import re
import threading
from datetime import datetime, time as dtime, timedelta
from typing import Any, Callable, TypeVar
import pandas as pd import pandas as pd
logger = logging.getLogger("datasource.utils")
KLINE_COLS = ["trade_date", "open", "high", "low", "close", "volume"] KLINE_COLS = ["trade_date", "open", "high", "low", "close", "volume"]
KLINE_5MIN_COLS = ["bar_time", "open", "high", "low", "close", "volume", "amount", "turnover_rate"] 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: def to_code6(code: str) -> str:
"""'SH600000' / 'sh.600000' / '600000' / 'SH600000.SH''600000'""" """'SH600000' / 'sh.600000' / '600000' / 'SH600000.SH''600000'"""
+80 -94
View File
@@ -141,7 +141,6 @@ class Stock(ORMBase):
exchange: Mapped[str] = mapped_column(String(8), nullable=False, default="") exchange: Mapped[str] = mapped_column(String(8), nullable=False, default="")
list_date: Mapped[Optional[date]] = mapped_column(Date) list_date: Mapped[Optional[date]] = mapped_column(Date)
listing_status: Mapped[str] = mapped_column(String(16), nullable=False, default="normal") 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) total_share: Mapped[float] = mapped_column(Float, default=0)
float_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) share_updated_at: Mapped[Optional[date]] = mapped_column(Date)
@@ -258,92 +257,7 @@ class Share(ORMBase):
float_share: Mapped[float] = mapped_column(Float, nullable=False) float_share: Mapped[float] = mapped_column(Float, nullable=False)
# ──────────────────────── 10. sectors ──────────────────────── # ──────────────────────── 12. market_regime_daily ────────────────────────
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 ────────────────────────
class MarketRegimeDaily(ORMBase): class MarketRegimeDaily(ORMBase):
"""市场情绪衍生指标(基于本地 kline 聚合)。""" """市场情绪衍生指标(基于本地 kline 聚合)。"""
__tablename__ = "market_regime_daily" __tablename__ = "market_regime_daily"
@@ -599,6 +513,80 @@ class StockNodeMap(ORMBase):
updated_at: Mapped[Optional[datetime]] = _updated_at() 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__ = [ __all__ = [
# 1-2 # 1-2
"Config", "Config",
@@ -612,13 +600,7 @@ __all__ = [
"Kline5Min", "Kline5Min",
"Moneyflow", "Moneyflow",
"Share", "Share",
# 10-12 # 10-11
"Sectors",
"StockSectorMap",
"Industry",
# 13-15
"SectorIndices",
"SectorFeaturesDaily",
"MarketRegimeDaily", "MarketRegimeDaily",
# 16 # 16
"TickTrade", "TickTrade",
@@ -631,4 +613,8 @@ __all__ = [
"NodeCategory", "NodeCategory",
"Node", "Node",
"StockNodeMap", "StockNodeMap",
# 22-24 (2026-07-08 mairui 技术指标 MACD/KDJ/BOLL - 日 K 级别)
"KlineStockMACDDaily",
"KlineStockKDJDaily",
"KlineStockBOLLDaily",
] ]
+94 -157
View File
@@ -22,10 +22,12 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert
from app.core.db.models import ( from app.core.db.models import (
Config, Config,
DatasetRegistry, DatasetRegistry,
Industry,
Kline5Min, Kline5Min,
KlineIndex, KlineIndex,
KlineStock, KlineStock,
KlineStockBOLLDaily,
KlineStockKDJDaily,
KlineStockMACDDaily,
KlineStockMADaily, KlineStockMADaily,
LonghubangDaily, LonghubangDaily,
LonghubangSeat, LonghubangSeat,
@@ -34,13 +36,9 @@ from app.core.db.models import (
Moneyflow, Moneyflow,
Node, Node,
NodeCategory, NodeCategory,
SectorFeaturesDaily,
SectorIndices,
Sectors,
Share, Share,
Stock, Stock,
StockNodeMap, StockNodeMap,
StockSectorMap,
SyncHistory, SyncHistory,
TickTrade, TickTrade,
) )
@@ -451,19 +449,16 @@ def upsert_stock(
exchange: str, exchange: str,
list_date: str = "", list_date: str = "",
listing_status: str = "normal", listing_status: str = "normal",
industry: str = "",
) -> None: ) -> None:
"""upsert 一只股票基础信息(单条)。 """upsert 一只股票基础信息(单条)。
注意:MySQL 9.7.0 在 ON DUPLICATE KEY UPDATE 阶段对 DATE 字段的 VALUES()/new.col 注意:MySQL 9.7.0 在 ON DUPLICATE KEY UPDATE 阶段对 DATE 字段的 VALUES()/new.col
求值存在 bug(会强制把空串塞进去,触发 1292 严格模式错误)。所以这里 求值存在 bug(会强制把空串塞进去,触发 1292 严格模式错误)。所以这里
- list_date 字段在首次 INSERT 时写入;UPDATE 分支不更新(保持原值或 NULL) list_date 字段在首次 INSERT 时写入;UPDATE 分支不更新(保持原值或 NULL)
- industry 字段也避开这个 bug
批量写入请用 upsert_stocks_bulk(),性能高 10-20 倍。 批量写入请用 upsert_stocks_bulk(),性能高 10-20 倍。
""" """
del list_date # 显式不接受 list_date 更新(旧值保留) del list_date # 显式不接受 list_date 更新(旧值保留)
del industry # 同上
with get_session() as s: with get_session() as s:
stmt = _pg_upsert( stmt = _pg_upsert(
Stock, Stock,
@@ -477,7 +472,7 @@ def upsert_stock(
def upsert_stocks_bulk(rows: list[dict[str, Any]], chunk_size: int = 500) -> int: def upsert_stocks_bulk(rows: list[dict[str, Any]], chunk_size: int = 500) -> int:
"""批量 upsert 股票基础信息。 """批量 upsert 股票基础信息。
每条 row 需有: code, name, exchange, listing_status(可选 list_date / industry 每条 row 需有: code, name, exchange, listing_status(可选 list_date
性能:~5000 只股票从 ~50s 降到 ~3s。 性能:~5000 只股票从 ~50s 降到 ~3s。
""" """
if not rows: if not rows:
@@ -499,7 +494,7 @@ def upsert_stocks_bulk(rows: list[dict[str, Any]], chunk_size: int = 500) -> int
stmt = _pg_upsert( stmt = _pg_upsert(
Stock, values, Stock, values,
conflict_keys=["code"], conflict_keys=["code"],
update_cols=["name", "exchange", "list_date", "listing_status", "industry"], update_cols=["name", "exchange", "list_date", "listing_status"],
) )
s.execute(stmt) s.execute(stmt)
total += len(chunk) total += len(chunk)
@@ -507,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: 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: with get_session() as s:
snap_date = _to_date_str(trade_date) if trade_date else None
s.execute( s.execute(
update(Stock) update(Stock)
.where(Stock.code == code) .where(Stock.code == code)
.values( .values(
total_share=float(total_share), total_share=float(total_share),
float_share=float(float_share), float_share=float(float_share),
share_updated_at=snap_date, share_updated_at=date.today(), # "拉取日",不是"数据日"
) )
) )
@@ -543,7 +548,6 @@ def _stock_row_to_dict(r: Stock) -> dict[str, Any]:
"exchange": r.exchange or "", "exchange": r.exchange or "",
"list_date": _to_date_str(r.list_date), "list_date": _to_date_str(r.list_date),
"listing_status": r.listing_status or "normal", "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, "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, "float_share": r.float_share if r.float_share is not None else 0,
"share_updated_at": _to_date_str(r.share_updated_at), "share_updated_at": _to_date_str(r.share_updated_at),
@@ -674,9 +678,13 @@ def get_kline_5min_snapshots() -> dict[str, Optional[datetime]]:
返回:{stock_code (6位): max(bar_time) 或 None} 返回:{stock_code (6位): max(bar_time) 或 None}
用于 kline_5min 任务的"全量/增量"统一规划。 用于 kline_5min 任务的"全量/增量"统一规划。
DB 里 stock_code 形如 "000001.SZ"mairui 写入格式),函数剥掉 DB 里 stock_code 是 hermes 格式 "SH600000"带 SH/SZ/BJ 前缀,无点),
交易所后缀统一为 6 位。bar_time 是 DATETIME 字段,SQLAlchemy 2.x 2026-07-09 之前误以为 mairui 原始格式 "000001.SZ",用 split(".")[0] 剥
native 返回 datetime;如果是 str(方言边界情况)手动解析 不到 → 全部股票被 skip → task 把整市场当"全量"重跑(实际是增量)
修:用正则剥 SH/SZ/BJ 前缀。
bar_time 是 DATETIME 字段,SQLAlchemy 2.x native 返回 datetime;如果是
str(方言边界情况)手动解析。
""" """
_ensure_schema() _ensure_schema()
out: dict[str, Optional[datetime]] = {} out: dict[str, Optional[datetime]] = {}
@@ -686,7 +694,13 @@ def get_kline_5min_snapshots() -> dict[str, Optional[datetime]]:
.group_by(Kline5Min.stock_code) .group_by(Kline5Min.stock_code)
).all() ).all()
for raw_code, latest in rows: 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(): if len(code6) != 6 or not code6.isdigit():
continue continue
if isinstance(latest, datetime): if isinstance(latest, datetime):
@@ -834,143 +848,6 @@ def replace_all_stock_node_map(rows: list[dict[str, Any]]) -> None:
s.execute(stmt) s.execute(stmt)
# ── 行业 / 概念板块 ─────────────────────────────────────────────────────
def replace_all_industries(rows: list[dict[str, Any]]) -> None:
"""全量替换 industry 表。rows: code, industry_name, industry_classification, update_date"""
if not rows:
return
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"])
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"""
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),
}
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)
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
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"])
s.execute(stmt)
# ── 市场情绪(衍生)───────────────────────────────────────────────────── # ── 市场情绪(衍生)─────────────────────────────────────────────────────
@@ -1031,6 +908,51 @@ def upsert_kline_stock_ma_daily_rows(rows: list[dict[str, Any]]) -> None:
s.execute(stmt) 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: def pd_isna(v: Any) -> bool:
"""避免直接 import pandas(开销大),手写 nan/None 检查。""" """避免直接 import pandas(开销大),手写 nan/None 检查。"""
if v is None: if v is None:
@@ -1042,11 +964,26 @@ def pd_isna(v: Any) -> bool:
def get_stock_kline_max_date(code: str) -> Optional[str]: 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() _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: with get_session() as s:
d = s.execute( d = s.execute(
select(func.max(KlineStock.trade_date)) select(func.max(KlineStock.trade_date))
.where(KlineStock.stock_code == code) .where(KlineStock.stock_code.in_(candidates))
).scalar() ).scalar()
return _to_date_str(d) if d else None return _to_date_str(d) if d else None
+13 -24
View File
@@ -323,17 +323,6 @@ DEFAULT_SCHEDULES: list[tuple[str, dict, str]] = [
}, },
"每个交易日 21:35 拉取个股资金流(mairui 21:30 发布)", "每个交易日 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", "schedule_market_regime",
{ {
@@ -345,17 +334,6 @@ DEFAULT_SCHEDULES: list[tuple[str, dict, str]] = [
}, },
"每个交易日 16:15 聚合市场情绪(基于本地日K线)", "每个交易日 16:15 聚合市场情绪(基于本地日K线)",
), ),
(
"schedule_share_snapshot",
{
"name": "盘后股本快照",
"time": "16:20",
"condition": "trading_day",
"job": "share_snapshot",
"enabled": True,
},
"每个交易日 16:20 刷新个股总股本/流通股本快照",
),
( (
"schedule_longhubang", "schedule_longhubang",
{ {
@@ -389,6 +367,17 @@ DEFAULT_SCHEDULES: list[tuple[str, dict, str]] = [
}, },
"每个交易日 16:30 基于 kline_stock 计算 MA5/10/20/60 (本地派生,~30s @5213 只)", "每个交易日 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 指标 × 全市场,增量约数分钟)",
),
] ]
@@ -428,8 +417,8 @@ def register_sync_jobs() -> None:
for dataset_id in [ for dataset_id in [
"stock_basic", "kline_daily", "kline_index", "kline_5min", "stock_basic", "kline_daily", "kline_index", "kline_5min",
"moneyflow", "industry_sector", "share_snapshot", "market_regime", "moneyflow", "share_snapshot", "market_regime",
"longhubang", "stock_node", "mairui_ma_daily", "longhubang", "stock_node", "mairui_ma_daily", "mairui_indicators",
]: ]:
def _make_job(did=dataset_id): def _make_job(did=dataset_id):
+7 -7
View File
@@ -34,13 +34,13 @@ def _is_market_closed() -> bool:
def _effective_sync_end() -> str: def _effective_sync_end() -> str:
"""若盘后则今天,否则昨天。""" """若盘后则今天,否则昨天。
today = datetime.now()
if today.time() >= dtime(15, 30): 委派到 app.core.datasource.utils.effective_market_date() 统一实现,
return today.strftime("%Y-%m-%d") 该函数加载交易日历,会跳过节假日返回最近交易日。
# 昨天 """
from datetime import timedelta from app.core.datasource.utils import effective_market_date
return (today - timedelta(days=1)).strftime("%Y-%m-%d") return effective_market_date()
class SyncTask: class SyncTask:
+12 -24
View File
@@ -94,30 +94,6 @@ SYNC_DEFINITIONS: list[dict[str, Any]] = [
"dependency_ids": ["stock_basic"], "dependency_ids": ["stock_basic"],
"sort_order": 50, "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", "dataset_id": "share_snapshot",
"name": "股本快照", "name": "股本快照",
@@ -178,6 +154,18 @@ SYNC_DEFINITIONS: list[dict[str, Any]] = [
"dependency_ids": ["kline_daily"], "dependency_ids": ["kline_daily"],
"sort_order": 90, "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,
},
] ]
+24
View File
@@ -30,7 +30,15 @@ def start_scheduler_thread() -> None:
给 service_run.sh 用:在 uvicorn 同进程跑后台 scheduler,避免 给 service_run.sh 用:在 uvicorn 同进程跑后台 scheduler,避免
docker 一个容器跑两个进程(supervisord / s6 那种方案)。 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 # 1. 注册数据源 + seed config
from app.core.datasource.registry import ( from app.core.datasource.registry import (
build_default_registry, build_default_registry,
@@ -74,6 +82,22 @@ def start_scheduler_thread() -> None:
def main(): def main():
logger.info("[worker] market_data_sync worker 启动") 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 # 1. 注册数据源 + seed config
from app.core.datasource.registry import build_default_registry, seed_datasource_configs from app.core.datasource.registry import build_default_registry, seed_datasource_configs
+53 -10
View File
@@ -34,6 +34,7 @@ if str(_PROJECT_ROOT) not in sys.path:
# ── MCP server bootstrap ──────────────────────────────────────────── # ── MCP server bootstrap ────────────────────────────────────────────
from mcp.server import Server from mcp.server import Server
from mcp.server.sse import SseServerTransport
from mcp.server.stdio import stdio_server from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent from mcp.types import Tool, TextContent
@@ -125,9 +126,10 @@ def list_datasets() -> list[dict]:
from sqlalchemy import func, select from sqlalchemy import func, select
from app.core.db.models import ( from app.core.db.models import (
KlineStock, KlineIndex, Kline5Min, Moneyflow, Share, TickTrade, KlineStock, KlineIndex, Kline5Min, Moneyflow, Share, TickTrade,
SectorIndices, SectorFeaturesDaily, MarketRegimeDaily, MarketRegimeDaily,
LonghubangDaily, LonghubangSeat, Stock, Industry, LonghubangDaily, LonghubangSeat, Stock,
NodeCategory, Node, StockNodeMap, KlineStockMADaily, NodeCategory, Node, StockNodeMap, KlineStockMADaily,
KlineStockMACDDaily, KlineStockKDJDaily, KlineStockBOLLDaily,
) )
from app.core.db.orm import SessionLocal from app.core.db.orm import SessionLocal
@@ -140,15 +142,15 @@ def list_datasets() -> list[dict]:
("moneyflow", Moneyflow), ("moneyflow", Moneyflow),
("share", Share), ("share", Share),
("tick_trade", TickTrade), ("tick_trade", TickTrade),
("sector_indices", SectorIndices),
("sector_features_daily", SectorFeaturesDaily),
("market_regime_daily", MarketRegimeDaily), ("market_regime_daily", MarketRegimeDaily),
("longhubang_daily", LonghubangDaily), ("longhubang_daily", LonghubangDaily),
("longhubang_seat", LonghubangSeat), ("longhubang_seat", LonghubangSeat),
("industry", Industry),
("node_categories", NodeCategory), ("node_categories", NodeCategory),
("nodes", Node), ("nodes", Node),
("stock_node_map", StockNodeMap), ("stock_node_map", StockNodeMap),
("kline_stock_macd_daily", KlineStockMACDDaily),
("kline_stock_kdj_daily", KlineStockKDJDaily),
("kline_stock_boll_daily", KlineStockBOLLDaily),
] ]
out = [] out = []
with SessionLocal() as s: with SessionLocal() as s:
@@ -175,9 +177,10 @@ def get_dataset_info(table_name: str) -> dict:
allowed = { allowed = {
"stocks", "kline_stock", "kline_index", "kline_5min", "stocks", "kline_stock", "kline_index", "kline_5min",
"kline_stock_ma_daily", "moneyflow", "share", "tick_trade", "kline_stock_ma_daily", "moneyflow", "share", "tick_trade",
"sector_indices", "sector_features_daily", "market_regime_daily", "market_regime_daily",
"longhubang_daily", "longhubang_seat", "industry", "longhubang_daily", "longhubang_seat",
"node_categories", "nodes", "stock_node_map", "node_categories", "nodes", "stock_node_map",
"kline_stock_macd_daily", "kline_stock_kdj_daily", "kline_stock_boll_daily",
"dataset_registry", "sync_history", "config", "dataset_registry", "sync_history", "config",
} }
if table_name not in allowed: if table_name not in allowed:
@@ -291,11 +294,51 @@ async def call_tool(name: str, arguments: dict):
# ── main ──────────────────────────────────────────────────────────── # ── main ────────────────────────────────────────────────────────────
async def main(): async def main_sse(host: str = "127.0.0.1", port: int = 8101):
# 启动时注册数据源(让 datasource.health_check 之类方法可用) """启动 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 from app.core.datasource.registry import build_default_registry
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): async with stdio_server() as (read_stream, write_stream):
await server.run( await server.run(
read_stream, read_stream,
@@ -305,4 +348,4 @@ async def main():
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) main()
+63 -1
View File
@@ -32,7 +32,7 @@ from app.core.datasource.utils import (
class MairuiSource(DataSource): class MairuiSource(DataSource):
key = "datasource_mairui" key = "datasource_mairui"
name = "麦蕊智数(mairui.club" name = "麦蕊智数(mairui.club"
provides = ["kline_daily", "kline_5min", "index_daily", "stock_basic", "moneyflow", "tick_trade", "stock_node"] provides = ["kline_daily", "kline_5min", "index_daily", "stock_basic", "moneyflow", "tick_trade", "stock_node", "indicator_daily"]
requires_credential = True requires_credential = True
credential_key = "MAIRUI_LICENCE" credential_key = "MAIRUI_LICENCE"
@@ -285,6 +285,68 @@ class MairuiSource(DataSource):
df = df[df["bar_time"] < pd.Timestamp(end) + pd.Timedelta(days=1)] df = df[df["bar_time"] < pd.Timestamp(end) + pd.Timedelta(days=1)]
return df 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: def fetch_index_daily(self, index_code: str, start: str, end: str) -> pd.DataFrame:
"""指数日 K。index_code 用 mairui 格式(如 '000300.SH')。""" """指数日 K。index_code 用 mairui 格式(如 '000300.SH')。"""
lic = self._get_licence() lic = self._get_licence()
+31 -7
View File
@@ -16,7 +16,7 @@ from typing import Any, Optional
import pandas as pd import pandas as pd
from app.core.datasource.base import DataSource 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") logger = logging.getLogger("sync.xueqiu")
@@ -94,7 +94,11 @@ class XueqiuSource(DataSource):
try: try:
ball = self._import_ball() ball = self._import_ball()
self._set_token_once() 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: if result is None or result.get("error_code") != 0:
return { return {
"success": False, "success": False,
@@ -123,12 +127,23 @@ class XueqiuSource(DataSource):
count = min(max((end_dt - start_dt).days + 60, 10), 5000) 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 result = None
for attempt in range(3): 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: if result and result.get("error_code") == 0:
break 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: if not result or result.get("error_code") != 0:
logger.warning( logger.warning(
"[kline %s] 雪球 kline 失败: error_code=%s desc=%s", "[kline %s] 雪球 kline 失败: error_code=%s desc=%s",
@@ -177,7 +192,12 @@ class XueqiuSource(DataSource):
self._wait_rps() self._wait_rps()
symbol = code6_to_xueqiu(code6) 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: if not result or result.get("error_code") != 0:
logger.warning( logger.warning(
"[share %s] 雪球 quote_detail 失败: error_code=%s desc=%s", "[share %s] 雪球 quote_detail 失败: error_code=%s desc=%s",
@@ -187,14 +207,18 @@ class XueqiuSource(DataSource):
) )
return None return None
quote = result.get("data", {}).get("quote", {}) 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) total = round(float(quote.get("total_shares") or 0) / 1e8, 4)
flt = round(float(quote.get("float_shares") or 0) / 1e8, 4) flt = round(float(quote.get("float_shares") or 0) / 1e8, 4)
if total <= 0 or flt <= 0: if total <= 0 or flt <= 0:
logger.warning("[share %s] 雪球返回成功但 total/float 为 0", code6) logger.warning("[share %s] 雪球返回成功但 total/float 为 0", code6)
return None return None
return { return {
"trade_date": today_str, "trade_date": as_of,
"total_share": total, "total_share": total,
"float_share": flt, "float_share": flt,
} }
+2 -4
View File
@@ -3,15 +3,14 @@
所有 `SyncTask` 子类在此集中注册,外部通过 `get_task(dataset_id)` 或 所有 `SyncTask` 子类在此集中注册,外部通过 `get_task(dataset_id)` 或
遍历 `TASKS` 字典使用。 遍历 `TASKS` 字典使用。
""" """
from app.tasks.task_industry_sector import SyncIndustrySector
from app.tasks.task_kline_5min import SyncKline5Min from app.tasks.task_kline_5min import SyncKline5Min
from app.tasks.task_kline_daily import SyncKlineDaily from app.tasks.task_kline_daily import SyncKlineDaily
from app.tasks.task_kline_index import SyncKlineIndex from app.tasks.task_kline_index import SyncKlineIndex
from app.tasks.task_longhubang import SyncLonghubang from app.tasks.task_longhubang import SyncLonghubang
from app.tasks.task_mairui_ma_daily import SyncMairuiMADaily 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_market_regime import SyncMarketRegime
from app.tasks.task_moneyflow import SyncMoneyflow 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_share_snapshot import SyncShareSnapshot
from app.tasks.task_stock_node import SyncStockNode from app.tasks.task_stock_node import SyncStockNode
from app.tasks.task_stocks_basic import SyncStocksBasic from app.tasks.task_stocks_basic import SyncStocksBasic
@@ -26,13 +25,12 @@ TASKS: dict[str, type] = {
SyncKline5Min, SyncKline5Min,
SyncTickTrade, SyncTickTrade,
SyncMoneyflow, SyncMoneyflow,
SyncIndustrySector,
SyncSectorFeatures,
SyncShareSnapshot, SyncShareSnapshot,
SyncMarketRegime, SyncMarketRegime,
SyncLonghubang, SyncLonghubang,
SyncStockNode, SyncStockNode,
SyncMairuiMADaily, 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,
}
+163 -8
View File
@@ -17,7 +17,7 @@ from __future__ import annotations
import os import os
import time 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 datetime import datetime, timedelta, timezone
from typing import Any, Optional from typing import Any, Optional
@@ -37,6 +37,10 @@ MAIRUI_5MIN_FLOOR = datetime(2023, 6, 14, tzinfo=timezone.utc)
WINDOW_DAYS = 365 WINDOW_DAYS = 365
# 增量时往前多取的天数(防交易日历边界漏当天) # 增量时往前多取的天数(防交易日历边界漏当天)
INCREMENT_OVERLAP_DAYS = 2 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 = 120.0 # 5min K 一只拉多年,单只允许更久
class SyncKline5Min(SyncTask): class SyncKline5Min(SyncTask):
@@ -99,7 +103,12 @@ class SyncKline5Min(SyncTask):
# ── 2) 内存算计划 ── # ── 2) 内存算计划 ──
# PG TIMESTAMPTZ 是 timezone-aware,所以 end 也必须 tz-aware 否则比较会炸 # 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) plans = self._plan(stock_codes, end, snapshots)
if not plans: if not plans:
msg = f"5min K线 — 全部 {len(stock_codes)} 只都已最新(无需同步)" msg = f"5min K线 — 全部 {len(stock_codes)} 只都已最新(无需同步)"
@@ -129,12 +138,17 @@ class SyncKline5Min(SyncTask):
t0 = time.time() t0 = time.time()
ok_cnt = fail_cnt = 0 ok_cnt = fail_cnt = 0
rows_total = 0 rows_total = 0
with ThreadPoolExecutor(max_workers=max_workers) as pool: # 不用 `with ThreadPoolExecutor(...) as pool:` — 它的 __exit__ 默认 wait=True,
futures = {pool.submit(self._sync_one, primary, p[0], p[1], p[2]): p[0] for p in plans} # 一旦某个 worker 卡在 xueqiu IO,主线程会在 as_completed 触发 60s timeout 后
for i, future in enumerate(as_completed(futures), 1): # 仍被 __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] c6 = futures[future]
try: try:
res = future.result() res = future.result(timeout=0.1) # 已被 as_completed 释放
if res["status"] == "ok": if res["status"] == "ok":
ok_cnt += 1 ok_cnt += 1
rows_total += res["rows"] rows_total += res["rows"]
@@ -142,6 +156,9 @@ class SyncKline5Min(SyncTask):
fail_cnt += 1 fail_cnt += 1
if res.get("error"): if res.get("error"):
logger.warning(f"[5min {c6}] {res['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: except Exception as e:
fail_cnt += 1 fail_cnt += 1
logger.warning(f"[5min {c6}] {e}") logger.warning(f"[5min {c6}] {e}")
@@ -150,6 +167,64 @@ class SyncKline5Min(SyncTask):
message=f"5min 进度 {i}/{len(plans)} OK:{ok_cnt} FAIL:{fail_cnt}", message=f"5min 进度 {i}/{len(plans)} OK:{ok_cnt} FAIL:{fail_cnt}",
current=i, total=len(plans), current_step=c6, 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) elapsed = round(time.time() - t0, 1)
msg = ( msg = (
f"5min K线 {ok_cnt}{fail_cnt}败 共{rows_total}行, " f"5min K线 {ok_cnt}{fail_cnt}败 共{rows_total}行, "
@@ -192,10 +267,16 @@ class SyncKline5Min(SyncTask):
}) })
cur = w_end + timedelta(days=1) cur = w_end + timedelta(days=1)
except Exception as e: 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: 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: try:
CHUNK = 5000 CHUNK = 5000
for i in range(0, len(all_rows), CHUNK): for i in range(0, len(all_rows), CHUNK):
@@ -203,3 +284,77 @@ class SyncKline5Min(SyncTask):
except Exception as e: except Exception as e:
return {"status": "fail", "error": f"db write: {e}"} return {"status": "fail", "error": f"db write: {e}"}
return {"status": "ok", "rows": len(all_rows)} 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
+152 -29
View File
@@ -19,6 +19,7 @@ import os
import queue import queue
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeoutError
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import Any
@@ -32,15 +33,19 @@ from app.core.utils.logging import get_logger
logger = get_logger("sync.kline_daily") logger = get_logger("sync.kline_daily")
# 源优先级:mairui5 RPS,免费 + 稳定 + 单接口含5min)→ 雪球 → Baostock → 新浪 # 源优先级:麦蕊 → 雪球 → 新浪
# 经验:sina 在单日窗口 (start=end=某天) 会返回空,雪球更可靠 # 2026-07-21 改: 麦蕊15:10已有数据,雪球要15:40后才齐,麦蕊为主源提速
PRIMARY_PRIORITY = ["datasource_xueqiu", "datasource_mairui", "datasource_xinlang"] PRIMARY_PRIORITY = ["datasource_mairui", "datasource_xueqiu", "datasource_xinlang"]
DEFAULT_START = "2018-01-01" DEFAULT_START = "2018-01-01"
# 写库批量:攒够 BATCH_SIZE 行就 executemany 一次 + commit # 写库批量:攒够 BATCH_SIZE 行就 executemany 一次 + commit
BATCH_SIZE = 2000 BATCH_SIZE = 2000
# fetcher 并发:实测单线程最稳(5000 只 × 0.2s = 17 分钟,足够) # fetcher 并发:实测单线程最稳(5000 只 × 0.2s = 17 分钟,足够)
# 雪球 token 限速 10 RPS,单线程 0.1s/只 已经打满。多 worker 会触发风控 hang # 雪球 token 限速 10 RPS,单线程 0.1s/只 已经打满。多 worker 会触发风控 hang
DEFAULT_FETCH_WORKERS = 1 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): class SyncKlineDaily(SyncTask):
@@ -57,14 +62,15 @@ class SyncKlineDaily(SyncTask):
max_workers: int = DEFAULT_FETCH_WORKERS, max_workers: int = DEFAULT_FETCH_WORKERS,
**kwargs, **kwargs,
) -> dict[str, Any]: ) -> dict[str, Any]:
# 1. 选主源 # 1. 获取所有可用源(按优先级),支持 per-stock fallback
primary = self._pick_primary() sources = self._get_available_sources()
if primary is None: if not sources:
return {"status": "error", "message": "所有 K 线数据源均不可用"} return {"status": "error", "message": "所有 K 线数据源均不可用"}
source_names = ", ".join(s.name for s in sources)
self._progress( self._progress(
message=f"使用 {primary.name} 同步日K线 (fetcher={max_workers}, batch={BATCH_SIZE})...", message=f"使用 [{source_names}] 同步日K线 (fetcher={max_workers}, batch={BATCH_SIZE})...",
current_step=primary.key, current_step=sources[0].key,
) )
# 2. 拉股票列表 # 2. 拉股票列表
@@ -108,7 +114,6 @@ class SyncKlineDaily(SyncTask):
) )
# 4. 并发 fetch + 攒 batch 写库(线程安全) # 4. 并发 fetch + 攒 batch 写库(线程安全)
# mairui 等外部 API 无并发问题;DB 写串行加锁
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
t0 = time.time() t0 = time.time()
batch_lock = threading.Lock() batch_lock = threading.Lock()
@@ -116,6 +121,7 @@ class SyncKlineDaily(SyncTask):
pending_codes: dict[str, str] = {} pending_codes: dict[str, str] = {}
ok_lock = threading.Lock() ok_lock = threading.Lock()
ok_cnt = [0] ok_cnt = [0]
fallback_ok_cnt = [0]
fail_cnt = [0] fail_cnt = [0]
rows_cnt = [0] rows_cnt = [0]
@@ -138,15 +144,7 @@ class SyncKlineDaily(SyncTask):
except Exception as e: except Exception as e:
logger.warning(f"update synced_at 失败 {c6}: {e}") logger.warning(f"update synced_at 失败 {c6}: {e}")
def _fetch_one(code6: str, fetch_start: str) -> tuple[str, list[dict] | None, str | None]: def _rows_from_df(code6: str, df: pd.DataFrame) -> tuple[list[dict], str]:
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 失败。现统一。
hermes = to_hermes(code6) hermes = to_hermes(code6)
rows = [] rows = []
for _, r in df.iterrows(): for _, r in df.iterrows():
@@ -162,20 +160,56 @@ class SyncKlineDaily(SyncTask):
}) })
latest = df["trade_date"].max() latest = df["trade_date"].max()
latest = latest.strftime("%Y-%m-%d") if hasattr(latest, "strftime") else str(latest)[:10] 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) total = len(jobs)
with ThreadPoolExecutor(max_workers=max_workers) as pool: # 不用 `with ThreadPoolExecutor(...) as pool:` — 它的 __exit__ 默认 wait=True,
futures = {pool.submit(_fetch_one, c6, fs): c6 for c6, fs in jobs} # 一旦某个 worker 卡在 xueqiu IO,主线程会在 as_completed 触发 timeout 后
done_cnt = 0 # 仍被 __exit__ 阻塞等 worker 退出 → 进程挂死(2026-07-09 13:21 教训)。
for future in as_completed(futures): # 改成手动管理 + 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 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: if rows is None:
with ok_lock: with ok_lock:
fail_cnt[0] += 1 fail_cnt[0] += 1
logger.warning(f"[kline {code6}] {latest}") logger.warning(f"[kline {code6}] {latest}")
continue continue
if is_fallback:
with ok_lock:
fallback_ok_cnt[0] += 1
with batch_lock: with batch_lock:
batch_rows.extend(rows) batch_rows.extend(rows)
pending_codes[code6] = latest pending_codes[code6] = latest
@@ -194,32 +228,121 @@ class SyncKlineDaily(SyncTask):
message=f"进度 {done_cnt}/{total} OK:{ok_cnt[0]} FAIL:{fail_cnt[0]} {rate:.1f}只/秒", message=f"进度 {done_cnt}/{total} OK:{ok_cnt[0]} FAIL:{fail_cnt[0]} {rate:.1f}只/秒",
current=done_cnt, total=total, current_step=code6, 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
_flush() _flush()
elapsed = round(time.time() - t0, 1) 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 { return {
"status": "ok" if fail_cnt[0] == 0 else "warning", "status": "ok" if fail_cnt[0] == 0 else "warning",
"message": msg, "message": msg,
"ok": ok_cnt[0], "ok": ok_cnt[0],
"fail": fail_cnt[0], "fail": fail_cnt[0],
"fallback_ok": fb,
"skip": skipped, "skip": skipped,
"rows": rows_cnt[0], "rows": rows_cnt[0],
"elapsed_sec": elapsed, "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 from app.core.datasource.registry import is_source_ready, run_health_check, get_health_status
if not get_health_status(): if not get_health_status():
run_health_check() run_health_check()
available = []
for key in PRIMARY_PRIORITY: for key in PRIMARY_PRIORITY:
ok, _ = is_source_ready(key) ok, _ = is_source_ready(key)
if ok: if ok:
return ds_registry.get(key) available.append(ds_registry.get(key))
return None return available
def _sync_one(self, primary, code6: str, start: str, end: str) -> dict: def _sync_one(self, primary, code6: str, start: str, end: str) -> dict:
"""保留这个方法以兼容外部调用(已不用,但单只测试可能用到)""" """保留这个方法以兼容外部调用(已不用,但单只测试可能用到)"""
+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
+11 -3
View File
@@ -3,7 +3,6 @@
基于本地 kline_stock 数据聚合:advancers / decliners / turnover / advance_ratio。""" 基于本地 kline_stock 数据聚合:advancers / decliners / turnover / advance_ratio。"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Any from typing import Any
import numpy as np 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 import ops as db_ops
from app.core.db.orm import engine as pg_engine 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 from app.core.utils.logging import get_logger
logger = get_logger("sync.market_regime") logger = get_logger("sync.market_regime")
@@ -34,7 +33,16 @@ class SyncMarketRegime(SyncTask):
dataset_id = "market_regime" dataset_id = "market_regime"
def _run(self, *, trigger_source: str = "manual", start: str = DEFAULT_START, **kwargs) -> dict[str, Any]: 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}...") self._progress(message=f"构建 market_regime {start} ~ {end}...")
# 1. 取所有非退市股票代码 # 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。 # 与 stocks.code 保持一致,方便 JOIN。
# 历史 bug:之前 share 表写的是裸 6 位 c6,导致 share JOIN stocks 失败。 # 历史 bug:之前 share 表写的是裸 6 位 c6,导致 share JOIN stocks 失败。
# 一次性 UPDATE2026-07-01)已把存量 6.3 万行加上前缀。 # 一次性 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) hermes = self._to_hermes(c6)
db_ops.update_stock_share_snapshot( db_ops.update_stock_share_snapshot(
code=hermes, code=hermes,
total_share=r["total_share"], total_share=r["total_share"],
float_share=r["float_share"], float_share=r["float_share"],
trade_date=r.get("trade_date", today), trade_date=trade_date,
) )
rows_to_share_table.append({ rows_to_share_table.append({
"stock_code": hermes, "stock_code": hermes,
"trade_date": r.get("trade_date", today), "trade_date": trade_date,
"total_share": r["total_share"], "total_share": r["total_share"],
"float_share": r["float_share"], "float_share": r["float_share"],
}) })
+9 -2
View File
@@ -104,7 +104,6 @@ class SyncStocksBasic(SyncTask):
exchange=old.get("exchange", ""), exchange=old.get("exchange", ""),
list_date=old.get("list_date", ""), list_date=old.get("list_date", ""),
listing_status="delisted", listing_status="delisted",
industry=old.get("industry", ""),
) )
delisted += 1 delisted += 1
@@ -197,11 +196,19 @@ class SyncStocksBasic(SyncTask):
if r is None: if r is None:
fail += 1 fail += 1
else: 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( db_ops.update_stock_share_snapshot(
code=self._to_hermes(c6), code=self._to_hermes(c6),
total_share=r["total_share"], total_share=r["total_share"],
float_share=r["float_share"], float_share=r["float_share"],
trade_date=r.get("trade_date", today), trade_date=trade_date,
) )
ok += 1 ok += 1
if done % 100 == 0: if done % 100 == 0:
+37 -5
View File
@@ -12,12 +12,15 @@
trade_date 可能比 wall-clock 早一天;重跑靠 PK + ON CONFLICT 幂等 trade_date 可能比 wall-clock 早一天;重跑靠 PK + ON CONFLICT 幂等
4) 单只股票日均 5w-10w tickCHUNK=2000 upsert 防 driver 撑爆 4) 单只股票日均 5w-10w tickCHUNK=2000 upsert 防 driver 撑爆
5) 数据是"当天 only",无 start/end 窗口;不存增量概念 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 from __future__ import annotations
import os import os
import time import time
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeoutError
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -36,6 +39,10 @@ MAIRUI_TICK_TRADE_PUBLISH_HOUR = 21
MAIRUI_TICK_TRADE_PUBLISH_MIN = 0 MAIRUI_TICK_TRADE_PUBLISH_MIN = 0
# 21:00 之后再跑(留 5 分钟缓冲,等 mairui 完整入库) # 21:00 之后再跑(留 5 分钟缓冲,等 mairui 完整入库)
TICK_TRADE_RUN_GATE = (MAIRUI_TICK_TRADE_PUBLISH_HOUR, MAIRUI_TICK_TRADE_PUBLISH_MIN + 5) 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]: 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() t0 = time.time()
ok_cnt = fail_cnt = 0 ok_cnt = fail_cnt = 0
rows_total = 0 rows_total = 0
with ThreadPoolExecutor(max_workers=max_workers) as pool: pool = ThreadPoolExecutor(max_workers=max_workers)
futures = {pool.submit(self._sync_one, mr, c6): c6 for c6 in stock_codes} futures = {pool.submit(self._sync_one, mr, c6): c6 for c6 in stock_codes}
for i, future in enumerate(as_completed(futures), 1): try:
for i, future in enumerate(as_completed(futures, timeout=FETCH_HARD_TIMEOUT), 1):
c6 = futures[future] c6 = futures[future]
try: try:
res = future.result() res = future.result(timeout=0.1)
if res["status"] == "ok": if res["status"] == "ok":
ok_cnt += 1 ok_cnt += 1
rows_total += res["rows"] rows_total += res["rows"]
@@ -124,6 +134,9 @@ class SyncTickTrade(SyncTask):
fail_cnt += 1 fail_cnt += 1
if res.get("error"): if res.get("error"):
logger.warning(f"[tick_trade {c6}] {res['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: except Exception as e:
fail_cnt += 1 fail_cnt += 1
logger.warning(f"[tick_trade {c6}] {e}") 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}", message=f"tick_trade 进度 {i}/{total} OK:{ok_cnt} FAIL:{fail_cnt} 行:{rows_total}",
current=i, total=total, current_step=c6, 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) elapsed = round(time.time() - t0, 1)
msg = f"逐笔 {ok_cnt}{fail_cnt}败 共{rows_total}行, {elapsed}s" msg = f"逐笔 {ok_cnt}{fail_cnt}败 共{rows_total}行, {elapsed}s"
return { return {
+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())
+127 -9
View File
@@ -29,6 +29,7 @@ from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
import requests import requests
import sqlalchemy as sa
# ── 路径与日志 ───────────────────────────────────────────────────────────── # ── 路径与日志 ─────────────────────────────────────────────────────────────
@@ -42,21 +43,43 @@ SECRETS_FILE = PROJECT_ROOT / "config" / "daily_check_secrets.env"
LOG = logging.getLogger("daily-sync-check") 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 解释。 # 与 [[market-data-overview]] 保持一致;同步触发点(HH:MM)按 Asia/Shanghai 解释。
# weekend_only=True 表示只在周六/周日期望运行(实际仅 stock_node 周六一次)。 # weekend_only=True 表示只在周六/周日期望运行(实际仅 stock_node 周六一次)。
SCHEDULE: dict[str, dict[str, Any]] = { SCHEDULE: dict[str, dict[str, Any]] = {
"stock_basic": {"window_end": "09:30", "weekend_only": False}, "stock_basic": {"window_end": "09:30", "weekend_only": False},
"industry_sector": {"window_end": "09:45", "weekend_only": False},
"kline_index": {"window_end": "15:35", "weekend_only": False}, "kline_index": {"window_end": "15:35", "weekend_only": False},
"kline_daily": {"window_end": "15:45", "weekend_only": False}, "kline_daily": {"window_end": "15:45", "weekend_only": False},
"kline_5min": {"window_end": "16:05", "weekend_only": False}, "kline_5min": {"window_end": "16:05", "weekend_only": False},
"market_regime": {"window_end": "16:20", "weekend_only": False}, "market_regime": {"window_end": "16:20", "weekend_only": False},
"share_snapshot": {"window_end": "16:25", "weekend_only": False}, "share_snapshot": {"window_end": "12:00", "weekend_only": True}, # 周六
"moneyflow": {"window_end": "21:40", "weekend_only": False}, "moneyflow": {"window_end": "21:40", "weekend_only": False},
"longhubang": {"window_end": "22:05", "weekend_only": False}, "longhubang": {"window_end": "22:05", "weekend_only": False},
"stock_node": {"window_end": "12:00", "weekend_only": True}, # 周六 "stock_node": {"window_end": "12:00", "weekend_only": True}, # 周六
"mairui_ma_daily": {"window_end": "17:00", "weekend_only": False}, # 本地派生 "mairui_ma_daily": {"window_end": "17:00", "weekend_only": False}, # 本地派生
"mairui_indicators": {"window_end": "17:10", "weekend_only": False}, # mairui MACD/KDJ/BOLL
} }
@@ -245,25 +268,29 @@ def _classify_task(
# ── systemd unit drift 检测 ──────────────────────────────────────────────── # ── systemd unit drift 检测 ────────────────────────────────────────────────
SYSTEMD_REPO_DIR = PROJECT_ROOT / "bin" / "systemd" SYSTEMD_REPO_DIR = PROJECT_ROOT / "deploy" / "systemd" / "units"
SYSTEMD_LEGACY_REPO_DIR = PROJECT_ROOT / "bin" / "systemd"
SYSTEMD_ETC_DIR = Path("/etc/systemd/system") SYSTEMD_ETC_DIR = Path("/etc/systemd/system")
def _check_systemd_drift() -> dict[str, Any]: def _check_systemd_drift() -> dict[str, Any]:
"""对比 bin/systemd/ 与 /etc/systemd/system/ 下的 market-sync* unit。 """对比 deploy/systemd/units/ 与 /etc/systemd/system/ 下的 market-sync* unit。
兼容旧路径 bin/systemd/:如果 deploy/systemd/units/ 不存在则回退。
返回: 返回:
missing_in_etc: repo 有但 /etc 没装(drift 1:未部署) missing_in_etc: repo 有但 /etc 没装(drift 1:未部署)
drifted: 两边都有但内容不一致(drift 2:部署的版本过期) drifted: 两边都有但内容不一致(drift 2:部署的版本过期)
orphan_in_etc: /etc 有但 repo 没有(drift 3:临时/僵尸 unit 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] = [] missing_in_etc: list[str] = []
drifted: list[dict[str, str]] = [] drifted: list[dict[str, str]] = []
orphan_in_etc: list[str] = [] orphan_in_etc: list[str] = []
repo_units: set[str] = set() repo_units: set[str] = set()
if SYSTEMD_REPO_DIR.exists(): if repo_dir.exists():
for f in SYSTEMD_REPO_DIR.iterdir(): for f in repo_dir.iterdir():
if f.suffix in {".service", ".timer"} and f.name.startswith("market-sync"): if f.suffix in {".service", ".timer"} and f.name.startswith("market-sync"):
repo_units.add(f.name) repo_units.add(f.name)
@@ -279,8 +306,12 @@ def _check_systemd_drift() -> dict[str, Any]:
# drift 2: 两边都有但内容不一致 # drift 2: 两边都有但内容不一致
for name in sorted(repo_units & etc_units): for name in sorted(repo_units & etc_units):
repo_path = SYSTEMD_REPO_DIR / name repo_path = repo_dir / name
etc_path = SYSTEMD_ETC_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: try:
if repo_path.read_bytes() != etc_path.read_bytes(): if repo_path.read_bytes() != etc_path.read_bytes():
drifted.append({ drifted.append({
@@ -308,6 +339,87 @@ def _check_systemd_drift() -> dict[str, Any]:
} }
# ── 数据一致性检查 ─────────────────────────────────────────────────────────
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]: def build_report(check_date: date) -> dict[str, Any]:
"""生成当日巡检报告。""" """生成当日巡检报告。"""
@@ -338,7 +450,7 @@ def build_report(check_date: date) -> dict[str, Any]:
alerts.append({ alerts.append({
"dataset_id": f"systemd/{u}", "dataset_id": f"systemd/{u}",
"status": "not_deployed", "status": "not_deployed",
"reason": f"unit {u}bin/systemd/ 有但 /etc/systemd/system/ 没装", "reason": f"unit {u}deploy/systemd/units/ 有但 /etc/systemd/system/ 没装",
}) })
if drift["drifted"]: if drift["drifted"]:
for d in drift["drifted"]: for d in drift["drifted"]:
@@ -355,6 +467,10 @@ def build_report(check_date: date) -> dict[str, Any]:
"reason": f"unit {u} 在 /etc/systemd/system/ 但 repo 没维护", "reason": f"unit {u} 在 /etc/systemd/system/ 但 repo 没维护",
}) })
# 数据一致性检查(上游回填后下游未跟进)
consistency = _check_data_consistency()
alerts.extend(consistency["alerts"])
overall = "ok" if not alerts else ( overall = "ok" if not alerts else (
"warning" if any(a["status"] in {"missed", "never_run", "not_deployed", "drifted"} for a in alerts) else "error" "warning" if any(a["status"] in {"missed", "never_run", "not_deployed", "drifted"} for a in alerts) else "error"
) )
@@ -363,11 +479,13 @@ def build_report(check_date: date) -> dict[str, Any]:
"schema_version": 2, "schema_version": 2,
"generated_at": now.strftime("%Y-%m-%d %H:%M:%S"), "generated_at": now.strftime("%Y-%m-%d %H:%M:%S"),
"check_date": check_date.isoformat(), "check_date": check_date.isoformat(),
"runtime_mode": _runtime_mode(),
"overall": overall, "overall": overall,
"counts": counts, "counts": counts,
"alerts": alerts, "alerts": alerts,
"tasks": classified, "tasks": classified,
"systemd_drift": drift, "systemd_drift": drift,
"data_consistency": consistency["details"],
} }
@@ -383,7 +501,7 @@ def write_report(report: dict[str, Any]) -> Path:
def print_summary(report: dict[str, Any]) -> None: def print_summary(report: dict[str, Any]) -> None:
"""stdout 一眼看懂的汇总。""" """stdout 一眼看懂的汇总。"""
print("=" * 78) print("=" * 78)
print(f"每日同步巡检 date={report['check_date']} overall={report['overall']}") print(f"每日同步巡检 date={report['check_date']} runtime_mode={report.get('runtime_mode', 'unknown')} overall={report['overall']}")
print("=" * 78) print("=" * 78)
print(f"counts: {report['counts']}") print(f"counts: {report['counts']}")
if report["alerts"]: if report["alerts"]:
+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
+2 -4
View File
@@ -1,8 +1,6 @@
#!/bin/bash #!/bin/bash
# 早盘前同步:stock_basic + industry_sector # 早盘前同步:stock_basic(仅)
# - stock_basic:雪球 quote_detail 拉全市场股本快照(~9min @ 5 RPS # - stock_basic:雪球 quote_detail 拉全市场股本快照(~9min @ 5 RPS
# - industry_sectorbaostock 拉股票-行业映射(~16min @ 5 RPS
# - 串行执行,industry_sector 依赖 stock_basic
# - 与 runall_once.py 区别:不拉 kline_daily / kline_5min 等耗时长任务 # - 与 runall_once.py 区别:不拉 kline_daily / kline_5min 等耗时长任务
set -u set -u
@@ -27,7 +25,7 @@ seed_sync_registry()
from app.tasks import get_task from app.tasks import get_task
results = [] results = []
for tid in ['stock_basic', 'industry_sector']: for tid in ['stock_basic']:
print(f'[morning-run] >>> 开始 {tid}', flush=True) print(f'[morning-run] >>> 开始 {tid}', flush=True)
t0 = time.time() t0 = time.time()
try: try:
+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
+3 -2
View File
@@ -1,5 +1,6 @@
#!/bin/bash #!/bin/bash
# 跑剩余任务:tick_trade → moneyflow → share_snapshot → market_regime # 跑剩余任务:tick_trade → moneyflow → market_regime
# share_snapshot 已改为每周单独 timermarket-sync-share.timer),不在此处触发
# 跳过 kline_5min(全量回填 6 年太慢,下次有空再补) # 跳过 kline_5min(全量回填 6 年太慢,下次有空再补)
# tick_trade 用 --force 跳过 21:00 门控(人工补跑场景) # tick_trade 用 --force 跳过 21:00 门控(人工补跑场景)
set -e set -e
@@ -10,7 +11,7 @@ mkdir -p logs
# 先清理上次可能留下的卡死记录(recover_interrupted_syncs 启动时自动处理,但显式更稳) # 先清理上次可能留下的卡死记录(recover_interrupted_syncs 启动时自动处理,但显式更稳)
echo "=== 启动时间: $(date) ===" | tee logs/runall_remaining.log 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 "" | tee -a logs/runall_remaining.log
echo "=== [$TASK] 开始 $(date) ===" | tee -a logs/runall_remaining.log echo "=== [$TASK] 开始 $(date) ===" | tee -a logs/runall_remaining.log
T0=$(date +%s) T0=$(date +%s)
+118 -31
View File
@@ -9,13 +9,26 @@
注:以下 task **不**在 runall 链中 —— 各自有独立的 systemd timer 注:以下 task **不**在 runall 链中 —— 各自有独立的 systemd timer
- tick_trade : mairui 21:00 发布 → market-sync-tick.timer (21:05) - tick_trade : mairui 21:00 发布 → market-sync-tick.timer (21:05)
- moneyflow : mairui 21:30 发布 → market-sync-moneyflow.timer (21:35) - 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 from __future__ import annotations
import json import json
import multiprocessing as mp
import os
import signal
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
from typing import Any
_PROJECT_ROOT = Path(__file__).resolve().parent.parent _PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(_PROJECT_ROOT) not in sys.path: if str(_PROJECT_ROOT) not in sys.path:
@@ -30,8 +43,19 @@ from app.core.sync.registry import seed_sync_registry
build_default_registry() build_default_registry()
seed_sync_registry() seed_sync_registry()
from app.core.db import ops as db_ops
from app.tasks import get_task 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 限流) # 顺序执行,按 sort_order 走(便于排查 + 避免外部 API 限流)
# 注:tick_trade / moneyflow 由独立 timer 跑(21:05 / 21:35 # 注:tick_trade / moneyflow 由独立 timer 跑(21:05 / 21:35
TASKS = [ TASKS = [
@@ -39,48 +63,111 @@ TASKS = [
"kline_index", "kline_index",
"kline_daily", "kline_daily",
"kline_5min", "kline_5min",
"industry_sector",
"sector_features",
"share_snapshot",
"market_regime", "market_regime",
] ]
# 不跳任何 task — 全量跑 # 不跳任何 task — 全量跑
SKIP: set[str] = set() SKIP: set[str] = set()
results = {} # 每个 task 的硬上限(秒)。覆盖数据源/任务层都失败的最坏情况:
t_all = time.time() # kline_daily: 35 min (正常 18 min, 留余量)
for tid in TASKS: # kline_5min: 75 min (全量首次跑很久, 增量 ~30 min)
if tid in SKIP: # share_snapshot: 15 min
results[tid] = {"status": "skipped", "message": "已 ok"} # 其他小表: 10 min
logger.info(f"[runall] {tid} 跳过(已 ok") PER_TASK_TIMEOUT_SEC = {
continue "kline_daily": 35 * 60,
logger.info(f"[runall] >>> 开始 {tid}") "kline_5min": 75 * 60,
t0 = time.time() "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: try:
task = get_task(tid) task = get_task(tid)
# 大表 task 限小批量股票,避免跑爆;其他 task 跑全量
kwargs = {"max_workers": 5} 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) r = task.run(trigger_source="runall", **kwargs)
elapsed = round(time.time() - t0, 1) result_queue.put(r)
r["elapsed_sec"] = elapsed
results[tid] = r
logger.info(f"[runall] <<< {tid} 完成 status={r.get('status')} elapsed={elapsed}s msg={r.get('message')}")
except Exception as e: 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) 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 让健康监控跑 if proc.is_alive():
time.sleep(5) 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) time.sleep(5)
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)) if __name__ == '__main__':
logger.info(f"[runall] 全部完成 total={elapsed_total}s summary={out}") _run_tasks()
print(json.dumps(summary, ensure_ascii=False, indent=2))
# 全部完成清一次 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))
+9
View File
@@ -10,6 +10,15 @@
set -eu 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 早启) # 等 PG 就绪(避免 racemarket_sync 比 postgres 早启)
if [ -n "${PG_HOST:-}" ]; then if [ -n "${PG_HOST:-}" ]; then
echo "[service] wait for PG ${PG_HOST}:${PG_PORT:-5432}..." echo "[service] wait for PG ${PG_HOST}:${PG_PORT:-5432}..."
@@ -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
+3 -3
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Market sync morning pre-market — stock_basic + industry_sector Description=Market sync morning pre-market — stock_basic
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_morning_run.sh Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_morning_run.sh
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
@@ -10,10 +10,10 @@ WorkingDirectory=/home/gao/Development/quant_home/market_sync
User=gao User=gao
Group=gao Group=gao
# 早盘前只跑 stock_basic + industry_sector(避免和 15:30 runall 重复 # 早盘前只跑 stock_basic(确保最新股票列表+股本快照
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_morning_run.sh ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_morning_run.sh
# stock_basic ~9min + industry_sector ~16min + sleep 5s + buffer = 1h 够用 # stock_basic ~9min + buffer = 1h 够用
TimeoutStartSec=3600 TimeoutStartSec=3600
# 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑) # 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑)
+2 -2
View File
@@ -1,10 +1,10 @@
[Unit] [Unit]
Description=Schedule market sync morning — Mon..Fri 09:00 Asia/Shanghai Description=Schedule market sync morning — Mon..Fri 09:00 Asia/Shanghai
# market-sync-morning.service 是这个 timer 的执行单元 # market-sync-morning.service 是这个 timer 的执行单元
# 早盘前 09:00 触发:跑 stock_basic + industry_sector,给 09:30 开盘留 30min buffer # 早盘前 09:00 触发:跑 stock_basic,给 09:30 开盘留 30min buffer
[Timer] [Timer]
# A 股开盘 09:3009:00 跑 stock_basic (~9min) + industry_sector (~16min) = ~25min # A 股开盘 09:3009:00 跑 stock_basic (~9min) 即可
OnCalendar=Mon..Fri 09:00:00 Asia/Shanghai OnCalendar=Mon..Fri 09:00:00 Asia/Shanghai
# 关机/错过时下次开机补跑 # 关机/错过时下次开机补跑
Persistent=true Persistent=true
+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
+1 -1
View File
@@ -1,7 +1,7 @@
[Unit] [Unit]
Description=Market data 股票-节点映射 sync (mairui /hszg, weekly Sat 11:30) 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 Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_stock_node_run.sh
After=network-online.target After=network-online.target market-sync-morning.service
Wants=network-online.target Wants=network-online.target
[Service] [Service]
+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 -4
View File
@@ -13,10 +13,11 @@ Group=gao
# 入口脚本(runall_once.py + structured_watch.sh 二合一) # 入口脚本(runall_once.py + structured_watch.sh 二合一)
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_run.sh ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_run.sh
# 硬上限 4h(实际 ~80min + kline_5min 增量 70min, 总 ~2.5h, 留 buffer 给 retry / 慢任务 # 硬上限 3h(实际 ~80min + kline_5min 增量 70min, 总 ~2.5h, 留 30min buffer
# 历史 bug2026-07-02):2h 超时截断,share_snapshot / market_regime 没跑成 # 2026-07-08 教训: 之前 4h 让 kline_daily 单点卡死拖 4h,runall 链全部受影响。
# 详见 docs/works/2026-07-03-01-market-sync-timeout.md # runall_once.py 已加 per-task multiprocessing timeout(单 task 最长 75min),
TimeoutStartSec=14400 # 这里 3h 是 systemd 层兜底。
TimeoutStartSec=10800
# 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑—— # 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑——
# 重跑会撞外部 API 限流窗口,浪费 1.5h) # 重跑会撞外部 API 限流窗口,浪费 1.5h)
-185
View File
@@ -1,185 +0,0 @@
#!/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
+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()
+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
@@ -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
@@ -0,0 +1,33 @@
[Unit]
Description=Market data tick-by-tick trade sync (mairui hsrl/zbjy, daily 21:05)
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_tick_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
# 入口脚本:单独跑 tick_trade 一个 taskmairui 21:00 发布数据,
# 这个 service 在 21:05 触发,留 5min bufferforce=True 绕过 task 内部 21:00 门控)
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_tick_run.sh
# 硬上限 2h5000 只 × 1 RPS ≈ 17min,留 buffer 给 retry / 慢任务)
TimeoutStartSec=7200
# 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑——
# 重跑会撞外部 API 限流窗口,浪费 1.5h)
# Restart=no 是 oneshot 默认值
# 日志走 journaldjournalctl -u market-sync-tick.service -f
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync-tick
# 环境(不读 /etc/environment,只带这几个;.env 由 cli 内部 load_dotenv
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,17 @@
[Unit]
Description=Schedule tick_trade sync — Mon..Fri 21:05 Asia/Shanghai
# market-sync-tick.service 是这个 timer 的执行单元
# 触发顺序:market-sync.service (15:30) 之后 market-sync-tick.service (21:05)
# 但 systemd timer 独立触发,After= 只是声明依赖关系(非强制)
[Timer]
# mairui 文档:「更新:每日 21:00」—— 21:05 触发留 5 分钟 buffer
OnCalendar=Mon..Fri 21:05:00 Asia/Shanghai
# 系统关机 / 错过执行时,下次开机补跑一次(避免漏数据)
Persistent=true
# 单位(service)的精确名称
Unit=market-sync-tick.service
# 不要 AccuracySec(默认 1min 漂移够用,避免 21:05:00 整点打堆)
[Install]
WantedBy=timers.target
@@ -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
+35
View File
@@ -0,0 +1,35 @@
[Unit]
Description=Market Data Full Sync (PostgreSQL, 9 sync tasks end-to-end)
Documentation=file:///home/gao/Development/quant_home/market_sync/bin/market_sync_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
# 入口脚本(runall_once.py + structured_watch.sh 二合一)
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_run.sh
# 硬上限 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)
# Restart=no 是 oneshot 默认值
# 日志走 journaldjournalctl -u market-sync.service -f
StandardOutput=journal
StandardError=journal
SyslogIdentifier=market-sync
# 环境(不读 /etc/environment,只带这几个;.env 由 runall_once.py 内部 load_dotenv
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Schedule market data full sync — Mon..Fri 15:30 Asia/Shanghai
# market-sync.service 是这个 timer 的执行单元
[Timer]
# A 股收盘时间 15:0030 分钟 buffer 让最后一笔 tick 落地
OnCalendar=Mon..Fri 15:30:00 Asia/Shanghai
# 系统关机 / 错过执行时,下次开机补跑一次(避免漏数据)
Persistent=true
# 单位(service)的精确名称
Unit=market-sync.service
# 不要 AccuracySec(默认 1min 漂移够用,避免 15:30:00 整点打堆)
[Install]
WantedBy=timers.target
-84
View File
@@ -1,84 +0,0 @@
# 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:
# 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:
+1
View File
@@ -0,0 +1 @@
deploy/docker/docker-compose.yml
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"market_sync": {
"type": "remote",
"url": "http://127.0.0.1:8101/sse",
"enabled": true
}
}
}
+33 -62
View File
@@ -1,81 +1,52 @@
#!/bin/bash #!/bin/bash
# 串行跑所有 6 个同步任务,后台模式,写到 logs/ set -u
# 用法:./run_all_sync.sh [STEP]
# STEP 为空 = 跑 1-6
# STEP=1 只跑 stock_basic,等等
set -e PROJECT_ROOT="/home/gao/Development/quant_home/market_sync"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" LOG_DIR="$PROJECT_ROOT/logs"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" RUN_LOG="$LOG_DIR/run_all_sync_$(date +%Y%m%d_%H%M%S).log"
cd "$PROJECT_DIR"
mkdir -p logs cd "$PROJECT_ROOT"
LOG_DIR="$PROJECT_DIR/logs"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
RUN_LOG="$LOG_DIR/run_all_$TIMESTAMP.log"
# 读 token LOG_PREFIX="[run_all_sync]"
if [ -f "$PROJECT_DIR/.env" ]; then
set -a
source "$PROJECT_DIR/.env"
set +a
fi
# 把 .env 里的 TRADING_HOLIDAYS 转成 -- 跳过
# XUEQIU_TOKEN 在 .env 里
source .venv/bin/activate
export PYTHONPATH="$PROJECT_DIR"
LOG_PREFIX="[$TIMESTAMP]"
run_step() { run_step() {
local step_name="$1" local step=$1 task=$2 extra_args=${3:-}
local task_id="$2" echo "$LOG_PREFIX === 步骤 $step: $task ===" | tee -a "$RUN_LOG"
local extra_args="$3" if [ -n "$extra_args" ]; then
local step_log="$LOG_DIR/step_${step_name}_$TIMESTAMP.log" .venv/bin/python -m app.entrypoints.cli sync "$task" $extra_args 2>&1 | tee -a "$RUN_LOG"
echo "$LOG_PREFIX === Step $step_name: $task_id ===" | tee -a "$RUN_LOG"
echo "$LOG_PREFIX log: $step_log" | tee -a "$RUN_LOG"
# 跑任务(前台模式,方便顺序执行 + 立即看到结果)
python -m app.entrypoints.cli sync "$task_id" $extra_args 2>&1 | tee "$step_log" | tail -10
local exit_code=${PIPESTATUS[0]}
if [ $exit_code -eq 0 ]; then
echo "$LOG_PREFIX ✓ Step $step_name OK" | tee -a "$RUN_LOG"
else else
echo "$LOG_PREFIX ✗ Step $step_name FAIL (exit=$exit_code)" | tee -a "$RUN_LOG" .venv/bin/python -m app.entrypoints.cli sync "$task" 2>&1 | tee -a "$RUN_LOG"
return $exit_code
fi fi
local rc=${PIPESTATUS[0]}
if [ $rc -ne 0 ]; then
echo "$LOG_PREFIX ✗ 步骤 $step ($task) 失败 (exit=$rc)" | tee -a "$RUN_LOG"
return $rc
fi
echo "$LOG_PREFIX ✓ 步骤 $step ($task) 完成" | tee -a "$RUN_LOG"
} }
case "${1:-all}" in case "${1:-all}" in
1|stock_basic) run_step "1_stock_basic" "stock_basic" ;; 1|stock_basic) run_step "1_stock_basic" "stock_basic" ;;
2|industry_sector) run_step "2_industry_sector" "industry_sector" ;; 2|kline_index) run_step "2_kline_index" "kline_index" ;;
3|kline_index) run_step "3_kline_index" "kline_index" ;; 3|kline_daily) run_step "3_kline_daily" "kline_daily" "--workers 20" ;;
4|kline_daily) run_step "4_kline_daily" "kline_daily" "--workers 20" ;; 4|share_snapshot) run_step "4_share_snapshot" "share_snapshot" "--workers 10" ;;
5|share_snapshot) run_step "5_share_snapshot" "share_snapshot" "--workers 10" ;; 5|market_regime) run_step "5_market_regime" "market_regime" ;;
6|market_regime) run_step "6_market_regime" "market_regime" ;; 6|kline_5min) run_step "6_kline_5min" "kline_5min" "--workers 10" ;;
7|kline_5min) run_step "7_kline_5min" "kline_5min" "--workers 10" ;; 7|moneyflow) run_step "7_moneyflow" "moneyflow" "--workers 10" ;;
8|moneyflow) run_step "8_moneyflow" "moneyflow" "--workers 10" ;; 8|tick_trade) run_step "8_tick_trade" "tick_trade" "--workers 5 --force" ;;
9|sector_features) run_step "9_sector_features" "sector_features" ;;
10|tick_trade) run_step "10_tick_trade" "tick_trade" "--workers 5 --force" ;;
all) all)
run_step "1_stock_basic" "stock_basic" || exit 1 run_step "1_stock_basic" "stock_basic" || exit 1
run_step "2_industry_sector" "industry_sector" || exit 1 run_step "2_kline_index" "kline_index" || exit 1
run_step "3_kline_index" "kline_index" || exit 1 run_step "3_kline_daily" "kline_daily" "--workers 20" || exit 1
run_step "4_kline_daily" "kline_daily" "--workers 20" || exit 1 run_step "4_share_snapshot" "share_snapshot" "--workers 10" || exit 1
run_step "5_share_snapshot" "share_snapshot" "--workers 10" || exit 1 run_step "5_market_regime" "market_regime" || exit 1
run_step "6_market_regime" "market_regime" || exit 1 run_step "6_kline_5min" "kline_5min" "--workers 10" || exit 1
run_step "7_kline_5min" "kline_5min" "--workers 10" || exit 1 run_step "7_moneyflow" "moneyflow" "--workers 10" || exit 1
run_step "8_moneyflow" "moneyflow" "--workers 10" || exit 1 run_step "8_tick_trade" "tick_trade" "--workers 5 --force" || exit 1
run_step "9_sector_features" "sector_features" || exit 1 echo "$LOG_PREFIX ✓ 全部 8 步完成" | tee -a "$RUN_LOG"
run_step "10_tick_trade" "tick_trade" "--workers 5 --force" || exit 1
echo "$LOG_PREFIX ✓ 全部 10 步完成" | tee -a "$RUN_LOG"
;; ;;
*) *)
echo "usage: $0 [1|2|3|4|5|6|7|8|9|10|all]" echo "usage: $0 [1|2|3|4|5|6|7|8|all]"
exit 1 exit 1
;; ;;
esac esac
+5 -4
View File
@@ -7,24 +7,25 @@ from __future__ import annotations
def test_orm_metadata_registers_all_tables(): def test_orm_metadata_registers_all_tables():
"""ORMBase.metadata 应该注册 23 张 PG 表(项目主业务表)。""" """ORMBase.metadata 应该注册 25 张 PG 表(项目主业务表)。"""
from app.core.db import models # 触发全部模型 import from app.core.db import models # 触发全部模型 import
from app.core.db.orm import ORMBase from app.core.db.orm import ORMBase
# 注意:SA 2.x 的 MetaData.tables 既能按 bare name 也能按 schema-qualified name 索引 # 注意:SA 2.x 的 MetaData.tables 既能按 bare name 也能按 schema-qualified name 索引
# keyed 容器),所以用 values() 拿 Table 对象再用 .name 拿 bare 名 # keyed 容器),所以用 values() 拿 Table 对象再用 .name 拿 bare 名
bare_names = {t.name for t in ORMBase.metadata.tables.values()} bare_names = {t.name for t in ORMBase.metadata.tables.values()}
assert len(bare_names) == 23, f"期望 23 张 ORM 表,实际 {len(bare_names)}: {bare_names}" assert len(bare_names) == 21, f"期望 21 张 ORM 表,实际 {len(bare_names)}: {bare_names}"
expected = { expected = {
"config", "dataset_registry", "stocks", "indices", "config", "dataset_registry", "stocks", "indices",
"kline_stock", "kline_index", "kline_5min", "moneyflow", "share", "kline_stock", "kline_index", "kline_5min", "moneyflow", "share",
"sectors", "stock_sector_map", "industry", "market_regime_daily",
"sector_indices", "sector_features_daily", "market_regime_daily",
"tick_trade", "tick_trade",
"longhubang_daily", "longhubang_seat", # 2026-07-01 龙虎榜 "longhubang_daily", "longhubang_seat", # 2026-07-01 龙虎榜
"kline_stock_ma_daily", # 2026-07-04 mairui_ma_daily "kline_stock_ma_daily", # 2026-07-04 mairui_ma_daily
"node_categories", "nodes", "stock_node_map", # 2026-07-02 股票-节点映射 "node_categories", "nodes", "stock_node_map", # 2026-07-02 股票-节点映射
"sync_history", # 2026-07-07 同步历史表 "sync_history", # 2026-07-07 同步历史表
# 2026-07-08 mairui 技术指标 MACD/KDJ/BOLL
"kline_stock_macd_daily", "kline_stock_kdj_daily", "kline_stock_boll_daily",
} }
assert bare_names == expected, f"ORM 表名集合与期望不符: {bare_names ^ expected}" assert bare_names == expected, f"ORM 表名集合与期望不符: {bare_names ^ expected}"
+6 -6
View File
@@ -35,10 +35,10 @@ def test_tasks_registry():
from app.tasks import TASKS, get_task from app.tasks import TASKS, get_task
assert isinstance(TASKS, dict) assert isinstance(TASKS, dict)
# 注:每次新增 task 都要更新这里的数字 # 注:每次新增 task 都要更新这里的数字
# 当前 13 个:stock_basic, kline_daily, kline_index, kline_5min, tick_trade, # 当前 12 个:stock_basic, kline_daily, kline_index, kline_5min, tick_trade,
# moneyflow, industry_sector, sector_features, share_snapshot, market_regime, # moneyflow, share_snapshot, market_regime,
# longhubang, mairui_ma_daily, stock_node # longhubang, mairui_ma_daily, stock_node, mairui_indicators
assert len(TASKS) == 13 assert len(TASKS) == 12
# get_task 应该返回实例 # get_task 应该返回实例
task = get_task("kline_daily") task = get_task("kline_daily")
assert task.dataset_id == "kline_daily" assert task.dataset_id == "kline_daily"
@@ -46,10 +46,10 @@ def test_tasks_registry():
def test_sync_definitions(): def test_sync_definitions():
from app.core.sync.registry import SYNC_DEFINITIONS from app.core.sync.registry import SYNC_DEFINITIONS
assert len(SYNC_DEFINITIONS) == 13 assert len(SYNC_DEFINITIONS) == 12
# 所有 dataset_id 应唯一 # 所有 dataset_id 应唯一
ids = [d["dataset_id"] for d in SYNC_DEFINITIONS] ids = [d["dataset_id"] for d in SYNC_DEFINITIONS]
assert len(set(ids)) == 13 assert len(set(ids)) == 12
# tick_trade 应在其中且 sort_order=45 # tick_trade 应在其中且 sort_order=45
tt = next(d for d in SYNC_DEFINITIONS if d["dataset_id"] == "tick_trade") tt = next(d for d in SYNC_DEFINITIONS if d["dataset_id"] == "tick_trade")
assert tt["sort_order"] == 45 assert tt["sort_order"] == 45