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
+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
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")
# ── 运行模式 ───────────────────────────────────────────────────────────────
def _runtime_mode() -> str:
"""读取当前运行模式:docker | systemd。
优先读环境变量 RUNTIME_MODE,未设置时尝试从 .env 读取,默认 docker。
"""
mode = os.environ.get("RUNTIME_MODE", "")
if not mode:
env_file = PROJECT_ROOT / ".env"
if env_file.exists():
try:
for line in env_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith("RUNTIME_MODE="):
mode = line.split("=", 1)[1].strip().strip('"').strip("'")
break
except Exception:
pass
return (mode or "docker").lower()
# ── 预期调度表 ─────────────────────────────────────────────────────────────
# 与 [[market-data-overview]] 保持一致;同步触发点(HH:MM)按 Asia/Shanghai 解释。
# weekend_only=True 表示只在周六/周日期望运行(实际仅 stock_node 周六一次)。
SCHEDULE: dict[str, dict[str, Any]] = {
"stock_basic": {"window_end": "09:30", "weekend_only": False},
"industry_sector": {"window_end": "09:45", "weekend_only": False},
"kline_index": {"window_end": "15:35", "weekend_only": False},
"kline_daily": {"window_end": "15:45", "weekend_only": False},
"kline_5min": {"window_end": "16:05", "weekend_only": False},
"market_regime": {"window_end": "16:20", "weekend_only": False},
"share_snapshot": {"window_end": "16:25", "weekend_only": False},
"share_snapshot": {"window_end": "12:00", "weekend_only": True}, # 周六
"moneyflow": {"window_end": "21:40", "weekend_only": False},
"longhubang": {"window_end": "22:05", "weekend_only": False},
"stock_node": {"window_end": "12:00", "weekend_only": True}, # 周六
"mairui_ma_daily": {"window_end": "17:00", "weekend_only": False}, # 本地派生
"mairui_indicators": {"window_end": "17:10", "weekend_only": False}, # mairui MACD/KDJ/BOLL
}
@@ -245,25 +268,29 @@ def _classify_task(
# ── 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")
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:未部署)
drifted: 两边都有但内容不一致(drift 2:部署的版本过期)
orphan_in_etc: /etc 有但 repo 没有(drift 3:临时/僵尸 unit
"""
repo_dir = SYSTEMD_REPO_DIR if SYSTEMD_REPO_DIR.exists() else SYSTEMD_LEGACY_REPO_DIR
missing_in_etc: list[str] = []
drifted: list[dict[str, str]] = []
orphan_in_etc: list[str] = []
repo_units: set[str] = set()
if SYSTEMD_REPO_DIR.exists():
for f in SYSTEMD_REPO_DIR.iterdir():
if repo_dir.exists():
for f in repo_dir.iterdir():
if f.suffix in {".service", ".timer"} and f.name.startswith("market-sync"):
repo_units.add(f.name)
@@ -279,8 +306,12 @@ def _check_systemd_drift() -> dict[str, Any]:
# drift 2: 两边都有但内容不一致
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
# 跳过被 mask 的 unit/etc 下是 /dev/null 的符号链接,表示 systemd 故意禁用
# 这是 deploy.sh 对 market-sync-worker.service 的标准操作,不应报 drift
if etc_path.is_symlink() and etc_path.resolve() == Path("/dev/null"):
continue
try:
if repo_path.read_bytes() != etc_path.read_bytes():
drifted.append({
@@ -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]:
"""生成当日巡检报告。"""
@@ -338,7 +450,7 @@ def build_report(check_date: date) -> dict[str, Any]:
alerts.append({
"dataset_id": f"systemd/{u}",
"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"]:
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 没维护",
})
# 数据一致性检查(上游回填后下游未跟进)
consistency = _check_data_consistency()
alerts.extend(consistency["alerts"])
overall = "ok" if not alerts else (
"warning" if any(a["status"] in {"missed", "never_run", "not_deployed", "drifted"} for a in alerts) else "error"
)
@@ -363,11 +479,13 @@ def build_report(check_date: date) -> dict[str, Any]:
"schema_version": 2,
"generated_at": now.strftime("%Y-%m-%d %H:%M:%S"),
"check_date": check_date.isoformat(),
"runtime_mode": _runtime_mode(),
"overall": overall,
"counts": counts,
"alerts": alerts,
"tasks": classified,
"systemd_drift": drift,
"data_consistency": consistency["details"],
}
@@ -383,7 +501,7 @@ def write_report(report: dict[str, Any]) -> Path:
def print_summary(report: dict[str, Any]) -> None:
"""stdout 一眼看懂的汇总。"""
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(f"counts: {report['counts']}")
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
# 早盘前同步:stock_basic + industry_sector
# 早盘前同步:stock_basic(仅)
# - stock_basic:雪球 quote_detail 拉全市场股本快照(~9min @ 5 RPS
# - industry_sectorbaostock 拉股票-行业映射(~16min @ 5 RPS
# - 串行执行,industry_sector 依赖 stock_basic
# - 与 runall_once.py 区别:不拉 kline_daily / kline_5min 等耗时长任务
set -u
@@ -27,7 +25,7 @@ seed_sync_registry()
from app.tasks import get_task
results = []
for tid in ['stock_basic', 'industry_sector']:
for tid in ['stock_basic']:
print(f'[morning-run] >>> 开始 {tid}', flush=True)
t0 = time.time()
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
# 跑剩余任务:tick_trade → moneyflow → share_snapshot → market_regime
# 跑剩余任务:tick_trade → moneyflow → market_regime
# share_snapshot 已改为每周单独 timermarket-sync-share.timer),不在此处触发
# 跳过 kline_5min(全量回填 6 年太慢,下次有空再补)
# tick_trade 用 --force 跳过 21:00 门控(人工补跑场景)
set -e
@@ -10,7 +11,7 @@ mkdir -p logs
# 先清理上次可能留下的卡死记录(recover_interrupted_syncs 启动时自动处理,但显式更稳)
echo "=== 启动时间: $(date) ===" | tee logs/runall_remaining.log
for TASK in "tick_trade --force" moneyflow share_snapshot market_regime; do
for TASK in "tick_trade --force" moneyflow market_regime; do
echo "" | tee -a logs/runall_remaining.log
echo "=== [$TASK] 开始 $(date) ===" | tee -a logs/runall_remaining.log
T0=$(date +%s)
+118 -31
View File
@@ -9,13 +9,26 @@
注:以下 task **不**在 runall 链中 —— 各自有独立的 systemd timer
- tick_trade : mairui 21:00 发布 → market-sync-tick.timer (21:05)
- moneyflow : mairui 21:30 发布 → market-sync-moneyflow.timer (21:35)
2026-07-08 教训: 之前 runall 是顺序串行,任一 task 永久卡住(kline_daily 在
5200/5204 卡死 4h)→ 后面 5 个 task 全部没机会跑,runall 进程被 systemd 4h
超时杀, dataset_registry 卡在 running 状态无人清理。修复:
1. 启动时调 recover_interrupted_dataset_registry() — 清上次被中断的卡死
2. 每个 task 用 multiprocessing.Process 跑 + 硬上限 PER_TASK_TIMEOUT_SEC,
超时直接 kill 子进程,继续下一个 task (不卡整条 runall)
3. 数据源/任务层也加了 timeout(xueqiu 20s + kline_daily as_completed 30s),
这里是最后一道防线
"""
from __future__ import annotations
import json
import multiprocessing as mp
import os
import signal
import sys
import time
from pathlib import Path
from typing import Any
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(_PROJECT_ROOT) not in sys.path:
@@ -30,8 +43,19 @@ from app.core.sync.registry import seed_sync_registry
build_default_registry()
seed_sync_registry()
from app.core.db import ops as db_ops
from app.tasks import get_task
# 启动时清理上一轮被中断的卡死状态(2026-07-08 教训)
# 注意: 这里清的是"上次 runall 被杀时的 running",如果当前 runall 自己卡死,
# 子进程被 kill 后 recover 仍要再调一次 — 见末尾的 finally。
try:
n_recovered = db_ops.recover_interrupted_dataset_registry()
if n_recovered:
logger.warning("[runall] 启动时清掉 %d 个上轮卡死的 running 任务", n_recovered)
except Exception as e:
logger.warning("[runall] 启动清理失败(非致命): %s", e)
# 顺序执行,按 sort_order 走(便于排查 + 避免外部 API 限流)
# 注:tick_trade / moneyflow 由独立 timer 跑(21:05 / 21:35
TASKS = [
@@ -39,48 +63,111 @@ TASKS = [
"kline_index",
"kline_daily",
"kline_5min",
"industry_sector",
"sector_features",
"share_snapshot",
"market_regime",
]
# 不跳任何 task — 全量跑
SKIP: set[str] = set()
results = {}
t_all = time.time()
for tid in TASKS:
if tid in SKIP:
results[tid] = {"status": "skipped", "message": "已 ok"}
logger.info(f"[runall] {tid} 跳过(已 ok")
continue
logger.info(f"[runall] >>> 开始 {tid}")
t0 = time.time()
# 每个 task 的硬上限(秒)。覆盖数据源/任务层都失败的最坏情况:
# kline_daily: 35 min (正常 18 min, 留余量)
# kline_5min: 75 min (全量首次跑很久, 增量 ~30 min)
# share_snapshot: 15 min
# 其他小表: 10 min
PER_TASK_TIMEOUT_SEC = {
"kline_daily": 35 * 60,
"kline_5min": 75 * 60,
"share_snapshot": 15 * 60,
"market_regime": 5 * 60,
"stock_basic": 15 * 60,
"kline_index": 5 * 60,
}
DEFAULT_TASK_TIMEOUT = 20 * 60
def _run_task_in_subprocess(tid: str, result_queue: mp.Queue) -> None:
"""子进程入口: 跑 task.run() 并把 result 通过 queue 返回。
单独进程是为了父进程能用 SIGKILL 干掉它(timeout 时)而不污染主 runall 状态。
"""
try:
task = get_task(tid)
# 大表 task 限小批量股票,避免跑爆;其他 task 跑全量
kwargs = {"max_workers": 5}
if tid in {"kline_daily", "kline_5min", "moneyflow", "share_snapshot"}:
# 这些 task 支持 MARKET_DATA_STOCK_LIMIT 环境变量,但通过 cli 跑可 --codes 限
# 测全量费时,先跑全量
pass
r = task.run(trigger_source="runall", **kwargs)
elapsed = round(time.time() - t0, 1)
r["elapsed_sec"] = elapsed
results[tid] = r
logger.info(f"[runall] <<< {tid} 完成 status={r.get('status')} elapsed={elapsed}s msg={r.get('message')}")
result_queue.put(r)
except Exception as e:
result_queue.put({"status": "error", "message": f"{type(e).__name__}: {e}"})
results: dict[str, Any] = {}
t_all = time.time()
# 注意: 主执行块必须在 __name__ == '__main__' 内部,否则 multiprocessing spawn
# 子进程重新 import 本模块时会再次执行进程启动逻辑,导致 RuntimeError:
# "An attempt has been made to start a new process before the current process
# has finished its bootstrapping phase." (2026-07-13 15:30 runall 全部失败)
def _run_tasks():
global results
for tid in TASKS:
if tid in SKIP:
results[tid] = {"status": "skipped", "message": "已 ok"}
logger.info(f"[runall] {tid} 跳过(已 ok")
continue
timeout_sec = PER_TASK_TIMEOUT_SEC.get(tid, DEFAULT_TASK_TIMEOUT)
logger.info(f"[runall] >>> 开始 {tid} (hard timeout {timeout_sec}s)")
t0 = time.time()
# 子进程跑 task,父进程用 .join(timeout) 守门
ctx = mp.get_context("spawn")
result_queue: mp.Queue = ctx.Queue()
proc = ctx.Process(target=_run_task_in_subprocess, args=(tid, result_queue), name=f"runall-{tid}")
proc.start()
proc.join(timeout=timeout_sec)
elapsed = round(time.time() - t0, 1)
logger.exception(f"[runall] !!! {tid} 异常: {e}")
results[tid] = {"status": "error", "message": str(e), "elapsed_sec": elapsed}
# 每个 task 之间 sleep 5s 让健康监控跑
time.sleep(5)
if proc.is_alive():
logger.error(
f"[runall] !!! {tid} 超时 (>{timeout_sec}s, elapsed={elapsed}s), 强制 kill"
)
proc.terminate()
proc.join(5)
if proc.is_alive():
proc.kill()
proc.join(2)
results[tid] = {
"status": "error",
"message": f"runall timeout (>={timeout_sec}s), killed",
"elapsed_sec": elapsed,
}
try:
db_ops.recover_interrupted_dataset_registry()
except Exception as e:
logger.warning("[runall] 超时后清理 dataset_registry 失败: %s", e)
else:
try:
r = result_queue.get(timeout=5)
except Exception as e:
r = {"status": "error", "message": f"无法获取子进程结果: {e}"}
r["elapsed_sec"] = elapsed
results[tid] = r
logger.info(
f"[runall] <<< {tid} 完成 status={r.get('status')} elapsed={elapsed}s msg={r.get('message')}"
)
elapsed_total = round(time.time() - t_all, 1)
summary = {"total_elapsed_sec": elapsed_total, "tasks": results}
out = _PROJECT_ROOT / "logs" / "runall_summary.json"
out.write_text(json.dumps(summary, ensure_ascii=False, indent=2))
logger.info(f"[runall] 全部完成 total={elapsed_total}s summary={out}")
print(json.dumps(summary, ensure_ascii=False, indent=2))
time.sleep(5)
if __name__ == '__main__':
_run_tasks()
# 全部完成清一次 stuck 状态
try:
db_ops.recover_interrupted_dataset_registry()
except Exception as e:
logger.warning("[runall] 末尾清理失败(非致命): %s", e)
elapsed_total = round(time.time() - t_all, 1)
summary: dict[str, Any] = {"total_elapsed_sec": elapsed_total, "tasks": results}
out = _PROJECT_ROOT / "logs" / "runall_summary.json"
out.write_text(json.dumps(summary, ensure_ascii=False, indent=2))
logger.info(f"[runall] 全部完成 total={elapsed_total}s summary={out}")
print(json.dumps(summary, ensure_ascii=False, indent=2))
+9
View File
@@ -10,6 +10,15 @@
set -eu
RUNTIME_MODE="${RUNTIME_MODE:-docker}"
if [ "$RUNTIME_MODE" != "docker" ]; then
echo "[service] ❌ ERROR: service_run.sh 是 Docker 入口,只在 RUNTIME_MODE=docker 时可用。" >&2
echo "[service] 当前 RUNTIME_MODE=$RUNTIME_MODE" >&2
echo "[service] Linux 宿主机部署请使用 systemd timer/service,不要运行此脚本。" >&2
exit 1
fi
# 等 PG 就绪(避免 racemarket_sync 比 postgres 早启)
if [ -n "${PG_HOST:-}" ]; then
echo "[service] wait for PG ${PG_HOST}:${PG_PORT:-5432}..."
@@ -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]
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
After=network-online.target
Wants=network-online.target
@@ -10,10 +10,10 @@ WorkingDirectory=/home/gao/Development/quant_home/market_sync
User=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
# stock_basic ~9min + industry_sector ~16min + sleep 5s + buffer = 1h 够用
# stock_basic ~9min + buffer = 1h 够用
TimeoutStartSec=3600
# 不要 Restart=oneshot 失败就让 OnFailure= 发通知,别自动重跑)
+2 -2
View File
@@ -1,10 +1,10 @@
[Unit]
Description=Schedule market sync morning — Mon..Fri 09:00 Asia/Shanghai
# market-sync-morning.service 是这个 timer 的执行单元
# 早盘前 09:00 触发:跑 stock_basic + industry_sector,给 09:30 开盘留 30min buffer
# 早盘前 09:00 触发:跑 stock_basic,给 09:30 开盘留 30min buffer
[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
# 关机/错过时下次开机补跑
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]
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
After=network-online.target market-sync-morning.service
Wants=network-online.target
[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 二合一)
ExecStart=/home/gao/Development/quant_home/market_sync/bin/market_sync_run.sh
# 硬上限 4h(实际 ~80min + kline_5min 增量 70min, 总 ~2.5h, 留 buffer 给 retry / 慢任务
# 历史 bug2026-07-02):2h 超时截断,share_snapshot / market_regime 没跑成
# 详见 docs/works/2026-07-03-01-market-sync-timeout.md
TimeoutStartSec=14400
# 硬上限 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)
-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()