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:
@@ -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"]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)}
|
||||
@@ -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
|
||||
|
||||
设计要点:
|
||||
- 避免每只股票 commit(5000 只 = 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)}
|
||||
@@ -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 上证 → .SH;399xxx 深证 → .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,
|
||||
}
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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)}
|
||||
@@ -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 + 1,ema10>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 engine(pymysql 裸连接 + 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)
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user