fix(market): bound AkShare market stats calls (#2345)

This commit is contained in:
SynapShift
2026-09-19 23:09:08 +08:00
committed by GitHub
parent 1168e31626
commit f9e7b778c4
3 changed files with 165 additions and 2 deletions

View File

@@ -64,6 +64,7 @@ logger = logging.getLogger(__name__)
SINA_REALTIME_ENDPOINT = "hq.sinajs.cn/list"
TENCENT_REALTIME_ENDPOINT = "qt.gtimg.cn/q"
_AKSHARE_HISTORY_CALL_TIMEOUT = 30.0
_AKSHARE_MARKET_STATS_CALL_TIMEOUT = 30.0
_AKSHARE_TIMEOUT_PROCESS_JOIN_GRACE = 1.0
_AKSHARE_TIMEOUT_PROCESS_START_METHOD = "spawn"
@@ -428,6 +429,7 @@ class AkshareFetcher(BaseFetcher):
self.sleep_max = sleep_max
self._last_request_time: Optional[float] = None
self._history_call_timeout = _AKSHARE_HISTORY_CALL_TIMEOUT
self._market_stats_call_timeout = _AKSHARE_MARKET_STATS_CALL_TIMEOUT
# 东财补丁开启才执行打补丁操作
if get_config().enable_eastmoney_patch:
eastmoney_patch()
@@ -2013,7 +2015,11 @@ class AkshareFetcher(BaseFetcher):
"[MarketStats] component=market_stats provider=AkshareFetcher "
"api=ak.stock_zh_a_spot_em action=request_start"
)
df = ak.stock_zh_a_spot_em()
df = _akshare_call_with_timeout(
ak.stock_zh_a_spot_em,
timeout=self._market_stats_call_timeout,
call_name="ak.stock_zh_a_spot_em",
)
elapsed = time.monotonic() - started_at
logger.info(
"[MarketStats] component=market_stats provider=AkshareFetcher "
@@ -2026,6 +2032,15 @@ class AkshareFetcher(BaseFetcher):
"[MarketStats] component=market_stats provider=AkshareFetcher "
"api=ak.stock_zh_a_spot_em action=parse status=empty"
)
except TimeoutError as e:
elapsed = time.monotonic() - started_at
logger.warning(
"[MarketStats] component=market_stats provider=AkshareFetcher "
"api=ak.stock_zh_a_spot_em action=request_timeout elapsed=%.2fs "
"error=%s fallback=ak.stock_zh_a_spot",
elapsed,
e,
)
except Exception as e:
logger.warning(
"[MarketStats] component=market_stats provider=AkshareFetcher "
@@ -2043,7 +2058,11 @@ class AkshareFetcher(BaseFetcher):
"[MarketStats] component=market_stats provider=AkshareFetcher "
"api=ak.stock_zh_a_spot action=request_start"
)
df = ak.stock_zh_a_spot()
df = _akshare_call_with_timeout(
ak.stock_zh_a_spot,
timeout=self._market_stats_call_timeout,
call_name="ak.stock_zh_a_spot",
)
elapsed = time.monotonic() - started_at
logger.info(
"[MarketStats] component=market_stats provider=AkshareFetcher "
@@ -2056,6 +2075,15 @@ class AkshareFetcher(BaseFetcher):
"[MarketStats] component=market_stats provider=AkshareFetcher "
"api=ak.stock_zh_a_spot action=parse status=empty"
)
except TimeoutError as e:
elapsed = time.monotonic() - started_at
logger.error(
"[MarketStats] component=market_stats provider=AkshareFetcher "
"api=ak.stock_zh_a_spot action=request_timeout elapsed=%.2fs "
"error=%s fallback=none",
elapsed,
e,
)
except Exception as e:
logger.error(
"[MarketStats] component=market_stats provider=AkshareFetcher "

View File

@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [测试] 修复股票名称解析冷启动超时并发测试的同步竞态:在放行后台抓取前确认两个等待者均已结束并返回空结果,避免 Docker 发布门禁偶发失败。
- [文档] 将仓库内所有 SerpApi 链接统一更新为新的赞助转化追踪地址。
- [修复] 智能导入兼容带 UTF-8 BOM 的 CSV 与剪贴板文本,避免 `code` 表头被误当成数据并丢失有效股票代码。
- [修复] 为 AkShare 大盘涨跌统计的东财与新浪降级调用增加可强制终止的子进程超时避免外部接口无响应时长期占用分析线程Fixes #2340)。
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore-->
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->

View File

@@ -0,0 +1,134 @@
# -*- coding: utf-8 -*-
"""Regression tests for AkShare market statistics timeout handling."""
import sys
from types import SimpleNamespace
import pandas as pd
from tests.litellm_stub import ensure_litellm_stub
ensure_litellm_stub()
from data_provider.akshare_fetcher import AkshareFetcher
def _market_frame() -> pd.DataFrame:
return pd.DataFrame(
{
"代码": ["600000", "000001"],
"名称": ["浦发银行", "平安银行"],
"最新价": [11.0, 9.0],
"昨收": [10.0, 10.0],
"成交额": [100_000_000, 200_000_000],
}
)
def test_market_stats_eastmoney_call_uses_timeout_wrapper(monkeypatch) -> None:
calls = []
eastmoney_api = object()
sina_api = object()
def fake_call(func, *args, timeout=None, call_name="", **kwargs):
calls.append((func, timeout, call_name))
return _market_frame()
monkeypatch.setitem(
sys.modules,
"akshare",
SimpleNamespace(
stock_zh_a_spot_em=eastmoney_api,
stock_zh_a_spot=sina_api,
),
)
monkeypatch.setattr(
"data_provider.akshare_fetcher._akshare_call_with_timeout",
fake_call,
)
fetcher = AkshareFetcher(sleep_min=0, sleep_max=0)
fetcher._market_stats_call_timeout = 7
stats = fetcher.get_market_stats()
assert calls == [(eastmoney_api, 7, "ak.stock_zh_a_spot_em")]
assert stats == {
"up_count": 1,
"down_count": 1,
"flat_count": 0,
"limit_up_count": 1,
"limit_down_count": 1,
"total_amount": 3.0,
}
def test_market_stats_falls_back_to_sina_after_eastmoney_timeout(
monkeypatch,
) -> None:
calls = []
eastmoney_api = object()
sina_api = object()
def fake_call(func, *args, timeout=None, call_name="", **kwargs):
calls.append((func, timeout, call_name))
if func is eastmoney_api:
raise TimeoutError("eastmoney timeout")
return _market_frame()
monkeypatch.setitem(
sys.modules,
"akshare",
SimpleNamespace(
stock_zh_a_spot_em=eastmoney_api,
stock_zh_a_spot=sina_api,
),
)
monkeypatch.setattr(
"data_provider.akshare_fetcher._akshare_call_with_timeout",
fake_call,
)
fetcher = AkshareFetcher(sleep_min=0, sleep_max=0)
fetcher._market_stats_call_timeout = 9
stats = fetcher.get_market_stats()
assert calls == [
(eastmoney_api, 9, "ak.stock_zh_a_spot_em"),
(sina_api, 9, "ak.stock_zh_a_spot"),
]
assert stats is not None
assert stats["total_amount"] == 3.0
def test_market_stats_returns_none_after_both_calls_timeout(monkeypatch) -> None:
calls = []
eastmoney_api = object()
sina_api = object()
def fake_call(func, *args, timeout=None, call_name="", **kwargs):
calls.append((func, timeout, call_name))
raise TimeoutError(f"{call_name} timeout")
monkeypatch.setitem(
sys.modules,
"akshare",
SimpleNamespace(
stock_zh_a_spot_em=eastmoney_api,
stock_zh_a_spot=sina_api,
),
)
monkeypatch.setattr(
"data_provider.akshare_fetcher._akshare_call_with_timeout",
fake_call,
)
fetcher = AkshareFetcher(sleep_min=0, sleep_max=0)
fetcher._market_stats_call_timeout = 5
assert fetcher.get_market_stats() is None
assert calls == [
(eastmoney_api, 5, "ak.stock_zh_a_spot_em"),
(sina_api, 5, "ak.stock_zh_a_spot"),
]