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
+261
View File
@@ -0,0 +1,261 @@
"""同步任务:mairui 日 K 级别技术指标 MACD / KDJ / BOLL。
数据源:mairui /hsstock/history/{indicator}/{symbol}/d/n/{licence}
- macd → market_data.kline_stock_macd_daily (diff, dea, macd, ema12, ema26)
- kdj → market_data.kline_stock_kdj_daily (k, d, j)
- boll → market_data.kline_stock_boll_daily (upper, mid, lower ← mairui u/m/d)
设计(对齐 task_kline_daily):
- 全市场 A 股循环,每只票分别拉 3 个指标
- 增量:读各指标表 MAX(trade_date),只补新增区间(回看 5 天防漏)
- 并发 fetch + 攒 BATCH 批量 upsert,写库串行加锁
- stock_code 落库用 hermes 格式(SH600519),与项目其他表一致
参考实现:pre_limit_seeker/production/sync_mairui_indicators.pyparquet 版)。
"""
from __future__ import annotations
import os
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
from typing import Any
from app.core.db import ops as db_ops
from app.core.datasource.base import registry as ds_registry
from app.core.datasource.utils import is_a_share_code, to_code6, to_hermes
from app.core.sync.base import SyncTask, _effective_sync_end
from app.core.utils.logging import get_logger
logger = get_logger("sync.mairui_indicators")
DEFAULT_START = "2018-01-01"
BATCH_SIZE = 2000
# mairui 钻石档 100 RPS5 workers × 10 RPS/worker = 50 RPS,留 buffer
DEFAULT_FETCH_WORKERS = 5
# 指标 → (mairui 源字段, 落库列名, upsert 函数)
INDICATORS: dict[str, dict[str, Any]] = {
"macd": {
"src_fields": ["diff", "dea", "macd", "ema12", "ema26"],
"db_cols": ["diff", "dea", "macd", "ema12", "ema26"],
"upsert": db_ops.upsert_kline_stock_macd_daily_rows,
},
"kdj": {
"src_fields": ["k", "d", "j"],
"db_cols": ["k", "d", "j"],
"upsert": db_ops.upsert_kline_stock_kdj_daily_rows,
},
"boll": {
# mairui: u=上轨, m=中轨, d=下轨 → 落库 upper/mid/lower
"src_fields": ["u", "m", "d"],
"db_cols": ["upper", "mid", "lower"],
"upsert": db_ops.upsert_kline_stock_boll_daily_rows,
},
}
class SyncMairuiIndicators(SyncTask):
dataset_id = "mairui_indicators"
def _run(
self,
*,
trigger_source: str = "manual",
indicators: list[str] | None = None,
codes: list[str] | None = None,
start: str | None = None,
end: str | None = None,
incremental: bool = True,
max_workers: int = DEFAULT_FETCH_WORKERS,
**kwargs,
) -> dict[str, Any]:
targets = [i.lower() for i in (indicators or ["macd", "kdj", "boll"])]
for i in targets:
if i not in INDICATORS:
return {"status": "error", "message": f"未知指标 {i!r},可选 {list(INDICATORS)}"}
source = self._pick_source()
if source is None:
return {"status": "error", "message": "mairui 数据源不可用(licence 未配置?)"}
# 股票列表(hermes 格式)
if codes:
hermes_codes = [to_hermes(c) for c in codes]
else:
hermes_codes = [
to_hermes(c) for c in db_ops.iter_stock_codes(active_only=True)
if is_a_share_code(c)
]
limit_raw = os.environ.get("MARKET_DATA_STOCK_LIMIT", "").strip()
if limit_raw.isdigit() and int(limit_raw) > 0:
hermes_codes = hermes_codes[: int(limit_raw)]
if not hermes_codes:
return {"status": "error", "message": "无股票代码(stocks 表为空?)"}
_end = end or _effective_sync_end()
end_date_obj = datetime.strptime(_end, "%Y-%m-%d").date()
t0 = time.time()
agg_ok = 0
agg_fail = 0
agg_rows = 0
per_indicator: dict[str, dict[str, int]] = {}
for ind in targets:
self._progress(message=f"[{ind}] 规划增量区间...", current_step=ind)
jobs = self._plan_jobs(ind, hermes_codes, start, incremental, end_date_obj)
total = len(jobs)
skipped = len(hermes_codes) - total
logger.info(f"[mairui_indicators] {ind}: to_pull={total}, skip={skipped}")
self._progress(
message=f"[{ind}] 开始同步 {total} 只 (跳过 {skipped} 只已最新)",
current=0, total=total, current_step=ind,
)
ok, fail, rows = self._sync_indicator(
source, ind, jobs, _end, max_workers,
)
per_indicator[ind] = {"ok": ok, "fail": fail, "skip": skipped, "rows": rows}
agg_ok += ok
agg_fail += fail
agg_rows += rows
elapsed = round(time.time() - t0, 1)
parts = ", ".join(
f"{k}({v['ok']}成/{v['fail']}败/{v['rows']}行)"
for k, v in per_indicator.items()
)
msg = f"指标 {parts};共 {agg_rows} 行, {elapsed}s"
logger.info(f"[mairui_indicators] {msg}")
return {
"status": "ok" if agg_fail == 0 else "warning",
"message": msg,
"ok": agg_ok,
"fail": agg_fail,
"rows": agg_rows,
"elapsed_sec": elapsed,
"per_indicator": per_indicator,
}
def _plan_jobs(
self, indicator: str, hermes_codes: list[str],
start: str | None, incremental: bool, end_date_obj,
) -> list[tuple[str, str]]:
"""返回 [(hermes_code, fetch_start), ...]。"""
jobs: list[tuple[str, str]] = []
if incremental and not start:
for hc in hermes_codes:
last = db_ops.get_indicator_max_date(indicator, hc)
if last is None:
fetch_start = DEFAULT_START
elif datetime.strptime(last, "%Y-%m-%d").date() >= end_date_obj:
continue
else:
fetch_start = (
datetime.strptime(last, "%Y-%m-%d").date() - timedelta(days=5)
).strftime("%Y-%m-%d")
jobs.append((hc, fetch_start))
else:
fetch_start = start or DEFAULT_START
jobs = [(hc, fetch_start) for hc in hermes_codes]
return jobs
def _sync_indicator(
self, source, indicator: str, jobs: list[tuple[str, str]],
end: str, max_workers: int,
) -> tuple[int, int, int]:
cfg = INDICATORS[indicator]
upsert_fn = cfg["upsert"]
src_fields = cfg["src_fields"]
db_cols = cfg["db_cols"]
batch_lock = threading.Lock()
batch_rows: list[dict] = []
ok_lock = threading.Lock()
ok_cnt = [0]
fail_cnt = [0]
rows_cnt = [0]
def _flush():
with batch_lock:
if not batch_rows:
return
to_write = list(batch_rows)
batch_rows.clear()
try:
upsert_fn(to_write)
except Exception as e:
logger.error(f"[{indicator}] batch upsert 失败 ({len(to_write)} 行): {e}")
def _fetch_one(hermes_code: str, fetch_start: str):
code6 = to_code6(hermes_code)
try:
df = source.fetch_indicator_daily(indicator, code6, fetch_start, end)
except Exception as e:
return (hermes_code, None, str(e)[:120])
if df is None or df.empty:
return (hermes_code, None, "no data")
rows = []
for _, r in df.iterrows():
td = r["trade_date"]
# 2026-07-11 修复: mairui 可能返回整行 NaN/NaT(如 688 早期无指标),
# float(NaT) 会抛 TypeError。用 pandas.isna 统一跳过无效值。
row = {
"stock_code": hermes_code,
"trade_date": td.strftime("%Y-%m-%d") if hasattr(td, "strftime") else str(td)[:10],
"source": "mairui",
}
for src_f, db_c in zip(src_fields, db_cols):
v = r.get(src_f)
if v is None or (hasattr(v, "isna") and v.isna()) or (isinstance(v, float) and v != v):
row[db_c] = None
else:
try:
row[db_c] = float(v)
except Exception:
row[db_c] = None
rows.append(row)
return (hermes_code, rows, None)
total = len(jobs)
t0 = time.time()
with ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {pool.submit(_fetch_one, hc, fs): hc for hc, fs in jobs}
done = 0
for fut in as_completed(futures):
done += 1
hermes_code, rows, err = fut.result()
if rows is None:
with ok_lock:
fail_cnt[0] += 1
continue
with batch_lock:
batch_rows.extend(rows)
cur = len(batch_rows)
with ok_lock:
ok_cnt[0] += 1
rows_cnt[0] += len(rows)
if cur >= BATCH_SIZE:
_flush()
if done % 100 == 0 or done == total:
el = time.time() - t0
rate = done / el if el > 0 else 0
self._progress(
message=f"[{indicator}] {done}/{total} OK:{ok_cnt[0]} FAIL:{fail_cnt[0]} {rate:.1f}只/秒",
current=done, total=total, current_step=indicator,
)
_flush()
return ok_cnt[0], fail_cnt[0], rows_cnt[0]
def _pick_source(self):
from app.core.datasource.registry import (
get_health_status, is_source_ready, run_health_check,
)
if not get_health_status():
run_health_check()
ok, _ = is_source_ready("datasource_mairui")
if ok:
return ds_registry.get("datasource_mairui")
return None