refactor: PG-only 迁移 + 龙虎榜/tick/moneyflow 同步 + stock_code 统一 + mairui 编码修复

## 1. PG-only 重构
- 删 app/core/db/connection.py + schema.py (MySQL 路径)
- 新 app/core/db/{orm,models,pg_bootstrap}.py — SQLAlchemy 2.x ORM 一键建表
- 16 张业务表全在 market_data schema,原生 TIMESTAMPTZ / JSONB / Float / TEXT
- requirements.txt 删 PyMySQL 路径,加 psycopg2

## 2. 同步任务扩展(3 个新 task)
- **tick_trade** (mairui hsrl/zbjy):当天逐笔交易,21:00 发布
- **moneyflow** (mairui hsstock/history/transaction):个股资金流,21:30 发布
- **longhubang** (akshare):龙虎榜聚合层 + 席位层(2 张新表)
  - 长虎榜放宽 akshare 政策:仅"无替代源 + 烟测通过"场景允许
  - data_eastmoney 私有 API 不需要(akshare 烟测通过)
- 3-timer 设计:
  - 15:30 market-sync.service (8 base tasks via runall_once)
  - 21:05 market-sync-tick.service (tick_trade)
  - 21:35 market-sync-moneyflow.service (moneyflow)
  - 22:00 market-sync-lhb.service (longhubang,新加)
- bin/systemd/ 新增 tick / moneyflow / lhb 各 1 对 service+timer
- bin/market_sync_*_run.sh wrapper 脚本(不做法定节假日过滤,fail-open)

## 3. stock_code 统一为带 SH/SZ/BJ 前缀
- 历史 bug:stocks.code 用 SH600519,但 kline/moneyflow/tick_trade/kline_5min
  /stock_sector_map/industry 6 张表用纯 6 位 600519,跨表 JOIN 全部 0 行
- 新增 to_hermes() 工具:6位 / 9位(mairui `000001.SZ` 格式)→ 统一 SH000001
- 5 个 task 改写:用 to_hermes(code6) 写入 stock_code
- 一次性迁移 6 张表存量 154M 行(CASE WHEN 探测 + 去重 + 加前缀)
- ORM: stock_sector_map.stock_code / industry.code String(6)→String(10)

## 4. Bug 修复
- **share table stock_code 格式**:之前写 6 位不带前缀,与 stocks 不一致
  → 修 task_share_snapshot + 一次性 UPDATE 63,417 行加前缀
- **share_snapshot warning 状态错填 last_error**:
  → 加 mark_sync_warning() 走专用路径,不写 last_failure_at / last_error
- **schedule config lastRun 不同步**:
  → 加 update_job_status_for_dataset(),SyncTask.run() 完成后自动镜像
  → cli/runall 触发的 task 也能更新 schedule config

## 5. mairui UTF-8 编码修复
- 历史 bug:mairui.py:_fetch 用 latin-1 兜底解码,把所有 UTF-8 中文名
  double-encoded 写入 stocks.name(如 `歌华有线` 变成 `æ\xad\x8cå\x8d\x8e...`)
- 加 _decode_response():UTF-8 → GBK → latin-1 兜底
- 一次性修复 stocks.name 5,213 行:
  - 4,370 行 (encode('latin-1').decode('utf-8') 反向解码)
  - 616 行 (含 fullwidth A,宽松 printable 检查)
  - 820 行 (mid-character 截断,重新从 mairui 拉)

## 6. 测试
- tests/test_smoke.py: TASKS 10→11, SYNC_DEFINITIONS 10→11
- tests/test_schema_models.py: 16→18 张表,新增 longhubang_daily/seat
- pytest 11/11 passed

## 验证
- 6 张表 0 残留无前缀行
- stocks JOIN kline_stock / kline_5min / moneyflow / tick_trade / stock_sector_map:88-100% 命中
- 5,213 stocks.name 全部正确 UTF-8 中文
- pytest 11/11 passed
This commit is contained in:
gao
2026-07-01 22:00:58 +08:00
parent 05635b76b9
commit 7a985dddd5
45 changed files with 3037 additions and 958 deletions
+288
View File
@@ -0,0 +1,288 @@
"""[DEPRECATED 2026-06-16] MySQL → PostgreSQL 数据迁移工具。
⚠️ 项目从 2026-06-16 起放弃 MySQL,只支持 PostgreSQL。
本脚本已归档到 ``bin/archive/``,仅作历史参考 / 应急回滚。
如果你想从 MySQL 备份恢复数据到 PG,可以临时把它移回 ``bin/`` 用一次。
同步目标:把 MySQL `market_data_sync_db` 库 15 张表的数据搬到 PG `market_data.schema` 下。
策略(不重新建表,建表由 pg_bootstrap 负责):
- 用 pymysql 读 MySQL 流式分页(避免 1 亿行一次占满内存)
- 用 psycopg2.copy_from 或 executemany 写 PG(按表选择最高效方式)
- 顺序:先小表(配置/字典)→ 后大表(kline_5min 等)
数据量预估(按当时 MySQL 实测,2026-06-16):
config: 20 行
dataset_registry: 15 行
industry: 5,207 行
sectors: 83 行
stock_sector_map: 5,207 行
indices: 6 行
market_regime_daily: 2,046 行
share: 6,141 行
stocks: 5,210 行
sector_indices: 103,084 行
sector_features_daily: 416,521 行
kline_index: 37,480 行
moneyflow: 3,188,301 行
kline_stock: 11,605,541 行
kline_5min: 121,945,849 行 ← 最大,约 1.2 亿
用法(应急时):
mv bin/archive/migrate_mysql_to_pg.py bin/migrate_mysql_to_pg.py
.venv/bin/python -m bin.migrate_mysql_to_pg
"""
from __future__ import annotations
import argparse
import sys
import time
from io import StringIO
from typing import Iterable
import pymysql
import psycopg2
from dotenv import load_dotenv
load_dotenv(".env", override=False)
# 15 张表的列定义(MySQL → PG 列名映射)
# 大部分列名一致;少数 PG 用 JSONBdataset_registry.dependency_ids)需要 JSON 解析
TABLES = [
# (table_name, mysql_order_by, list_of_column_names)
("config", "key", ["key", "value", "category", "description", "updated_at"]),
("dataset_registry", "dataset_id", ["dataset_id", "name", "description", "storage_uri", "storage_layer",
"management_role", "source", "sync_script", "dependency_ids",
"enabled", "sort_order", "status", "trigger_source",
"started_at", "finished_at", "last_success_at", "last_failure_at",
"message", "last_error", "needs_resync", "progress_current",
"progress_total", "current_step", "updated_at"]),
("indices", "index_code", ["index_code", "index_name", "market", "category", "source", "enabled", "updated_at"]),
("industry", "code", ["code", "industry_name", "industry_classification", "update_date", "updated_at"]),
("sectors", "sector_key", ["sector_key", "sector_name", "taxonomy", "level", "source", "enabled", "updated_at"]),
("stock_sector_map", "stock_code", ["stock_code", "sector_key", "updated_at"]),
("stocks", "code", ["code", "name", "exchange", "list_date", "listing_status", "industry",
"total_share", "float_share", "share_updated_at", "kline_synced_at",
"updated_at"]),
("market_regime_daily", "trade_date", ["trade_date", "advancers", "decliners", "advance_ratio", "turnover",
"turnover_avg_5d", "turnover_ratio_5d", "source", "is_extreme_panic",
"updated_at"]),
("share", "stock_code,trade_date", ["stock_code", "trade_date", "total_share", "float_share"]),
("sector_indices", "trade_date,sector_name", ["trade_date", "sector_name", "close", "sector_amplitude"]),
("sector_features_daily", "trade_date,sector_name", ["trade_date", "sector_name", "sector_ret", "sector_amplitude",
"close", "ema10", "ema20", "ema200", "score"]),
("kline_index", "index_code,trade_date", ["index_code", "trade_date", "open", "high", "low", "close", "volume"]),
("moneyflow", "stock_code,trade_date", ["stock_code", "trade_date", "main_net_inflow",
"large_net_inflow", "medium_net_inflow", "small_net_inflow"]),
("kline_stock", "stock_code,trade_date", ["stock_code", "trade_date", "open", "high", "low", "close", "volume"]),
("kline_5min", "stock_code,bar_time", ["stock_code", "bar_time", "open", "high", "low", "close",
"volume", "amount", "turnover_rate"]),
]
def mysql_conn():
return pymysql.connect(
host=__import__("os").environ.get("MYSQL_HOST", "127.0.0.1"),
port=int(__import__("os").environ.get("MYSQL_PORT", "3306")),
user=__import__("os").environ.get("MYSQL_USER", "root"),
password=__import__("os").environ.get("MYSQL_PASSWORD", ""),
database=__import__("os").environ.get("MYSQL_DATABASE", "market_data_sync_db"),
charset="utf8mb4",
cursorclass=pymysql.cursors.SSDictCursor, # 服务端 cursor,流式不撑内存
)
def pg_conn():
import os as _os
pg_url = _os.environ.get("PG_URL", "").strip()
# SQLAlchemy 格式 → psycopg2 格式(剥掉 +psycopg2
if pg_url.startswith("postgresql+psycopg2://"):
pg_url = pg_url.replace("postgresql+psycopg2://", "postgresql://", 1)
if not pg_url:
host = _os.environ.get("PG_HOST", "127.0.0.1")
port = _os.environ.get("PG_PORT", "5432")
user = _os.environ.get("PG_USER", "market_sync")
pw = _os.environ.get("PG_PASSWORD", "market_sync")
db = _os.environ.get("PG_DB_NAME", "market_data")
pg_url = f"postgresql://{user}:{pw}@{host}:{port}/{db}"
return psycopg2.connect(pg_url)
def _convert_value(col: str, v):
"""MySQL → PG 类型兼容转换。"""
if v is None:
return None
if col == "dependency_ids" and isinstance(v, str):
# MySQL 存的是 Python list repr(如 ['stock_info']),不是合法 JSON。
# 先后备方案:先 json.loads(合法 JSON),失败再 ast.literal_evalPython repr)。
import ast
import json
if not v.strip():
return []
# 试 1: 当 JSON 解析
try:
return json.loads(v)
except json.JSONDecodeError:
pass
# 试 2: 当 Python list/tuple repr 解析
try:
parsed = ast.literal_eval(v)
if isinstance(parsed, (list, tuple)):
return list(parsed)
except (ValueError, SyntaxError):
pass
# 试 3: 逗号分隔的纯字符串列表(兜底)
return [s.strip().strip("'\"") for s in v.strip("[]").split(",") if s.strip()]
if isinstance(v, str) and v == "":
return None
return v
def _copy_from_path(conn_pg, table_name: str, columns: list[str], tmp_path: str) -> int:
"""从已写好的 temp file COPY 到 PG。
temp file 由调用方在游标活跃时填充好。
"""
cur = conn_pg.cursor()
with open(tmp_path, "rb") as fb:
full_table = f"market_data.{table_name}"
cur.copy_expert(
f"COPY {full_table} ({','.join(columns)}) FROM STDIN WITH (FORMAT text, NULL '')",
fb,
)
conn_pg.commit()
return 0 # 实际行数已经在外层统计了
def _truncate_pg_table(conn_pg, table_name: str) -> None:
"""清空 PG 表。用 DELETE 而不是 TRUNCATETRUNCATE 需要额外权限)。
DELETE 在大表上慢,但仅迁移时用一次可以接受。
"""
cur = conn_pg.cursor()
cur.execute(f"DELETE FROM market_data.{table_name}")
conn_pg.commit()
def migrate_table(mysql, pg, table_name: str, order_by: str, columns: list[str],
chunk_size: int = 100_000) -> int:
"""单表迁移:先 TRUNCATE,再流式读 MySQL + COPY 写 PG。"""
t0 = time.time()
col_list = ", ".join(f"`{c}`" for c in columns)
with mysql.cursor() as cur:
cur.execute(f"SELECT COUNT(*) FROM {table_name}")
n_total = cur.fetchone()["COUNT(*)"]
if n_total == 0:
print(f" {table_name:<25} 0 行(跳过)")
return 0
# 先清空 PG 表(确保是干净迁移,不重复)
_truncate_pg_table(pg, table_name)
# 读 MySQL(流式:SSCursor 不缓存全表)→ 写 PG 临时文件 → COPY
# 关键:迭代 + 写 temp file 必须在 with 块内完成(cur 关闭后再迭代会失败)
print(f" {table_name:<25} {n_total:>12,} 行 ...", end=" ", flush=True)
import json as _json
import tempfile
JSONB_COLS = {"dependency_ids"}
tmp = tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", prefix=f"migrate_{table_name}_", suffix=".tsv",
delete=False,
)
tmp_path = tmp.name
written = 0
try:
with mysql.cursor() as cur:
order_by_quoted = ", ".join(f"`{c.strip()}`" for c in order_by.split(","))
cur.execute(f"SELECT {col_list} FROM `{table_name}` ORDER BY {order_by_quoted}")
for r in cur: # SSCursor:一次一行,不缓存全表
cells = []
for col, v in zip(columns, (r.get(col) for col in columns)):
if v is None:
cells.append("")
elif col in JSONB_COLS and not isinstance(v, str):
cells.append(_json.dumps(v, ensure_ascii=False))
else:
s = str(v)
cells.append(s.replace("\\", "\\\\").replace("\t", "\\t").replace("\n", "\\n"))
tmp.write("\t".join(cells))
tmp.write("\n")
written += 1
# with 块结束,cur 已关闭。temp file 写完。关 file 准备 COPY。
tmp.flush()
tmp.close()
# COPY 从磁盘 temp file 读
with open(tmp_path, "rb") as fb:
cur_pg = pg.cursor()
full_table = f"market_data.{table_name}"
cur_pg.copy_expert(
f"COPY {full_table} ({','.join(columns)}) FROM STDIN WITH (FORMAT text, NULL '')",
fb,
)
pg.commit()
finally:
try:
import os as _os
_os.unlink(tmp_path)
except OSError:
pass
elapsed = time.time() - t0
print(f"{written:>12,} 行 ({elapsed:.1f}s)")
return written
def main():
p = argparse.ArgumentParser()
p.add_argument("--only", help="逗号分隔的表名列表,只迁指定的")
p.add_argument("--chunk-size", type=int, default=100_000)
p.add_argument("--skip", help="逗号分隔要跳过的表")
args = p.parse_args()
only = set((args.only or "").split(",")) if args.only else None
skip = set((args.skip or "").split(",")) if args.skip else set()
targets = [
t for t in TABLES
if (only is None or t[0] in only) and t[0] not in skip
]
print(f"=== MySQL → PG 数据迁移 ===")
print(f"目标表: {len(targets)}")
print(f"源: MySQL market_data_sync_db (127.0.0.1:3306)")
print(f"目标: PG market_data 库 market_data schema")
print()
mysql = mysql_conn()
pg = pg_conn()
grand_total = 0
grand_t0 = time.time()
for table_name, order_by, columns in targets:
try:
n = migrate_table(mysql, pg, table_name, order_by, columns, args.chunk_size)
grand_total += n
except Exception as e:
print(f"{table_name} 失败: {e}")
grand_elapsed = time.time() - grand_t0
print()
print(f"=== 全部完成:{grand_total:,} 行 / 耗时 {grand_elapsed:.1f}s ===")
# 简单校验:每张表行数对比
print("\n=== 校验:MySQL vs PG 行数对比 ===")
with mysql.cursor() as cur, pg.cursor() as cur_pg:
for table_name, _, _ in targets:
cur.execute(f"SELECT COUNT(*) AS n FROM {table_name}")
n_mysql = cur.fetchone()["n"]
cur_pg.execute(f"SELECT COUNT(*) FROM market_data.{table_name}")
n_pg = cur_pg.fetchone()[0]
ok = "" if n_mysql == n_pg else ""
print(f" {ok} {table_name:<25} MySQL={n_mysql:>12,} PG={n_pg:>12,}")
mysql.close()
pg.close()
if __name__ == "__main__":
main()
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# systemd 调度的入口(专跑龙虎榜)
#
# 设计:
# - 与 market_sync_moneyflow_run.sh 同构,只跑 longhubang 一个 task
# - 22:00 触发(龙虎榜 19:00~21:00 陆续出齐;与 moneyflow 21:35 错开避免并发限速)
# - 单日调用 ~3 次聚合层 + ~200 次席位层,3-5 分钟内完成
# - 单独日志到 logs/lhb_<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/lhb_${TS}.log"
echo "[market-sync-lhb] start ts=$TS log=$LOG" | tee -a "$LOG"
# 注:不做法定节假日过滤 —— 与其他 3 个 timer 一致的 fail-open 原则。
# 非交易日 akshare 仍可能返当周数据 → task 报 warning,不丢历史。
# ── 跑 longhubang ──
cd "$PROJECT_ROOT"
.venv/bin/python -m app.entrypoints.cli sync longhubang 2>&1 | tee -a "$LOG"
EXIT_CODE=${PIPESTATUS[0]}
echo "[market-sync-lhb] done exit_code=$EXIT_CODE" | tee -a "$LOG"
exit $EXIT_CODE
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# systemd 调度的入口(专跑 moneyflow
#
# 设计:
# - 与 market_sync_tick_run.sh 同构,只跑 moneyflow 一个 task
# - mairui 文档:「更新:每日 21:30」—— systemd 21:35 触发,留 5min buffer
# - CLI 不传 --force:让 task 走 21:30 时间门控(moneyflow 暂无门控;为一致仍走通路径)
# - 单独日志到 logs/moneyflow_<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/moneyflow_${TS}.log"
echo "[market-sync-moneyflow] start ts=$TS log=$LOG" | tee -a "$LOG"
# 注:不做法定节假日过滤 —— 与 market_sync_run.sh 同样的 fail-open 原则。
# 大不了非交易日 mairui 返回空 → task 报 warning,不丢历史。
# ── 跑 moneyflow ──
cd "$PROJECT_ROOT"
.venv/bin/python -m app.entrypoints.cli sync moneyflow 2>&1 | tee -a "$LOG"
EXIT_CODE=${PIPESTATUS[0]}
echo "[market-sync-moneyflow] done exit_code=$EXIT_CODE" | tee -a "$LOG"
exit $EXIT_CODE
+92
View File
@@ -0,0 +1,92 @@
#!/bin/bash
# systemd 调度的入口:跑一次全量同步 + 配套结构化 watch
#
# 设计:
# - 后台启动 runall_once.py(输出重定向到时间戳 log)
# - 同时后台启动 structured_watch.sh 盯进程(13 checkpoint × 递增间隔)
# - wrapper 自身 fast-poll runall PID(每 5s 一次)
# - runall 一退出就 SIGTERM watch(不靠 watch 自己的 checkpoint 轮询,避开 30min 长 sleep
# - watch 自然结束后整个脚本退出
# - 退出码透传 runall_once.py 的 exit codesystemd 据此判断成功 / 失败
#
# 为什么不只用 structured_watch
# - 看 [[background-watcher-pitfalls]] 记忆:先承诺后验证是三大坑之一
# - structured_watch.sh 的 schedule 在后期是 30min / 60min 间隔
# - runall 死后,watch 最坏要等 60min 才发现进程没了
# - wrapper fast-poll 让响应时间 < 5s
#
# 为什么不让 systemd TimeoutStartSec= 自动收尾:
# - 超时是 SIGTERM → SIGKILL,但 SIGKILL 不给 runall / watch 写日志的机会
# - 自己 fast-poll 能优雅收尾(先 SIGTERM watch 留出写日志时间,再 SIGKILL 兜底)
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)
RUNALL_LOG="$LOG_DIR/runall_${TS}.log"
WATCH_LOG="$LOG_DIR/watch_${TS}.log"
echo "[market-sync-run] start ts=$TS"
echo "[market-sync-run] runall_log=$RUNALL_LOG"
echo "[market-sync-run] watch_log=$WATCH_LOG"
# 注:不做法定节假日过滤。理由(fail-open 原则):
# - 节假日配置(TRADING_HOLIDAYS / config 表)一旦出错(误把今天加进去、
# 漏更新本年度、笔误等)= 直接漏一天数据,且没声音
# - 大不了非交易日跑出来 mairui/baostock 返回空数据,task 报 warning 但不丢历史
# - 周末过滤由 systemd timer `OnCalendar=Mon..Fri` 完成,足够
# ── 1) 后台启动 runall_once.py ──
cd "$PROJECT_ROOT"
.venv/bin/python bin/runall_once.py > "$RUNALL_LOG" 2>&1 &
RUNALL_PID=$!
echo "[market-sync-run] runall pid=$RUNALL_PID"
# ── 2) 后台启动 structured_watch.sh(独立进程,不阻塞 wrapper)
# PATTERN 不含 "python" → 触发 watch 内部 case 第二分支,前缀 [p]ython.* 拼出
"$PROJECT_ROOT/bin/structured_watch.sh" \
"runall_once" \
"$RUNALL_LOG" \
"$WATCH_LOG" \
"market-sync-full" > /dev/null 2>&1 &
WATCH_PID=$!
echo "[market-sync-run] watch pid=$WATCH_PID"
# ── 3) wrapper 自身 fast-poll runall PID(每 5s
# 这是关键修复:runall 死后 < 5s 内就触发 watch 退出
RUNALL_EXIT_CODE=1
while kill -0 "$RUNALL_PID" 2>/dev/null; do
sleep 5
done
# runall 已退出,取 exit code
wait "$RUNALL_PID" 2>/dev/null
RUNALL_EXIT_CODE=$?
echo "[market-sync-run] runall 已退出 exit_code=$RUNALL_EXIT_CODE"
# ── 4) 通知 watch 退出(如果还在跑)──
if kill -0 "$WATCH_PID" 2>/dev/null; then
echo "[market-sync-run] 通知 watch 退出(pid=$WATCH_PID"
kill -TERM "$WATCH_PID" 2>/dev/null || true
# 给 watch 30s 自然退出(不要立即 KILL,留它写最后日志)
for _ in 1 2 3 4 5 6; do
sleep 5
kill -0 "$WATCH_PID" 2>/dev/null || break
done
if kill -0 "$WATCH_PID" 2>/dev/null; then
echo "[market-sync-run] ⚠️ watch 30s 内未退出,强制 kill -9"
kill -KILL "$WATCH_PID" 2>/dev/null || true
fi
fi
# ── 5) 输出 summary ──
SUMMARY="$LOG_DIR/runall_summary.json"
if [ -f "$SUMMARY" ]; then
echo "[market-sync-run] === summary ==="
cat "$SUMMARY"
echo "[market-sync-run] === /summary ==="
fi
echo "[market-sync-run] done exit_code=$RUNALL_EXIT_CODE"
exit "$RUNALL_EXIT_CODE"
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# systemd 调度的入口(专跑 tick_trade
#
# 设计:
# - 比 market_sync_run.sh 简单:只跑一个 task (tick_trade)
# - 21:00 门控由 task 内部处理;systemd 触发时间 21:05 留 5min buffer
# - CLI 加 --force 跳过 21:00 门控(systemd 触发时已是 21:05+force 不会真正"跳过"
# 任何东西,但保持与 runall_once 同步任务的 force 语义一致)
# - 单独日志到 logs/tick_<ts>.log
# - exit code 透传(systemd 据此判成功 / 失败)
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/tick_${TS}.log"
echo "[market-sync-tick] start ts=$TS log=$LOG" | tee -a "$LOG"
# 注:不做法定节假日过滤 —— 与 market_sync_run.sh 同样的 fail-open 原则。
# 大不了非交易日 mairui 返回空 → task 报 warning,不丢历史。
# ── 跑 tick_tradeforce=True 跳过 21:00 门控,因为 systemd 触发时间 21:05 已过门控)──
cd "$PROJECT_ROOT"
.venv/bin/python -m app.entrypoints.cli sync tick_trade --force 2>&1 | tee -a "$LOG"
EXIT_CODE=${PIPESTATUS[0]}
echo "[market-sync-tick] done exit_code=$EXIT_CODE" | tee -a "$LOG"
exit $EXIT_CODE
+5 -3
View File
@@ -1,6 +1,7 @@
#!/bin/bash
# 跑剩余三个任务:moneyflow → share_snapshot → market_regime
# 跑剩余任务:tick_trade → moneyflow → share_snapshot → market_regime
# 跳过 kline_5min(全量回填 6 年太慢,下次有空再补)
# tick_trade 用 --force 跳过 21:00 门控(人工补跑场景)
set -e
cd "$(dirname "$0")/.."
export PYTHONPATH="$(pwd)"
@@ -9,11 +10,12 @@ mkdir -p logs
# 先清理上次可能留下的卡死记录(recover_interrupted_syncs 启动时自动处理,但显式更稳)
echo "=== 启动时间: $(date) ===" | tee logs/runall_remaining.log
for TASK in moneyflow share_snapshot market_regime; do
for TASK in "tick_trade --force" moneyflow share_snapshot market_regime; do
echo "" | tee -a logs/runall_remaining.log
echo "=== [$TASK] 开始 $(date) ===" | tee -a logs/runall_remaining.log
T0=$(date +%s)
if .venv/bin/python -m app.entrypoints.cli sync "$TASK" 2>&1 | tee -a logs/runall_remaining.log; then
# shellcheck disable=SC2086
if .venv/bin/python -m app.entrypoints.cli sync $TASK 2>&1 | tee -a logs/runall_remaining.log; then
T1=$(date +%s)
echo "=== [$TASK] 完成,耗时 $((T1 - T0))s ===" | tee -a logs/runall_remaining.log
else
+14 -9
View File
@@ -1,10 +1,14 @@
"""一次性跑完所有 sync task(按依赖顺序)。
跳过 industry_sector(已 ok)。
按顺序触发:stock_basic → kline_daily → kline_index → kline_5min → moneyflow
→ share_snapshot → market_regime
按 sort_order 触发:stock_basic → kline_daily → kline_index → kline_5min
→ industry_sector → sector_features
→ share_snapshot → market_regime
每个 task 跑完写日志 + 把 result 落到 summary.json。
每个 task 跑完写日志 + 把 result 落到 logs/runall_summary.json。
注:以下 task **不**在 runall 链中 —— 各自有独立的 systemd timer
- tick_trade : mairui 21:00 发布 → market-sync-tick.timer (21:05)
- moneyflow : mairui 21:30 发布 → market-sync-moneyflow.timer (21:35)
"""
from __future__ import annotations
@@ -28,20 +32,21 @@ seed_sync_registry()
from app.tasks import get_task
# 顺序执行,便于排查
# 顺序执行,按 sort_order 走(便于排查 + 避免外部 API 限流)
# 注:tick_trade / moneyflow 由独立 timer 跑(21:05 / 21:35
TASKS = [
"stock_basic",
"industry_sector", # 已 ok,但再跑一次全量也 OK (用 SKIP 标志)
"kline_index",
"kline_daily",
"kline_5min",
"moneyflow",
"industry_sector",
"sector_features",
"share_snapshot",
"market_regime",
]
# industry_sector 跑过一次(291s 全量)已 ok,跳过
SKIP = {"industry_sector"}
# 不跳任何 task — 全量跑
SKIP: set[str] = set()
results = {}
t_all = time.time()
+33
View File
@@ -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 market-sync-tick.service market-sync-moneyflow.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
+16
View File
@@ -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
+33
View File
@@ -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 market-sync-tick.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
+16
View File
@@ -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
+33
View File
@@ -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
+17
View File
@@ -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
+32
View File
@@ -0,0 +1,32 @@
[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
# 硬上限 2h(实际 ~80min,留 buffer 给 retry / 慢任务)
TimeoutStartSec=7200
# 不要 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