Files
gao 7a985dddd5 refactor: PG-only 迁移 + 龙虎榜/tick/moneyflow 同步 + stock_code 统一 + mairui 编码修复
## 1. PG-only 重构
- 删 app/core/db/connection.py + schema.py (MySQL 路径)
- 新 app/core/db/{orm,models,pg_bootstrap}.py — SQLAlchemy 2.x ORM 一键建表
- 16 张业务表全在 market_data schema,原生 TIMESTAMPTZ / JSONB / Float / TEXT
- requirements.txt 删 PyMySQL 路径,加 psycopg2

## 2. 同步任务扩展(3 个新 task)
- **tick_trade** (mairui hsrl/zbjy):当天逐笔交易,21:00 发布
- **moneyflow** (mairui hsstock/history/transaction):个股资金流,21:30 发布
- **longhubang** (akshare):龙虎榜聚合层 + 席位层(2 张新表)
  - 长虎榜放宽 akshare 政策:仅"无替代源 + 烟测通过"场景允许
  - data_eastmoney 私有 API 不需要(akshare 烟测通过)
- 3-timer 设计:
  - 15:30 market-sync.service (8 base tasks via runall_once)
  - 21:05 market-sync-tick.service (tick_trade)
  - 21:35 market-sync-moneyflow.service (moneyflow)
  - 22:00 market-sync-lhb.service (longhubang,新加)
- bin/systemd/ 新增 tick / moneyflow / lhb 各 1 对 service+timer
- bin/market_sync_*_run.sh wrapper 脚本(不做法定节假日过滤,fail-open)

## 3. stock_code 统一为带 SH/SZ/BJ 前缀
- 历史 bug:stocks.code 用 SH600519,但 kline/moneyflow/tick_trade/kline_5min
  /stock_sector_map/industry 6 张表用纯 6 位 600519,跨表 JOIN 全部 0 行
- 新增 to_hermes() 工具:6位 / 9位(mairui `000001.SZ` 格式)→ 统一 SH000001
- 5 个 task 改写:用 to_hermes(code6) 写入 stock_code
- 一次性迁移 6 张表存量 154M 行(CASE WHEN 探测 + 去重 + 加前缀)
- ORM: stock_sector_map.stock_code / industry.code String(6)→String(10)

## 4. Bug 修复
- **share table stock_code 格式**:之前写 6 位不带前缀,与 stocks 不一致
  → 修 task_share_snapshot + 一次性 UPDATE 63,417 行加前缀
- **share_snapshot warning 状态错填 last_error**:
  → 加 mark_sync_warning() 走专用路径,不写 last_failure_at / last_error
- **schedule config lastRun 不同步**:
  → 加 update_job_status_for_dataset(),SyncTask.run() 完成后自动镜像
  → cli/runall 触发的 task 也能更新 schedule config

## 5. mairui UTF-8 编码修复
- 历史 bug:mairui.py:_fetch 用 latin-1 兜底解码,把所有 UTF-8 中文名
  double-encoded 写入 stocks.name(如 `歌华有线` 变成 `æ\xad\x8cå\x8d\x8e...`)
- 加 _decode_response():UTF-8 → GBK → latin-1 兜底
- 一次性修复 stocks.name 5,213 行:
  - 4,370 行 (encode('latin-1').decode('utf-8') 反向解码)
  - 616 行 (含 fullwidth A,宽松 printable 检查)
  - 820 行 (mid-character 截断,重新从 mairui 拉)

## 6. 测试
- tests/test_smoke.py: TASKS 10→11, SYNC_DEFINITIONS 10→11
- tests/test_schema_models.py: 16→18 张表,新增 longhubang_daily/seat
- pytest 11/11 passed

## 验证
- 6 张表 0 残留无前缀行
- stocks JOIN kline_stock / kline_5min / moneyflow / tick_trade / stock_sector_map:88-100% 命中
- 5,213 stocks.name 全部正确 UTF-8 中文
- pytest 11/11 passed
2026-07-01 22:00:58 +08:00

246 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""同步任务:龙虎榜聚合层 + 席位层(akshare 源)。
数据源:
- 聚合层:`ak.stock_lhb_detail_em(start_date, end_date)` → `longhubang_daily` 表
- 席位层:`ak.stock_lhb_stock_detail_em(symbol, date, flag)` → `longhubang_seat` 表
设计要点:
1) 22:00 触发(mairui 21:30 跑 moneyflow;龙虎榜 19:00-21:00 陆续出齐,22:00 兜底)
2) 默认"今天"增量;CLI 支持 --backfill YYYYMMDD 单日;--backfill-range YYYYMMDD YYYYMMDD 日期范围
3) 聚合层一次性拉日期范围;席位层先抓聚合层取出 stock_code 集合再逐只抓(节省调用)
4) 串行执行(每日调用 ~3 次聚合层 + ~200 次席位层;3-5 分钟内完成)
5) akshare 对"未上榜"的股票会抛 `'NoneType' object is not subscriptable`
→ source 层 try/except 兜住,按"空数据"处理
6) 数据是历史回填友好(全量 4 年 ≈ 25 分钟,2026-07-01 烟测通过)
"""
from __future__ import annotations
import time
from datetime import datetime, timedelta
from typing import Any
from app.core.db import ops as db_ops
from app.core.datasource.base import registry as ds_registry
from app.core.datasource.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.longhubang")
def _parse_yyyymmdd(s: str) -> str:
"""'YYYYMMDD''YYYY-MM-DD''YYYY-MM-DD'"""
s = str(s).strip()
if len(s) == 8 and s.isdigit():
return f"{s[:4]}-{s[4:6]}-{s[6:8]}"
return s
def _date_range(start: str, end: str) -> list[str]:
"""返回 start..end(含)期间所有日期字符串列表(YYYY-MM-DD)。"""
s = datetime.strptime(start, "%Y-%m-%d")
e = datetime.strptime(end, "%Y-%m-%d")
if e < s:
s, e = e, s
out = []
cur = s
while cur <= e:
out.append(cur.strftime("%Y-%m-%d"))
cur += timedelta(days=1)
return out
class SyncLonghubang(SyncTask):
"""龙虎榜聚合层 + 席位层同步。
CLI 用法:
python -m app.entrypoints.cli sync longhubang # 今天增量
... sync longhubang --backfill 20240614 # 单日
... sync longhubang --backfill-range 20240101 20240614 # 日期范围
... sync longhubang --start 20240101 --end 20240614 # 等价于 backfill-range
"""
dataset_id = "longhubang"
def _run(
self,
*,
trigger_source: str = "manual",
backfill: str | None = None,
backfill_range: tuple[str, str] | None = None,
start: str | None = None,
end: str | None = None,
**kwargs,
) -> dict[str, Any]:
src = ds_registry.get("datasource_akshare_lhb")
if src is None:
return {"status": "error", "message": "akshare_lhb 数据源未注册"}
ok, reason = is_source_ready(src.key)
if not ok:
mark_sync_blocked(self.dataset_id, message=f"akshare_lhb 未就绪: {reason}")
return {"status": "blocked", "message": f"akshare_lhb 未就绪: {reason}"}
# ── 决定要拉哪几天 ──
if backfill_range:
s, e = _parse_yyyymmdd(backfill_range[0]), _parse_yyyymmdd(backfill_range[1])
elif start and end:
s, e = _parse_yyyymmdd(start), _parse_yyyymmdd(end)
elif backfill:
s = e = _parse_yyyymmdd(backfill)
else:
# 默认"今天"(盘后未收盘时 fallback 到昨天)
from app.core.sync.base import _effective_sync_end
s = e = _effective_sync_end()
dates = _date_range(s, e)
if not dates:
return {"status": "error", "message": f"日期范围无效: {s}..{e}"}
logger.info(f"[longhubang] 计划: {len(dates)} 天 ({s} ~ {e})")
# ── 逐日同步 ──
t0 = time.time()
daily_ok = daily_fail = 0
daily_total = seat_total = 0
for i, d in enumerate(dates, 1):
self._progress(
message=f"longhubang 进度 {i}/{len(dates)} 当前 {d}",
current=i, total=len(dates), current_step=d,
)
try:
d_rows, s_rows = self._sync_one_day(src, d)
daily_ok += 1
daily_total += d_rows
seat_total += s_rows
except Exception as e:
daily_fail += 1
logger.warning(f"[longhubang {d}] 失败: {e}")
elapsed = round(time.time() - t0, 1)
msg = (
f"龙虎榜 {len(dates)}天 daily_ok={daily_ok} daily_fail={daily_fail} "
f"聚合层={daily_total}行 席位层={seat_total}行, {elapsed}s"
)
return {
"status": "ok" if daily_fail == 0 else "warning",
"message": msg,
"ok": daily_ok, "fail": daily_fail,
"daily_rows": daily_total, "seat_rows": seat_total,
"elapsed_sec": elapsed,
}
# ── 单日同步(聚合 + 席位)──
def _sync_one_day(self, src, trade_date: str) -> tuple[int, int]:
"""返回 (聚合层行数, 席位层行数)。"""
# 1) 聚合层(1 调用/天)
df_daily = src.fetch_longhubang_daily(trade_date, trade_date)
daily_rows = 0
stock_codes: list[str] = []
if df_daily is not None and not df_daily.empty:
rows = []
for _, r in df_daily.iterrows():
rows.append({
"stock_code": str(r.get("stock_code") or "").strip(),
"stock_name": str(r.get("stock_name") or "").strip(),
"trade_date": str(r.get("trade_date") or trade_date)[:10],
"rank_idx": self._to_int(r.get("rank_idx")),
"comment": self._to_str(r.get("comment")),
"close": self._to_float(r.get("close")),
"pct_chg": self._to_float(r.get("pct_chg")),
"lhb_net_buy": self._to_float(r.get("lhb_net_buy")),
"lhb_buy_amt": self._to_float(r.get("lhb_buy_amt")),
"lhb_sell_amt": self._to_float(r.get("lhb_sell_amt")),
"lhb_total_amt": self._to_float(r.get("lhb_total_amt")),
"market_total_amt": self._to_float(r.get("market_total_amt")),
"net_buy_ratio": self._to_float(r.get("net_buy_ratio")),
"total_amt_ratio": self._to_float(r.get("total_amt_ratio")),
"turnover_rate": self._to_float(r.get("turnover_rate")),
"float_mv": self._to_float(r.get("float_mv")),
"reason": self._to_str(r.get("reason")),
"post_1d_pct": self._to_float(r.get("post_1d_pct")),
"post_2d_pct": self._to_float(r.get("post_2d_pct")),
"post_5d_pct": self._to_float(r.get("post_5d_pct")),
"post_10d_pct": self._to_float(r.get("post_10d_pct")),
})
if rows:
db_ops.upsert_longhubang_daily(rows)
daily_rows = len(rows)
# 取出 stock_code(去交易所前缀得 6 位,供席位层调用)
for r in rows:
code6 = str(r["stock_code"]).replace("SH", "").replace("SZ", "").replace("BJ", "")
if len(code6) == 6 and code6.isdigit():
stock_codes.append(code6)
logger.info(f"[longhubang {trade_date}] 聚合层 {daily_rows} 行, {len(stock_codes)} 只上榜")
# 2) 席位层(每只票 2 调用:buy + sell
seat_rows_total = 0
if stock_codes:
seat_rows = []
for code6 in stock_codes:
for direction in ("buy", "sell"):
df_seat = src.fetch_longhubang_seat(code6, trade_date, direction)
if df_seat is None or df_seat.empty:
continue
for _, r in df_seat.iterrows():
# 字段映射(akshare 6 中文列名)
if direction == "buy":
buy_amt = self._to_float(r.get("买入金额"))
sell_amt = self._to_float(r.get("卖出金额"))
else:
buy_amt = self._to_float(r.get("买入金额"))
sell_amt = self._to_float(r.get("卖出金额"))
# 买方向的占比列叫"买入金额-占总成交比例",卖方向叫"卖出金额-..."
amt_ratio = (
self._to_float(r.get("买入金额-占总成交比例"))
if direction == "buy"
else self._to_float(r.get("卖出金额-占总成交比例"))
)
# amount=主金额:买方向=买入金额,卖方向=卖出金额
amt = buy_amt if direction == "buy" else sell_amt
seat_rows.append({
"stock_code": f"{'SH' if code6.startswith(('5','6','9')) else 'SZ'}{code6}",
"trade_date": trade_date,
"direction": direction,
"rank_idx": self._to_int(r.get("序号")) or 0,
"branch_name": self._to_str(r.get("交易营业部名称")),
"buy_amount": buy_amt,
"sell_amount": sell_amt,
"amount": amt,
"net_amount": self._to_float(r.get("净额")),
"amount_ratio": amt_ratio,
"explanation": self._to_str(r.get("类型")),
})
if seat_rows:
db_ops.upsert_longhubang_seat(seat_rows)
seat_rows_total = len(seat_rows)
logger.info(f"[longhubang {trade_date}] 席位层 {seat_rows_total}")
return daily_rows, seat_rows_total
# ── 类型转换 helper ──
@staticmethod
def _to_float(v) -> float | None:
if v is None:
return None
try:
f = float(v)
return f if f == f else None # NaN → None
except (TypeError, ValueError):
return None
@staticmethod
def _to_int(v) -> int | None:
if v is None:
return None
try:
return int(v)
except (TypeError, ValueError):
return None
@staticmethod
def _to_str(v) -> str | None:
if v is None:
return None
s = str(v).strip()
return s if s else None