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:
Executable
+409
@@ -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
@@ -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"]:
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
deploy/systemd/deploy.sh
|
||||
Executable
+17
@@ -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
|
||||
Executable
+17
@@ -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
|
||||
Executable
+17
@@ -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
|
||||
Executable
+25
@@ -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
|
||||
Executable
+25
@@ -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
|
||||
Executable
+17
@@ -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
|
||||
@@ -1,8 +1,6 @@
|
||||
#!/bin/bash
|
||||
# 早盘前同步:stock_basic + industry_sector
|
||||
# 早盘前同步:stock_basic(仅)
|
||||
# - stock_basic:雪球 quote_detail 拉全市场股本快照(~9min @ 5 RPS)
|
||||
# - industry_sector:baostock 拉股票-行业映射(~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:
|
||||
|
||||
Executable
+24
@@ -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
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/bin/bash
|
||||
# 跑剩余任务:tick_trade → moneyflow → share_snapshot → market_regime
|
||||
# 跑剩余任务:tick_trade → moneyflow → market_regime
|
||||
# share_snapshot 已改为每周单独 timer(market-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
@@ -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))
|
||||
@@ -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 就绪(避免 race:market_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
|
||||
@@ -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= 发通知,别自动重跑)
|
||||
|
||||
@@ -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:30,09:00 跑 stock_basic (~9min) + industry_sector (~16min) = ~25min
|
||||
# A 股开盘 09:30,09:00 跑 stock_basic (~9min) 即可
|
||||
OnCalendar=Mon..Fri 09:00:00 Asia/Shanghai
|
||||
# 关机/错过时下次开机补跑
|
||||
Persistent=true
|
||||
|
||||
@@ -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_detail,10 worker 约 3-8min,留 buffer)
|
||||
TimeoutStartSec=1800
|
||||
|
||||
# 不要 Restart=(oneshot 失败就让 OnFailure= 发通知,别自动重跑)
|
||||
# Restart=no 是 oneshot 默认值
|
||||
|
||||
# 日志走 journald(journalctl -u market-sync-share.service -f)
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=market-sync-share
|
||||
|
||||
# 环境(不读 /etc/environment,只带这几个;.env 由 cli 内部 load_dotenv)
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Schedule share_snapshot sync — Sat 11:30 Asia/Shanghai
|
||||
# market-sync-share.service 是这个 timer 的执行单元
|
||||
# 股本快照每周跑一次即可(雪球 quote_detail,周末收盘后数据稳定)
|
||||
|
||||
[Timer]
|
||||
# 周六 11:30(和 stock_node 同时段)
|
||||
OnCalendar=Sat *-*-* 11:30:00 Asia/Shanghai
|
||||
# 系统关机 / 错过执行时,下次开机补跑一次(避免漏数据)
|
||||
Persistent=true
|
||||
# 单位(service)的精确名称
|
||||
Unit=market-sync-share.service
|
||||
# 不要 AccuracySec(默认 1min 漂移够用)
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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 / 慢任务)
|
||||
# 历史 bug(2026-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)
|
||||
|
||||
@@ -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
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
deploy/systemd/deploy.sh
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user