chore: 重构前基线 — 9 个 sync task 全部 ok + akshare 移除 + mairui 资金流接入

状态:
- 9 个 sync task(stock_basic / kline_daily / kline_index / kline_5min /
  moneyflow / industry_sector / sector_features / share_snapshot / market_regime)
- 数据源:baostock + mairui + 雪球(pysnowball) + 新浪(4 个)
- 项目级约束:永远不用 akshare(已落实)
- kline_5min 改用 DB 快照统一全量/增量逻辑
- 零后端 Chrome 扩展 xueqiu_sync(独立项目)
This commit is contained in:
gao
2026-06-15 15:36:09 +08:00
commit 05635b76b9
55 changed files with 6307 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
"""同步任务注册表。
所有 `SyncTask` 子类在此集中注册,外部通过 `get_task(dataset_id)` 或
遍历 `TASKS` 字典使用。
"""
from app.tasks.task_industry_sector import SyncIndustrySector
from app.tasks.task_kline_5min import SyncKline5Min
from app.tasks.task_kline_daily import SyncKlineDaily
from app.tasks.task_kline_index import SyncKlineIndex
from app.tasks.task_market_regime import SyncMarketRegime
from app.tasks.task_moneyflow import SyncMoneyflow
from app.tasks.task_sector_features import SyncSectorFeatures
from app.tasks.task_share_snapshot import SyncShareSnapshot
from app.tasks.task_stocks_basic import SyncStocksBasic
TASKS: dict[str, type] = {
cls.dataset_id: cls
for cls in (
SyncStocksBasic,
SyncKlineDaily,
SyncKlineIndex,
SyncKline5Min,
SyncMoneyflow,
SyncIndustrySector,
SyncSectorFeatures,
SyncShareSnapshot,
SyncMarketRegime,
)
}
def get_task(dataset_id: str):
"""通过 dataset_id 取同步任务类实例,找不到抛 KeyError。"""
if dataset_id not in TASKS:
raise KeyError(f"未知的同步任务: {dataset_id!r},可选: {sorted(TASKS)}")
return TASKS[dataset_id]()
__all__ = ["TASKS", "get_task"]
+122
View File
@@ -0,0 +1,122 @@
"""同步任务:股票-行业映射(Baostock)。
数据流:Baostock query_stock_industry() → 写 industry / sectors / stock_sector_map 三张表。"""
from __future__ import annotations
import time
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.registry import is_source_ready
from app.core.sync.base import SyncTask
from app.core.sync.registry import mark_sync_blocked
from app.core.utils.logging import get_logger
logger = get_logger("sync.industry_sector")
def _build_sector_key(taxonomy: str, sector_name: str) -> str:
tax = (taxonomy or "").strip() or "default"
name = (sector_name or "").strip()
return f"{tax}::{name}"
class SyncIndustrySector(SyncTask):
dataset_id = "industry_sector"
def _run(self, *, trigger_source: str = "manual", **kwargs) -> dict[str, Any]:
bs = ds_registry.get("datasource_baostock")
if bs is None:
return {"status": "error", "message": "Baostock 数据源未注册"}
ok, reason = is_source_ready(bs.key)
if not ok:
mark_sync_blocked(self.dataset_id, message=f"Baostock 未就绪: {reason}")
return {"status": "blocked", "message": f"Baostock 未就绪: {reason}"}
self._progress(message="拉取股票-行业映射...")
t0 = time.time()
try:
raw_rows = bs.fetch_industry_map()
except Exception as e:
return {"status": "error", "message": f"拉取失败: {e}"}
if not raw_rows:
return {"status": "warning", "message": "Baostock 未返回行业数据"}
# 合并:sectors + stock_sector_map + industry
sectors_rows: dict[str, dict] = {}
stock_sector_rows: list[dict] = []
industry_rows: list[dict] = []
for r in raw_rows:
code6 = str(r["code"]).zfill(6)
name = (r.get("industry_name") or "").strip()
if not name:
continue
classification = (r.get("industry_classification") or "").strip()
sector_key = _build_sector_key(classification, name)
sectors_rows[sector_key] = {
"sector_key": sector_key,
"sector_name": name,
"taxonomy": classification,
"level": "",
"source": "baostock_query_stock_industry",
"enabled": 1,
}
stock_sector_rows.append({"stock_code": code6, "sector_key": sector_key})
industry_rows.append({
"code": code6,
"industry_name": name,
"industry_classification": classification,
"update_date": r.get("update_date", ""),
})
# 写库(全量替换)
try:
db_ops.replace_all_sectors(list(sectors_rows.values()))
db_ops.replace_all_stock_sector_map(stock_sector_rows)
db_ops.replace_all_industries(industry_rows)
except Exception as e:
return {"status": "error", "message": f"写库失败: {e}"}
# 回填 stocks.industry 字段(让 stocks 表也能直接看到)
# 使用批量 UPDATE ... CASE WHEN 方式一次性完成
try:
from app.core.db.connection import get_mysql
from app.core.db.schema import ensure_all_tables
ensure_all_tables(get_mysql())
# 按 industry_name 分组,对每组用一个 IN 查询批量更新
by_industry: dict[str, list[str]] = {}
for r in industry_rows:
name = r["industry_name"]
code = r["code"]
if name not in by_industry:
by_industry[name] = []
by_industry[name].append(code)
with get_mysql().cursor() as cur:
for ind_name, codes in by_industry.items():
# 构造 IN 子句:code IN (SHxxx, SZxxx, BJxxx)
placeholders = []
params = [ind_name]
for c in codes:
for prefix in ("SH", "SZ", "BJ"):
placeholders.append("%s")
params.append(f"{prefix}{c}")
in_clause = ", ".join(placeholders)
sql = f"UPDATE stocks SET industry = %s WHERE code IN ({in_clause})"
cur.execute(sql, params)
get_mysql().commit()
except Exception as e:
logger.warning(f"回填 stocks.industry 失败(不影响主任务): {e}")
elapsed = round(time.time() - t0, 1)
msg = f"行业 {len(sectors_rows)}个 映射{len(stock_sector_rows)}条, {elapsed}s"
return {
"status": "ok",
"message": msg,
"sectors": len(sectors_rows),
"stock_sector_map": len(stock_sector_rows),
"elapsed_sec": elapsed,
}
+203
View File
@@ -0,0 +1,203 @@
"""同步任务:5 分钟 K 线(mairui)。
设计要点:
1) 启动时**一次 SQL** 拿全表 {stock_code: max(bar_time)} 快照
2) 内存里给每只股票算 (start, end, windows_needed)
- DB 没数据 → start = mairui 历史深度起点 (2023-06-14)
- DB 有数据 → start = max(2023-06-14, max(bar_time) - 2 天)
- 兜底:start >= end → 跳过这只
3) 输出"计划"(全量/增量分类、预估请求数、预估耗时)再开始执行
4) 5 worker × 1 RPS/worker = 5 RPS 总(守住 mairui 上限)
5) 窗口大小 1 年(mairui 一次最多 ~11600 条)
akshare 时代写法:start 写死 2020-01-01 + 4 天窗口,137 小时跑完全市场。
本版:增量时通常只补最近 1-2 天,~30 分钟。
"""
from __future__ import annotations
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
from typing import Any, Optional
from app.core.db import ops as db_ops
from app.core.datasource.base import registry as ds_registry
from app.core.datasource.registry import is_source_ready
from app.core.datasource.utils import is_a_share_code, to_code6
from app.core.sync.base import SyncTask
from app.core.sync.registry import mark_sync_blocked
from app.core.utils.logging import get_logger
logger = get_logger("sync.kline_5min")
# mairui 5min K 实测历史深度起点(实测 2023-06-14 才有数据,更早就 0
MAIRUI_5MIN_FLOOR = datetime(2023, 6, 14)
# 单次请求窗口大小:1 年(mairui 单次最多 ~11640 条 5min
WINDOW_DAYS = 365
# 增量时往前多取的天数(防交易日历边界漏当天)
INCREMENT_OVERLAP_DAYS = 2
class SyncKline5Min(SyncTask):
dataset_id = "kline_5min"
def _plan(
self,
stock_codes: list[str],
end: datetime,
snapshots: dict[str, Optional[datetime]],
) -> list[tuple[str, datetime, datetime]]:
"""为每只股票算 (start, end) — 已排除 start >= end 的"无需同步"
Returns: [(code6, start, end), ...]
"""
plans = []
for code6 in stock_codes:
latest = snapshots.get(code6)
if latest is None:
# DB 里没数据 → 全量从 mairui 历史深度起点
start = MAIRUI_5MIN_FLOOR
else:
# DB 有数据 → 增量:从 max(bar_time) - overlap 到 end
start = max(MAIRUI_5MIN_FLOOR, latest - timedelta(days=INCREMENT_OVERLAP_DAYS))
if start < end:
plans.append((code6, start, end))
return plans
def _run(
self,
*,
trigger_source: str = "manual",
codes: list[str] | None = None,
max_workers: int = 5,
**kwargs,
) -> dict[str, Any]:
primary = ds_registry.get("datasource_mairui")
if primary is None:
return {"status": "error", "message": "5min 数据源 mairui 未注册"}
ok, reason = is_source_ready(primary.key)
if not ok:
mark_sync_blocked(self.dataset_id, message=f"{primary.key} 未就绪: {reason}")
return {"status": "blocked", "message": f"{primary.key} 未就绪: {reason}"}
if codes:
stock_codes = [to_code6(c) for c in codes]
else:
stock_codes = [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:
stock_codes = stock_codes[: int(limit_raw)]
if not stock_codes:
return {"status": "error", "message": "无股票代码"}
# ── 1) 一次 SQL 拿全表快照 ──
logger.info(f"[5min] 读取 DB 快照 ({len(stock_codes)} 只待查)…")
snapshots = db_ops.get_kline_5min_snapshots()
logger.info(f"[5min] DB 里已有 {len(snapshots)} 只的 kline_5min 数据")
# ── 2) 内存算计划 ──
end = datetime.now()
plans = self._plan(stock_codes, end, snapshots)
if not plans:
msg = f"5min K线 — 全部 {len(stock_codes)} 只都已最新(无需同步)"
logger.info(msg)
return {"status": "ok", "message": msg, "ok": 0, "fail": 0, "rows": 0, "elapsed_sec": 0.0}
# 分类统计
full_sync = [p for p in plans if snapshots.get(p[0]) is None]
incr_sync = [p for p in plans if snapshots.get(p[0]) is not None]
total_windows = sum(
max(1, (p[2] - p[1]).days // WINDOW_DAYS + 1)
for p in plans
)
# mairui 5 RPS 上限(5 worker × 1 RPS
est_sec = total_windows / 5.0
logger.info(
f"[5min] 计划: 总 {len(plans)} 只 (全量 {len(full_sync)} / 增量 {len(incr_sync)})"
f"总请求 {total_windows},按 5 RPS 预估 {est_sec:.0f}s ({est_sec/60:.1f}min)"
)
self._progress(
message=f"计划: {len(plans)} 只 (全量 {len(full_sync)}/增量 {len(incr_sync)}) "
f"{total_windows} 个请求, 预估 {est_sec/60:.1f}min",
current=0, total=len(plans), current_step="planning",
)
# ── 3) 执行 ──
t0 = time.time()
ok_cnt = fail_cnt = 0
rows_total = 0
with ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {pool.submit(self._sync_one, primary, p[0], p[1], p[2]): p[0] for p in plans}
for i, future in enumerate(as_completed(futures), 1):
c6 = futures[future]
try:
res = future.result()
if res["status"] == "ok":
ok_cnt += 1
rows_total += res["rows"]
else:
fail_cnt += 1
if res.get("error"):
logger.warning(f"[5min {c6}] {res['error']}")
except Exception as e:
fail_cnt += 1
logger.warning(f"[5min {c6}] {e}")
if i % 20 == 0 or i == len(plans):
self._progress(
message=f"5min 进度 {i}/{len(plans)} OK:{ok_cnt} FAIL:{fail_cnt}",
current=i, total=len(plans), current_step=c6,
)
elapsed = round(time.time() - t0, 1)
msg = (
f"5min K线 {ok_cnt}{fail_cnt}败 共{rows_total}行, "
f"全量{len(full_sync)}/增量{len(incr_sync)}, {elapsed}s"
)
return {
"status": "ok" if fail_cnt == 0 else "warning",
"message": msg,
"ok": ok_cnt, "fail": fail_cnt, "rows": rows_total,
"full_sync": len(full_sync), "incr_sync": len(incr_sync),
"total_requests": total_windows, "elapsed_sec": elapsed,
}
def _sync_one(self, primary, code6: str, start: datetime, end: datetime) -> dict:
all_rows: list[dict] = []
try:
cur = start
while cur < end:
w_end = min(cur + timedelta(days=WINDOW_DAYS), end)
df = primary.fetch_kline_5min(
code6,
cur.strftime("%Y-%m-%d"),
w_end.strftime("%Y-%m-%d"),
)
if df is not None and not df.empty:
for _, r in df.iterrows():
bar_time = r["bar_time"]
all_rows.append({
"stock_code": code6,
"bar_time": bar_time.strftime("%Y-%m-%d %H:%M:%S")
if hasattr(bar_time, "strftime") else str(bar_time),
"open": float(r.get("open") or 0),
"high": float(r.get("high") or 0),
"low": float(r.get("low") or 0),
"close": float(r.get("close") or 0),
"volume": float(r.get("volume") or 0),
"amount": float(r.get("amount") or 0),
"turnover_rate": float(r.get("turnover_rate") or 0),
})
cur = w_end + timedelta(days=1)
except Exception as e:
return {"status": "fail", "error": str(e)}
if not all_rows:
return {"status": "fail", "error": "no data"}
try:
CHUNK = 5000
for i in range(0, len(all_rows), CHUNK):
db_ops.upsert_kline_5min(all_rows[i: i + CHUNK])
except Exception as e:
return {"status": "fail", "error": f"db write: {e}"}
return {"status": "ok", "rows": len(all_rows)}
+251
View File
@@ -0,0 +1,251 @@
"""同步任务:全市场日 K 线。
数据流:
1. 选主源(按 priority 选第一个 is_available 的源)
2. 增量判断:读 kline_stock.MAX(trade_date) 决定每只股票 start
3. **Producer-Consumer 模式**
- N 个 fetcher 线程并发拉 K 线,把 (code, rows) 塞到 Queue
- 1 个 writer 线程攒满 BATCH_SIZE 就 executemany + commit 一次
4. 更新 stocks.kline_synced_at
设计要点:
- 避免每只股票 commit5000 只 = 5000 次 commit 太慢)
- 减少并发数(避免新浪限流 / MySQL 连接数爆)
- fetcher 间用 Queue 解耦,写库串行避免锁竞争
"""
from __future__ import annotations
import os
import queue
import threading
import time
from datetime import datetime, timedelta
from typing import Any
import pandas as pd
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
from app.core.sync.base import SyncTask, _effective_sync_end
from app.core.utils.logging import get_logger
logger = get_logger("sync.kline_daily")
# 主源优先级:mairui(5 RPS,免费 + 稳定 + 单接口含5min)→ 雪球 → Baostock → 新浪
# 经验:sina 在单日窗口 (start=end=某天) 会返回空,雪球更可靠
PRIMARY_PRIORITY = ["datasource_xueqiu", "datasource_mairui", "datasource_xinlang"]
DEFAULT_START = "2018-01-01"
# 写库批量:攒够 BATCH_SIZE 行就 executemany 一次 + commit
BATCH_SIZE = 2000
# fetcher 并发:实测单线程最稳(5000 只 × 0.2s = 17 分钟,足够)
# 雪球 token 限速 10 RPS,单线程 0.1s/只 已经打满。多 worker 会触发风控 hang
DEFAULT_FETCH_WORKERS = 1
class SyncKlineDaily(SyncTask):
dataset_id = "kline_daily"
def _run(
self,
*,
trigger_source: str = "manual",
start: str | None = None,
end: str | None = None,
codes: list[str] | None = None,
incremental: bool = True,
max_workers: int = DEFAULT_FETCH_WORKERS,
**kwargs,
) -> dict[str, Any]:
# 1. 选主源
primary = self._pick_primary()
if primary is None:
return {"status": "error", "message": "所有 K 线数据源均不可用"}
self._progress(
message=f"使用 {primary.name} 同步日K线 (fetcher={max_workers}, batch={BATCH_SIZE})...",
current_step=primary.key,
)
# 2. 拉股票列表
if codes:
stock_codes = [to_code6(c) for c in codes]
else:
stock_codes = [
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:
stock_codes = stock_codes[: int(limit_raw)]
if not stock_codes:
return {"status": "error", "message": "无股票代码(stocks 表为空?)"}
# 3. 增量判断
_end = end or _effective_sync_end()
end_date_obj = datetime.strptime(_end, "%Y-%m-%d").date()
jobs: list[tuple[str, str]] = []
if incremental and not start:
for c6 in stock_codes:
last = db_ops.get_stock_kline_max_date(c6)
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((c6, fetch_start))
else:
fetch_start = start or DEFAULT_START
jobs = [(c, fetch_start) for c in stock_codes]
total = len(jobs)
skipped = len(stock_codes) - total
self._progress(
message=f"开始同步 {total} 只股票 (跳过 {skipped} 只已最新)",
current=0, total=total,
)
# 4. 并发 fetch + 攒 batch 写库(线程安全)
# mairui 等外部 API 无并发问题;DB 写串行加锁
from concurrent.futures import ThreadPoolExecutor, as_completed
t0 = time.time()
batch_lock = threading.Lock()
batch_rows: list[dict] = []
pending_codes: dict[str, str] = {}
ok_lock = threading.Lock()
ok_cnt = [0]
fail_cnt = [0]
rows_cnt = [0]
def _flush():
with batch_lock:
if not batch_rows:
return
rows_to_write = list(batch_rows)
codes_to_update = dict(pending_codes)
batch_rows.clear()
pending_codes.clear()
try:
db_ops.upsert_kline_stock(rows_to_write)
except Exception as e:
logger.error(f"batch upsert 失败 ({len(rows_to_write)} 行): {e}")
return
for c6, latest in codes_to_update.items():
try:
db_ops.update_stock_kline_synced_at(c6, latest)
except Exception as e:
logger.warning(f"update synced_at 失败 {c6}: {e}")
def _fetch_one(code6: str, fetch_start: str) -> tuple[str, list[dict] | None, str | None]:
try:
df = primary.fetch_kline_daily(code6, fetch_start, _end)
except Exception as e:
return (code6, None, str(e)[:120])
if df is None or df.empty:
return (code6, None, "no data")
rows = []
for _, r in df.iterrows():
td = r["trade_date"]
rows.append({
"stock_code": code6,
"trade_date": td.strftime("%Y-%m-%d") if hasattr(td, "strftime") else str(td)[:10],
"open": float(r["open"]),
"high": float(r["high"]),
"low": float(r["low"]),
"close": float(r["close"]),
"volume": float(r["volume"]),
})
latest = df["trade_date"].max()
latest = latest.strftime("%Y-%m-%d") if hasattr(latest, "strftime") else str(latest)[:10]
return (code6, rows, latest)
total = len(jobs)
with ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {pool.submit(_fetch_one, c6, fs): c6 for c6, fs in jobs}
done_cnt = 0
for future in as_completed(futures):
done_cnt += 1
code6, rows, latest = future.result()
if rows is None:
with ok_lock:
fail_cnt[0] += 1
logger.warning(f"[kline {code6}] {latest}")
continue
with batch_lock:
batch_rows.extend(rows)
pending_codes[code6] = latest
cur_size = len(batch_rows)
with ok_lock:
ok_cnt[0] += 1
rows_cnt[0] += len(rows)
if cur_size >= BATCH_SIZE:
_flush()
if done_cnt % 50 == 0 or done_cnt == total:
elapsed = time.time() - t0
rate = done_cnt / elapsed if elapsed > 0 else 0
self._progress(
message=f"进度 {done_cnt}/{total} OK:{ok_cnt[0]} FAIL:{fail_cnt[0]} {rate:.1f}只/秒",
current=done_cnt, total=total, current_step=code6,
)
# 收尾 flush
_flush()
elapsed = round(time.time() - t0, 1)
msg = f"日K线 {ok_cnt[0]}{fail_cnt[0]}{skipped}跳 共{rows_cnt[0]}行, {elapsed}s"
return {
"status": "ok" if fail_cnt[0] == 0 else "warning",
"message": msg,
"ok": ok_cnt[0],
"fail": fail_cnt[0],
"skip": skipped,
"rows": rows_cnt[0],
"elapsed_sec": elapsed,
"primary_source": primary.key,
}
def _pick_primary(self):
from app.core.datasource.registry import is_source_ready, run_health_check, get_health_status
if not get_health_status():
run_health_check()
for key in PRIMARY_PRIORITY:
ok, _ = is_source_ready(key)
if ok:
return ds_registry.get(key)
return None
def _sync_one(self, primary, code6: str, start: str, end: str) -> dict:
"""保留这个方法以兼容外部调用(已不用,但单只测试可能用到)"""
try:
df = primary.fetch_kline_daily(code6, start, end)
except Exception as e:
return {"status": "fail", "error": str(e)}
if df is None or df.empty:
return {"status": "fail", "error": "no data"}
rows = []
for _, r in df.iterrows():
td = r["trade_date"]
rows.append({
"stock_code": code6,
"trade_date": td.strftime("%Y-%m-%d") if hasattr(td, "strftime") else str(td)[:10],
"open": float(r["open"]),
"high": float(r["high"]),
"low": float(r["low"]),
"close": float(r["close"]),
"volume": float(r["volume"]),
})
try:
db_ops.upsert_kline_stock(rows)
except Exception as e:
return {"status": "fail", "error": f"db write: {e}"}
if rows:
latest = max(r["trade_date"] for r in rows)
try:
db_ops.update_stock_kline_synced_at(code6, latest)
except Exception:
pass
return {"status": "ok", "rows": len(rows)}
+109
View File
@@ -0,0 +1,109 @@
"""同步任务:六大指数日 K 线。
数据流:新浪指数日K接口 → 写 kline_index + indices 表。"""
from __future__ import annotations
from datetime import datetime
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.registry import is_source_ready
from app.core.sync.base import SyncTask, _is_market_closed
from app.core.sync.registry import mark_sync_blocked
from app.core.utils.logging import get_logger
logger = get_logger("sync.kline_index")
MAJOR_INDICES = {
"000001": {"name": "上证指数", "market": "CN", "category": "broad_market"},
"399001": {"name": "深证成指", "market": "CN", "category": "broad_market"},
"399006": {"name": "创业板指", "market": "CN", "category": "broad_market"},
"000300": {"name": "沪深300", "market": "CN", "category": "broad_market"},
"000905": {"name": "中证500", "market": "CN", "category": "broad_market"},
"000852": {"name": "中证1000", "market": "CN", "category": "broad_market"},
}
class SyncKlineIndex(SyncTask):
dataset_id = "kline_index"
def _run(self, *, trigger_source: str = "manual", **kwargs) -> dict[str, Any]:
# 优先 mairui(指数无单日空窗问题),sina 作为 fallback
primary = ds_registry.get("datasource_mairui")
use_mairui = primary is not None and is_source_ready(primary.key)[0]
if not use_mairui:
primary = ds_registry.get("datasource_xinlang")
if primary is None:
return {"status": "error", "message": "指数数据源均不可用(mairui + 新浪)"}
ok, reason = is_source_ready(primary.key)
if not ok:
mark_sync_blocked(self.dataset_id, message=f"{primary.key} 未就绪: {reason}")
return {"status": "blocked", "message": f"{primary.key} 未就绪: {reason}"}
self._progress(message=f"开始同步 {len(MAJOR_INDICES)} 个指数...")
total = len(MAJOR_INDICES)
ok_cnt = fail_cnt = 0
results = {}
for i, (code, info) in enumerate(MAJOR_INDICES.items(), 1):
try:
# mairui 需带交易所后缀(如 000300.SH),sina 用纯6位
if use_mairui:
# 000xxx 上证 → .SH399xxx 深证 → .SZ
if code.startswith("399"):
suffix = ".SZ"
else:
suffix = ".SH"
fetch_code = f"{code}{suffix}"
else:
fetch_code = code
df = primary.fetch_index_daily(fetch_code, start="1990-01-01", end="2099-12-31")
if df is None or df.empty:
raise RuntimeError("返回空数据")
# 盘中未收盘则去掉当天
if not _is_market_closed():
today = datetime.now().strftime("%Y-%m-%d")
df = df[df["trade_date"].dt.strftime("%Y-%m-%d") < today]
rows = [
{
"index_code": code,
"trade_date": r["trade_date"].strftime("%Y-%m-%d"),
"open": float(r["open"]),
"high": float(r["high"]),
"low": float(r["low"]),
"close": float(r["close"]),
"volume": float(r["volume"]),
}
for _, r in df.iterrows()
]
db_ops.upsert_kline_index(rows)
db_ops.upsert_index(
index_code=code, index_name=info["name"],
market=info["market"], category=info["category"],
source="mairui_index_daily" if use_mairui else "sina_index_daily", enabled=True,
)
ok_cnt += 1
results[code] = {
"rows": len(rows),
"date_min": rows[0]["trade_date"] if rows else "",
"date_max": rows[-1]["trade_date"] if rows else "",
}
except Exception as e:
fail_cnt += 1
results[code] = {"error": str(e)}
logger.warning(f"[index {code}] 失败: {e}")
self._progress(
message=f"指数 {i}/{total} 完成 ({ok_cnt}{fail_cnt}败)",
current=i, total=total, current_step=code,
)
msg = f"六大指数 {ok_cnt}{fail_cnt}"
return {
"status": "ok" if fail_cnt == 0 else "warning",
"message": msg,
"ok": ok_cnt, "fail": fail_cnt,
"details": results,
}
+113
View File
@@ -0,0 +1,113 @@
"""同步任务:市场情绪(market_regime_daily,衍生源)。
基于本地 kline_stock 数据聚合:advancers / decliners / turnover / advance_ratio。"""
from __future__ import annotations
from datetime import datetime
from typing import Any
import numpy as np
import pandas as pd
from app.core.db import ops as db_ops
from app.core.sync.base import SyncTask
from app.core.utils.logging import get_logger
logger = get_logger("sync.market_regime")
DEFAULT_START = "2018-01-01"
EXTREME_PANIC_ADV = 0.2
EXTREME_PANIC_TURN = 1.5
def _is_extreme_panic(row: pd.Series) -> int:
ar = pd.to_numeric(row.get("advance_ratio"), errors="coerce")
tr = pd.to_numeric(row.get("turnover_ratio_5d"), errors="coerce")
if pd.isna(ar) or pd.isna(tr):
return 0
return int(ar < EXTREME_PANIC_ADV and tr > EXTREME_PANIC_TURN)
class SyncMarketRegime(SyncTask):
dataset_id = "market_regime"
def _run(self, *, trigger_source: str = "manual", start: str = DEFAULT_START, **kwargs) -> dict[str, Any]:
end = datetime.now().strftime("%Y-%m-%d")
self._progress(message=f"构建 market_regime {start} ~ {end}...")
# 1. 取所有非退市股票代码
codes = list(db_ops.iter_stock_codes(active_only=True))
if not codes:
return {"status": "error", "message": "stocks 表为空"}
# 2. 拉所有 K 线(按股票)- 这里用 SQL GROUP BY 一次性算 daily turnover
from app.core.db.connection import get_mysql
sql = """
SELECT
stock_code,
trade_date,
`close`,
volume
FROM kline_stock
WHERE trade_date BETWEEN %s AND %s
ORDER BY stock_code, trade_date
"""
with get_mysql().cursor() as cur:
cur.execute(sql, (start, end))
rows = cur.fetchall()
if not rows:
return {"status": "warning", "message": f"kline_stock 在 {start} ~ {end} 区间无数据"}
df = pd.DataFrame(rows)
df["trade_date"] = pd.to_datetime(df["trade_date"], errors="coerce")
df["close"] = pd.to_numeric(df["close"], errors="coerce")
df["volume"] = pd.to_numeric(df["volume"], errors="coerce").fillna(0.0)
df = df.dropna(subset=["trade_date", "close"]).sort_values(["stock_code", "trade_date"])
df["prev_close"] = df.groupby("stock_code")["close"].shift(1)
df = df.dropna(subset=["prev_close"])
df["advancers"] = (df["close"] > df["prev_close"]).astype(int)
df["decliners"] = (df["close"] < df["prev_close"]).astype(int)
df["turnover"] = df["close"] * df["volume"]
daily = df.groupby("trade_date", as_index=False)[["advancers", "decliners", "turnover"]].sum()
total = daily["advancers"] + daily["decliners"]
daily["advance_ratio"] = np.where(total > 0, daily["advancers"] / total, np.nan)
daily["turnover_avg_5d"] = daily["turnover"].rolling(5, min_periods=5).mean()
daily["turnover_ratio_5d"] = np.where(
daily["turnover_avg_5d"] > 0,
daily["turnover"] / daily["turnover_avg_5d"],
np.nan,
)
daily["source"] = "local_kline_proxy"
daily["is_extreme_panic"] = daily.apply(_is_extreme_panic, axis=1)
daily["trade_date"] = daily["trade_date"].dt.strftime("%Y-%m-%d")
rows_out = [
{
"trade_date": r["trade_date"],
"advancers": float(r["advancers"]),
"decliners": float(r["decliners"]),
"advance_ratio": float(r["advance_ratio"]) if pd.notna(r["advance_ratio"]) else 0,
"turnover": float(r["turnover"]),
"turnover_avg_5d": float(r["turnover_avg_5d"]) if pd.notna(r["turnover_avg_5d"]) else 0,
"turnover_ratio_5d": float(r["turnover_ratio_5d"]) if pd.notna(r["turnover_ratio_5d"]) else 0,
"source": r["source"],
"is_extreme_panic": int(r["is_extreme_panic"]),
}
for _, r in daily.iterrows()
]
try:
db_ops.upsert_market_regime_rows(rows_out)
except Exception as e:
return {"status": "error", "message": f"写库失败: {e}"}
msg = f"market_regime {len(rows_out)} 天 ({rows_out[0]['trade_date']} ~ {rows_out[-1]['trade_date']})"
return {
"status": "ok",
"message": msg,
"rows": len(rows_out),
"date_min": rows_out[0]["trade_date"],
"date_max": rows_out[-1]["trade_date"],
}
+117
View File
@@ -0,0 +1,117 @@
"""同步任务:个股资金流(mairui)。
数据源:mairui `hsstock/history/transaction/{code}.{ex}/{licence}`
口径:主力 / 大 / 中 / 小单 净额(与 akshare stock_individual_fund_flow 一致)
akshare 已移除(项目级决策:永远不用),mairui 替代为唯一源。
"""
from __future__ import annotations
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
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.registry import is_source_ready
from app.core.datasource.utils import is_a_share_code, to_code6
from app.core.sync.base import SyncTask
from app.core.sync.registry import mark_sync_blocked
from app.core.utils.logging import get_logger
logger = get_logger("sync.moneyflow")
class SyncMoneyflow(SyncTask):
dataset_id = "moneyflow"
def _run(
self,
*,
trigger_source: str = "manual",
codes: list[str] | None = None,
max_workers: int = 5,
**kwargs,
) -> dict[str, Any]:
mr = ds_registry.get("datasource_mairui")
if mr is None:
return {"status": "error", "message": "mairui 数据源未注册"}
ok, reason = is_source_ready(mr.key)
if not ok:
mark_sync_blocked(self.dataset_id, message=f"mairui 未就绪: {reason}")
return {"status": "blocked", "message": f"mairui 未就绪: {reason}"}
if codes:
stock_codes = [to_code6(c) for c in codes]
else:
stock_codes = [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:
stock_codes = stock_codes[: int(limit_raw)]
if not stock_codes:
return {"status": "error", "message": "无股票代码"}
total = len(stock_codes)
ok_cnt = fail_cnt = 0
rows_total = 0
t0 = time.time()
with ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {pool.submit(self._sync_one, mr, c6): c6 for c6 in stock_codes}
for i, future in enumerate(as_completed(futures), 1):
c6 = futures[future]
try:
res = future.result()
if res["status"] == "ok":
ok_cnt += 1
rows_total += res["rows"]
else:
fail_cnt += 1
if res.get("error"):
logger.warning(f"[moneyflow {c6}] {res['error']}")
except Exception as e:
fail_cnt += 1
logger.warning(f"[moneyflow {c6}] {e}")
if i % 20 == 0 or i == total:
self._progress(
message=f"moneyflow 进度 {i}/{total} OK:{ok_cnt} FAIL:{fail_cnt}",
current=i, total=total, current_step=c6,
)
elapsed = round(time.time() - t0, 1)
msg = f"资金流 {ok_cnt}{fail_cnt}败 共{rows_total}行, {elapsed}s"
return {
"status": "ok" if fail_cnt == 0 else "warning",
"message": msg,
"ok": ok_cnt, "fail": fail_cnt,
"rows": rows_total, "elapsed_sec": elapsed,
}
def _sync_one(self, mr, code6: str) -> dict:
# 取最近 30 天(mairui 的 transaction 接口历史深度有限,30 天足够覆盖日常)
from datetime import datetime, timedelta
end = datetime.now()
start = end - timedelta(days=30)
try:
df = mr.fetch_moneyflow(
code6,
start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"),
)
if df is None or df.empty:
return {"status": "fail", "error": "no data"}
rows = [
{
"stock_code": code6,
"trade_date": str(r["trade_date"]),
"main_net_inflow": float(r.get("main_net_inflow") or 0),
"large_net_inflow": float(r.get("large_net_inflow") or 0),
"medium_net_inflow": float(r.get("medium_net_inflow") or 0),
"small_net_inflow": float(r.get("small_net_inflow") or 0),
}
for _, r in df.iterrows()
]
db_ops.upsert_moneyflow(rows)
return {"status": "ok", "rows": len(rows)}
except Exception as e:
return {"status": "fail", "error": str(e)}
+184
View File
@@ -0,0 +1,184 @@
"""同步任务:行业聚合特征(衍生计算)。
输入:kline_stock(日 K 线)+ industry(股票→行业映射)
输出:
- sector_indices :行业日线(trade_date × sector_name × close × sector_amplitude
- sector_features_daily :行业特征(+ sector_ret + ema10/20/200 + score
算法(参考 dashboard/api/services/sync/sector.py 重写,持久层改为 MySQL):
1) 每只股票日涨跌幅 pct_chg = (close/prev_close - 1) * 100
2) 按行业聚合 sector_ret = 该行业所有股票当日 pct_chg 的均值
3) 行业指数 close = 100 × ∏(1 + sector_ret/100) (基点 100,复合收益)
4) 行业振幅 sector_amplitude = 该行业所有股票当日 (high-low)/close 的均值
5) EMA10/20/200 = close 的指数移动平均(adjust=False
6) score: 0/1/2 — close>ema200 + 1ema10>ema20 + 1
无外部 API 调用,纯本地计算;增量逻辑以"全量重算"实现(数据量小 ~75K 行)。
"""
from __future__ import annotations
import time
from datetime import datetime, timedelta
from typing import Any
import pandas as pd
from sqlalchemy import create_engine
from app.core.config import settings
from app.core.db import ops as db_ops
from app.core.db.connection import get_mysql
from app.core.sync.base import SyncTask
from app.core.utils.logging import get_logger
logger = get_logger("sync.sector_features")
# 起算点:DB 里 kline_stock 最早一天 往前 5 年,或固定一个默认起点
# (不用写死 2021-04-24,让数据自己决定;下面自动算)
DEFAULT_START_YEARS_BACK = 5
# 输出基点(行业指数 close 从多少开始)
INDEX_BASE = 100.0
class SyncSectorFeatures(SyncTask):
dataset_id = "sector_features"
def _run(
self,
*,
trigger_source: str = "manual",
codes: list[str] | None = None,
max_workers: int = 1, # 本任务纯本地计算,单线程足够
**kwargs,
) -> dict[str, Any]:
t0 = time.time()
logger.info("[sector] 启动行业聚合特征计算")
# ── 1) 拉 kline_stock(日线)只取需要的列 ──
# 用 SQLAlchemy enginepymysql 裸连接 + pd.read_sql 偶发只读 1 行的 bug
engine = create_engine(settings.mysql_url())
df_kline = pd.read_sql(
"SELECT stock_code, trade_date, open, high, low, `close`, volume "
"FROM kline_stock ORDER BY stock_code, trade_date",
engine,
)
if df_kline.empty:
return {"status": "error", "message": "kline_stock 为空"}
df_kline["stock_code"] = df_kline["stock_code"].astype(str).str.zfill(6)
df_kline["trade_date"] = pd.to_datetime(df_kline["trade_date"], errors="coerce")
logger.info(f"[sector] kline_stock: {len(df_kline):,} 行, {df_kline['stock_code'].nunique()} 只, "
f"{df_kline['trade_date'].min().date()} ~ {df_kline['trade_date'].max().date()}")
# ── 2) 拉 industry(股票→行业映射)──
df_ind = pd.read_sql(
"SELECT code, industry_name FROM industry WHERE industry_name IS NOT NULL",
engine,
)
if df_ind.empty:
return {"status": "error", "message": "industry 表为空"}
df_ind["code"] = df_ind["code"].astype(str).str.zfill(6)
df_ind = df_ind.drop_duplicates(subset=["code"], keep="first")
logger.info(f"[sector] industry 映射: {len(df_ind):,} 行, {df_ind['industry_name'].nunique()} 个行业")
# ── 3) 算每只股票日涨跌幅 + 振幅 ──
df = df_kline.merge(df_ind, left_on="stock_code", right_on="code", how="inner")
df = df.sort_values(["stock_code", "trade_date"])
df["prev_close"] = df.groupby("stock_code")["close"].shift(1)
df["stock_amplitude"] = (df["high"] - df["low"]) / df["close"]
df = df.dropna(subset=["prev_close", "industry_name"])
df["pct_chg"] = (df["close"] / df["prev_close"] - 1.0) * 100.0
logger.info(f"[sector] 合并后: {len(df):,} 行, 行业 {df['industry_name'].nunique()}")
# ── 4) 按行业 + 日期聚合 ──
sector_daily = (
df.groupby(["industry_name", "trade_date"], as_index=False).agg(
sector_ret=("pct_chg", "mean"),
sector_amplitude=("stock_amplitude", "mean"),
)
.rename(columns={"industry_name": "sector_name"})
.sort_values(["sector_name", "trade_date"])
.reset_index(drop=True)
)
logger.info(f"[sector] 行业日聚合: {len(sector_daily):,}")
# ── 5) 算行业指数 close(基点 100,复合)──
sector_index = self._build_index(sector_daily)
# ── 6) EMA + score ──
sector_index = self._calc_ema(sector_index)
sector_index["score"] = sector_index.apply(
lambda r: self._calc_score(r["close"], r["ema10"], r["ema20"], r["ema200"]),
axis=1,
)
# 整理列名
out_cols = ["trade_date", "sector_name", "sector_ret", "sector_amplitude",
"close", "ema10", "ema20", "ema200", "score"]
sector_index = sector_index[out_cols]
sector_index["trade_date"] = sector_index["trade_date"].dt.strftime("%Y-%m-%d")
# NaN → None(让 MySQL 接受 NULL
sector_index = sector_index.where(pd.notnull(sector_index), None)
# ── 7) 写入两张表 ──
# 7a. sector_indices (4 列)
si_rows = sector_index[["trade_date", "sector_name", "close", "sector_amplitude"]].to_dict("records")
db_ops.replace_all_sector_indices(si_rows)
# 7b. sector_features_daily (9 列)
sf_rows = sector_index.to_dict("records")
db_ops.replace_all_sector_features(sf_rows)
elapsed = round(time.time() - t0, 1)
msg = (
f"行业聚合 {sector_index['sector_name'].nunique()} 个行业 "
f"× {sector_index['trade_date'].nunique()} 天, "
f"{len(sector_index):,} 行, {elapsed}s"
)
logger.info(f"[sector] {msg}")
return {
"status": "ok",
"message": msg,
"sectors": int(sector_index["sector_name"].nunique()),
"days": int(sector_index["trade_date"].nunique()),
"rows": int(len(sector_index)),
"elapsed_sec": elapsed,
}
@staticmethod
def _build_index(sector_daily: pd.DataFrame) -> pd.DataFrame:
"""行业指数 close = INDEX_BASE × ∏(1 + sector_ret/100)"""
out = []
for sector_name, group in sector_daily.groupby("sector_name", sort=False):
g = group.sort_values("trade_date").copy()
close_vals = []
cur_base = INDEX_BASE
for ret in g["sector_ret"].to_numpy(dtype=float):
if pd.isna(ret):
close_vals.append(cur_base)
else:
cur_base = cur_base * (1 + float(ret) / 100.0)
close_vals.append(cur_base)
g["close"] = close_vals
out.append(g)
return pd.concat(out, ignore_index=True)
@staticmethod
def _calc_ema(sector_index: pd.DataFrame) -> pd.DataFrame:
"""每个行业分别算 EMA10/20/200"""
out = []
for sector_name, group in sector_index.groupby("sector_name", sort=False):
g = group.sort_values("trade_date").copy()
g["ema10"] = g["close"].ewm(span=10, adjust=False).mean()
g["ema20"] = g["close"].ewm(span=20, adjust=False).mean()
g["ema200"] = g["close"].ewm(span=200, adjust=False).mean()
out.append(g)
return pd.concat(out, ignore_index=True)
@staticmethod
def _calc_score(close, ema10, ema20, ema200) -> int:
score = 0
if pd.notna(close) and pd.notna(ema200) and close > ema200:
score += 1
if pd.notna(ema10) and pd.notna(ema20) and ema10 > ema20:
score += 1
return int(score)
+133
View File
@@ -0,0 +1,133 @@
"""同步任务:股本快照(雪球 quote_detail)。
写入两张表:
- stocks.total_share / float_share / share_updated_at(基础信息表)
- share(独立股本时序表)
"""
from __future__ import annotations
import os
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any
from app.core.config import settings
from app.core.db import ops as db_ops
from app.core.datasource.base import registry as ds_registry
from app.core.datasource.registry import is_source_ready
from app.core.datasource.utils import is_a_share_code, to_code6
from app.core.sync.base import SyncTask
from app.core.sync.registry import mark_sync_blocked
from app.core.utils.logging import get_logger
logger = get_logger("sync.share")
class SyncShareSnapshot(SyncTask):
dataset_id = "share_snapshot"
def _run(
self,
*,
trigger_source: str = "manual",
codes: list[str] | None = None,
max_workers: int = 10,
**kwargs,
) -> dict[str, Any]:
if not settings.xueqiu_token:
mark_sync_blocked(self.dataset_id, message="未配置 XUEQIU_TOKEN")
return {"status": "blocked", "message": "未配置 XUEQIU_TOKEN"}
xq = ds_registry.get("datasource_xueqiu")
if xq is None:
return {"status": "error", "message": "雪球数据源未注册"}
ok, reason = is_source_ready(xq.key)
if not ok:
mark_sync_blocked(self.dataset_id, message=f"雪球未就绪: {reason}")
return {"status": "blocked", "message": f"雪球未就绪: {reason}"}
if codes:
stock_codes = [to_code6(c) for c in codes]
else:
stock_codes = [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:
stock_codes = stock_codes[: int(limit_raw)]
if not stock_codes:
return {"status": "error", "message": "无股票代码"}
# 跳过今日已刷过的
today = time.strftime("%Y-%m-%d")
existing = {s["code"]: s for s in db_ops.fetch_all_stocks()}
todo = []
for c6 in stock_codes:
old = existing.get(c6, {})
if str(old.get("share_updated_at", "") or "") == today and old.get("total_share", 0) > 0:
continue
todo.append(c6)
skip = len(stock_codes) - len(todo)
if not todo:
return {"status": "ok", "message": f"全部 {skip} 只已最新,跳过"}
total = len(todo)
ok_cnt = fail_cnt = 0
rows_to_share_table: list[dict] = []
t0 = time.time()
with ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {pool.submit(xq.fetch_share_snapshot, c6): c6 for c6 in todo}
for i, future in enumerate(as_completed(futures), 1):
c6 = futures[future]
try:
r = future.result()
except Exception as e:
fail_cnt += 1
logger.warning(f"[share {c6}] 异常: {e}")
continue
if r is None:
fail_cnt += 1
continue
# 写 stocks
hermes = self._to_hermes(c6)
db_ops.update_stock_share_snapshot(
code=hermes,
total_share=r["total_share"],
float_share=r["float_share"],
trade_date=r.get("trade_date", today),
)
# 也写 share 表
rows_to_share_table.append({
"stock_code": c6,
"trade_date": r.get("trade_date", today),
"total_share": r["total_share"],
"float_share": r["float_share"],
})
ok_cnt += 1
if i % 50 == 0 or i == total:
self._progress(
message=f"股本 {i}/{total} OK:{ok_cnt} FAIL:{fail_cnt}",
current=i, total=total, current_step=c6,
)
# 批量写 share 表
if rows_to_share_table:
try:
db_ops.upsert_share(rows_to_share_table)
except Exception as e:
logger.warning(f"写 share 表失败: {e}")
elapsed = round(time.time() - t0, 1)
msg = f"股本 {ok_cnt}{fail_cnt}{skip}跳, {elapsed}s"
return {
"status": "ok" if fail_cnt == 0 else "warning",
"message": msg,
"ok": ok_cnt, "fail": fail_cnt, "skip": skip,
"elapsed_sec": elapsed,
}
@staticmethod
def _to_hermes(code6: str) -> str:
if code6.startswith(("5", "6", "9")):
return f"SH{code6}"
if code6.startswith(("4", "8")):
return f"BJ{code6}"
return f"SZ{code6}"
+221
View File
@@ -0,0 +1,221 @@
"""同步任务:全市场股票基础信息 + 股本快照。
数据流:
麦蕊智数 hslt/list(主)→ Baostock.query_stock_basic()(降级)→ 解析 → upsert stocks
可选:雪球 quote_detail 刷新股本(需要 XUEQIU_TOKEN
"""
from __future__ import annotations
import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any
from app.core.config import settings
from app.core.db import ops as db_ops
from app.core.datasource.base import registry as ds_registry
from app.core.sync.base import SyncTask
from app.core.sync.registry import mark_sync_blocked
from app.core.utils.logging import get_logger
logger = get_logger("sync.stocks_basic")
def _derive_listing_status(name: str) -> str:
n = name.strip()
if n.startswith("*ST") or n.startswith("ST"):
return "st"
return "normal"
class SyncStocksBasic(SyncTask):
dataset_id = "stock_basic"
def _run(self, *, trigger_source: str = "manual", with_share: bool = True, **kwargs) -> dict[str, Any]:
self._progress(message="选择股票基础信息数据源...")
rows: list[dict] = []
source_name = ""
# 1. 优先使用麦蕊智数
mairui = ds_registry.get("datasource_mairui")
if mairui is not None:
ok, msg = mairui.is_available()
if ok:
self._progress(message="使用麦蕊智数获取股票列表...")
rows = mairui.fetch_stock_list()
if rows:
source_name = "麦蕊智数"
self._progress(message=f"麦蕊智数返回 {len(rows)} 只股票")
# 2. 无数据则报错
if not rows:
return {"status": "error", "message": "麦蕊智数未返回股票数据"}
self._progress(
message=f"获取到 {len(rows)} 只 A 股,开始写入...",
current=0, total=len(rows),
)
new_cnt = upd_cnt = unchanged = 0
existing = {s["code"]: s for s in db_ops.fetch_all_stocks()}
# 准备批量写入(性能:executemany 一次写 500 条,比单条快 10-20 倍)
bulk_rows = []
for info in rows:
code = info["code"]
old = existing.get(code)
if old is None:
new_cnt += 1
elif (old.get("name") != info["name"]
or old.get("exchange") != info["exchange"]
or old.get("listing_status", "normal") != info["listing_status"]):
upd_cnt += 1
else:
unchanged += 1
bulk_rows.append({
"code": code,
"name": info["name"],
"exchange": info["exchange"],
"listing_status": info["listing_status"],
})
# 分块 executemany 写入 + 进度更新
CHUNK = 500
for i in range(0, len(bulk_rows), CHUNK):
db_ops.upsert_stocks_bulk(bulk_rows[i: i + CHUNK])
done = min(i + CHUNK, len(bulk_rows))
if done % 500 == 0 or done == len(bulk_rows):
self._progress(
message=f"写入数据库 {done}/{len(bulk_rows)}...",
current=done, total=len(bulk_rows),
current_step=bulk_rows[done - 1]["code"],
)
# 退市检测
delisted = 0
api_codes = {r["code"] for r in rows}
for code, old in existing.items():
if code not in api_codes and old.get("listing_status") != "delisted":
db_ops.upsert_stock(
code=code,
name=old.get("name", ""),
exchange=old.get("exchange", ""),
list_date=old.get("list_date", ""),
listing_status="delisted",
industry=old.get("industry", ""),
)
delisted += 1
msg = f"股票基础信息 {new_cnt}{upd_cnt}{unchanged}无变化 {delisted}退市"
self._progress(message=msg, current=len(rows), total=len(rows))
# 股本快照(可选 + 需要 token)
share_msg = ""
if with_share and settings.xueqiu_token:
self._progress(message="股票基础信息完成,开始刷新股本快照...")
xq = ds_registry.get("datasource_xueqiu")
if xq is not None:
try:
share_ok, share_skip, share_fail = self._refresh_shares(
xq, [r["code"] for r in rows]
)
share_msg = f";股本 {share_ok}{share_skip}{share_fail}"
except Exception as e:
share_msg = f";股本刷新异常: {e}"
else:
if with_share and not settings.xueqiu_token:
share_msg = ";未配置 XUEQIU_TOKEN,跳过股本刷新"
mark_sync_blocked(self.dataset_id, message=msg + share_msg)
return {
"status": "ok" if not share_msg.startswith(";未配置") else "blocked",
"source": source_name,
"message": f"[{source_name}] {msg}{share_msg}",
"total": len(rows),
"new": new_cnt,
"updated": upd_cnt,
"delisted": delisted,
"unchanged": unchanged,
}
def _fetch_with_timeout(self, bs, *, timeout: int) -> list[dict]:
result: list = [None]
exc: list = [None]
def _do():
try:
result[0] = bs.fetch_stock_basic()
except Exception as e:
exc[0] = e
t = threading.Thread(target=_do, daemon=True)
t.start()
t.join(timeout)
if t.is_alive():
logger.warning(f"Baostock 拉取超时({timeout}s")
return []
if exc[0]:
raise exc[0]
return result[0] or []
def _refresh_shares(
self, xq, codes: list[str], max_workers: int = 10,
) -> tuple[int, int, int]:
code6_list = []
for c in codes:
c6 = re.sub(r"^(SH|SZ|BJ)", "", c)
if c6.isdigit() and len(c6) == 6:
code6_list.append(c6)
code6_list = sorted(set(code6_list))
today = time.strftime("%Y-%m-%d")
existing = {s["code"]: s for s in db_ops.fetch_all_stocks()}
todo = []
for c6 in code6_list:
old = existing.get(c6, {})
if str(old.get("share_updated_at", "") or "") == today and old.get("total_share", 0) > 0:
continue
todo.append(c6)
if not todo:
return 0, len(code6_list), 0
ok = skip = fail = 0
with ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {pool.submit(xq.fetch_share_snapshot, c6): c6 for c6 in todo}
done = 0
for future in as_completed(futures):
c6 = futures[future]
done += 1
try:
r = future.result()
except Exception as e:
fail += 1
logger.warning(f"[share {c6}] 异常: {e}")
continue
if r is None:
fail += 1
else:
db_ops.update_stock_share_snapshot(
code=self._to_hermes(c6),
total_share=r["total_share"],
float_share=r["float_share"],
trade_date=r.get("trade_date", today),
)
ok += 1
if done % 100 == 0:
self._progress(
message=f"刷新股本 {done}/{len(todo)}...",
current=done, total=len(todo), current_step=c6,
)
skip = len(code6_list) - len(todo)
return ok, skip, fail
@staticmethod
def _to_hermes(code6: str) -> str:
if code6.startswith(("5", "6", "9")):
return f"SH{code6}"
if code6.startswith(("4", "8")):
return f"BJ{code6}"
return f"SZ{code6}"