Files
market_sync/app/sources/akshare_lhb.py
T
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

217 lines
8.9 KiB
Python
Raw 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 龙虎榜数据源(2026-07-01 引入)。
提供:
- 龙虎榜聚合层(`ak.stock_lhb_detail_em`):每日上榜股票汇总
- 龙虎榜席位层(`ak.stock_lhb_stock_detail_em`):单只上榜票的买卖前五营业部
设计原则:
- 严格遵循 [[no-akshare]] 当前规则:仅"无替代源"场景可用。
龙虎榜无合理替代(baostock / mairui / 雪球均无龙虎榜接口)→ 用 akshare。
- 速率限制:~0.5 RPS 主动 sleepakshare 经验值;东财未明确限速)
- 失败处理:席位层对"未上榜"的股票会触发 akshare 内部 `'NoneType' object is not subscriptable`
→ 在外层 try/except 吞掉该异常,按"空数据"处理。
- 每次调用前显式 sleep 0.4s,避免被东财风控。
"""
from __future__ import annotations
import threading
import time
from typing import Any, Optional
import pandas as pd
from app.core.datasource.base import DataSource
from app.core.utils.logging import get_logger
logger = get_logger("akshare_lhb")
class AkshareLhbSource(DataSource):
"""akshare 龙虎榜数据源(基于东方财富私有 API 的 akshare 封装)。"""
key = "datasource_akshare_lhb"
name = "akshare 龙虎榜(东方财富封装)"
provides = ["longhubang_daily", "longhubang_seat"]
requires_credential = False
# 全局 RPS 限流(akshare → 东财私有 API
# 设 2.5 RPS0.4s 间隔)—— 经验值比 mairui 钻石档保守,避免被东财风控
_RPS_LIMIT = 2.5
_rate_lock = threading.Lock()
_last_request_ts = 0.0
@classmethod
def _wait_rps(cls) -> None:
"""全局 RPS 限流:每次 akshare 调用前 sleep。"""
with cls._rate_lock:
now = time.time()
elapsed = now - cls._last_request_ts
min_interval = 1.0 / cls._RPS_LIMIT
if elapsed < min_interval:
time.sleep(min_interval - elapsed)
cls._last_request_ts = time.time()
def is_available(self) -> tuple[bool, str]:
try:
import akshare # noqa: F401
return True, "akshare 已安装"
except ImportError as e:
return False, f"akshare 未安装: {e}"
def health_check(self) -> dict[str, Any]:
try:
import akshare as ak
# 用一次历史数据作烟测(取一个肯定有数据的工作日;2024-06-14 是周五)
self._wait_rps()
try:
df = ak.stock_lhb_detail_em(start_date="20240614", end_date="20240614")
except Exception as inner_e:
# akshare 在节假日可能返 None 然后内部崩,catch 后报失败
return {"success": False, "message": f"akshare 龙虎榜失败: {inner_e}"}
if df is None or df.empty:
return {"success": False, "message": "akshare 龙虎榜返回空(可能是节假日)"}
return {"success": True, "message": f"akshare 龙虎榜 OK,样例 {len(df)}"}
except Exception as e:
return {"success": False, "message": f"akshare 龙虎榜失败: {e}"}
# ── 聚合层 ───────────────────────────────────────────
def fetch_longhubang_daily(
self,
start_date: str,
end_date: str,
) -> pd.DataFrame:
"""龙虎榜每日上榜股票汇总。
Args:
start_date / end_date: 'YYYY-MM-DD''YYYYMMDD'akshare 接受两种)
Returns:
DataFrame 字段:
序号 / 代码 / 名称 / 上榜日 / 解读 / 收盘价 / 涨跌幅 /
龙虎榜净买额 / 龙虎榜买入额 / 龙虎榜卖出额 / 龙虎榜成交额 /
市场总成交额 / 净买额占总成交比 / 成交额占总成交比 / 换手率 /
流通市值 / 上榜原因 / 上榜后1日 / 上榜后2日 / 上榜后5日 / 上榜后10日
"""
try:
import akshare as ak
except ImportError:
return pd.DataFrame()
# 兼容两种日期格式:YYYYMMDD 直接传,YYYY-MM-DD 剥横线
sd = start_date.replace("-", "")
ed = end_date.replace("-", "")
self._wait_rps()
try:
df = ak.stock_lhb_detail_em(start_date=sd, end_date=ed)
except Exception as e:
logger.warning(f"[akshare] stock_lhb_detail_em({sd},{ed}) failed: {e}")
return pd.DataFrame()
if df is None or df.empty:
return pd.DataFrame()
# akshare 6 位代码不带交易所前缀;project 标准是 SH600000 / SZ000001 → 加前缀
df = df.copy()
df["stock_code"] = df["代码"].astype(str).str.zfill(6).apply(self._prefix_code)
df = df.rename(columns={
"名称": "stock_name",
"上榜日": "trade_date",
"解读": "comment",
"收盘价": "close",
"涨跌幅": "pct_chg",
"龙虎榜净买额": "lhb_net_buy",
"龙虎榜买入额": "lhb_buy_amt",
"龙虎榜卖出额": "lhb_sell_amt",
"龙虎榜成交额": "lhb_total_amt",
"市场总成交额": "market_total_amt",
"净买额占总成交比": "net_buy_ratio",
"成交额占总成交比": "total_amt_ratio",
"换手率": "turnover_rate",
"流通市值": "float_mv",
"上榜原因": "reason",
"上榜后1日": "post_1d_pct",
"上榜后2日": "post_2d_pct",
"上榜后5日": "post_5d_pct",
"上榜后10日": "post_10d_pct",
"序号": "rank_idx",
})
# 去重:akshare 同一天同一只票可能因多上榜原因(涨幅+换手率)返回多行,
# 数值相同只 reason 不同 → 按 (stock_code, trade_date) group by
# reason 用 " | " 拼接保留所有原因,数值列 first()
agg_cols = {
"stock_name": "first",
"rank_idx": "min",
"comment": "first",
"close": "first",
"pct_chg": "first",
"lhb_net_buy": "first",
"lhb_buy_amt": "first",
"lhb_sell_amt": "first",
"lhb_total_amt": "first",
"market_total_amt": "first",
"net_buy_ratio": "first",
"total_amt_ratio": "first",
"turnover_rate": "first",
"float_mv": "first",
"reason": lambda s: " | ".join(sorted(set(v for v in s if v))),
"post_1d_pct": "first",
"post_2d_pct": "first",
"post_5d_pct": "first",
"post_10d_pct": "first",
}
df = (
df.groupby(["stock_code", "trade_date"], as_index=False)
.agg(agg_cols)
)
return df
@staticmethod
def _prefix_code(code6: str) -> str:
"""6 位代码 → SH/SZ 前缀。"""
if code6.startswith(("5", "6", "9")):
return f"SH{code6}"
return f"SZ{code6}"
# ── 席位层 ───────────────────────────────────────────
def fetch_longhubang_seat(
self,
code6: str,
trade_date: str,
direction: str = "buy",
) -> pd.DataFrame:
"""单只上榜股票单日的买/卖前 5 营业部。
Args:
code6: 6 位股票代码(无交易所前缀)
trade_date: 'YYYY-MM-DD'akshare 要求此格式)
direction: 'buy' / 'sell'(中文:'买入' / '卖出'
Returns:
DataFrame 字段:
序号 / 交易营业部名称 / 买入金额 / 买入金额-占总成交比例 /
卖出金额 / 卖出金额-占总成交比例 / 净额 / 类型
注意:若该股当日未上榜,akshare 内部抛 `'NoneType' object is not subscriptable`
我们 try/except 吞掉返回空 DataFrame(正常情况,不是错)。
"""
try:
import akshare as ak
except ImportError:
return pd.DataFrame()
flag = "买入" if direction == "buy" else "卖出"
# akshare 的 stock_lhb_stock_detail_em 要 YYYYMMDD 格式(不带横线),
# YYYY-MM-DD 会返回 None 然后内部崩在 'NoneType' object is not subscriptable
date_yyyymmdd = str(trade_date).replace("-", "")
self._wait_rps()
try:
df = ak.stock_lhb_stock_detail_em(
symbol=str(code6).zfill(6),
date=date_yyyymmdd,
flag=flag,
)
except Exception as e:
# 'NoneType' object is not subscriptable = 股票未上榜(akshare bug
# 其他真错也兜住,返回空
logger.debug(f"[akshare] stock_lhb_stock_detail_em({code6},{date_yyyymmdd},{flag}) → {type(e).__name__}: {e}")
return pd.DataFrame()
if df is None or df.empty:
return pd.DataFrame()
return df