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:
@@ -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 用 JSONB(dataset_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_eval(Python 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 而不是 TRUNCATE(TRUNCATE 需要额外权限)。
|
||||
|
||||
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()
|
||||
Executable
+29
@@ -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
|
||||
Executable
+29
@@ -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
|
||||
Executable
+92
@@ -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 code,systemd 据此判断成功 / 失败
|
||||
#
|
||||
# 为什么不只用 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"
|
||||
Executable
+30
@@ -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_trade(force=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
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 upsert,3-5 分钟内完成,留 buffer)
|
||||
TimeoutStartSec=3600
|
||||
|
||||
# 不要 Restart=(oneshot 失败就让 OnFailure= 发通知,别自动重跑——
|
||||
# 重跑会撞外部 API 限流窗口,浪费资源)
|
||||
# Restart=no 是 oneshot 默认值
|
||||
|
||||
# 日志走 journald(journalctl -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,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
|
||||
|
||||
# 硬上限 2h(5206 只 × 50 RPS = 104s 理论上限,留 buffer 给重试 / 慢响应)
|
||||
TimeoutStartSec=7200
|
||||
|
||||
# 不要 Restart=(oneshot 失败就让 OnFailure= 发通知,别自动重跑——
|
||||
# 重跑会撞外部 API 限流窗口,浪费 1.5h)
|
||||
# Restart=no 是 oneshot 默认值
|
||||
|
||||
# 日志走 journald(journalctl -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,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 一个 task(mairui 21:00 发布数据,
|
||||
# 这个 service 在 21:05 触发,留 5min buffer;force=True 绕过 task 内部 21:00 门控)
|
||||
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_tick_run.sh
|
||||
|
||||
# 硬上限 2h(5000 只 × 1 RPS ≈ 17min,留 buffer 给 retry / 慢任务)
|
||||
TimeoutStartSec=7200
|
||||
|
||||
# 不要 Restart=(oneshot 失败就让 OnFailure= 发通知,别自动重跑——
|
||||
# 重跑会撞外部 API 限流窗口,浪费 1.5h)
|
||||
# Restart=no 是 oneshot 默认值
|
||||
|
||||
# 日志走 journald(journalctl -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,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 默认值
|
||||
|
||||
# 日志走 journald(journalctl -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
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Schedule market data full sync — Mon..Fri 15:30 Asia/Shanghai
|
||||
# market-sync.service 是这个 timer 的执行单元
|
||||
|
||||
[Timer]
|
||||
# A 股收盘时间 15:00,30 分钟 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
|
||||
Reference in New Issue
Block a user