Skip to content

Commit 72da793

Browse files
authored
fix(data-provider): skip unavailable optional fetchers and back off longbridge reconnects (#1249)
* fix(data-provider): skip unavailable optional fetchers and back off longbridge reconnects * fix(data-provider): back off unavailable longbridge routes * fix(data-provider): back off noisy pytdx name lookups * docs: align PR 1249 changelog and compatibility notes * fix(config): preserve legacy schedule override precedence
1 parent 9e02f3f commit 72da793

12 files changed

Lines changed: 612 additions & 96 deletions

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ TUSHARE_TOKEN=
3131
# LONGBRIDGE_ACCESS_TOKEN=
3232
# static_info 进程内缓存秒数,默认 86400;0=每次请求拉取
3333
# LONGBRIDGE_STATIC_INFO_TTL_SECONDS=86400
34+
# 连接关闭类异常后的冷却秒数,默认 15;冷却期内会临时跳过 Longbridge,避免请求级频繁重连
35+
# LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS=15
3436
# Longbridge SDK 语言与 REPORT_LANGUAGE(zh/en)一致;SDK 日志写入 LOG_DIR/longbridge_sdk.log
3537
# 接入点(与 REPORT_LANGUAGE 无关;留空则用下列默认值):
3638
# LONGBRIDGE_HTTP_URL=https://openapi.longbridge.com
@@ -564,6 +566,7 @@ SCHEDULE_ENABLED=false
564566
# 每日执行时间(HH:MM 格式,24小时制)
565567
SCHEDULE_TIME=18:00
566568
# 定时模式启动时是否立即执行一次分析(true/false)
569+
# 若未显式设置,定时模式会沿用 RUN_IMMEDIATELY 的运行时覆盖语义以兼容旧配置
567570
SCHEDULE_RUN_IMMEDIATELY=true
568571
# 非定时模式启动时是否立即执行一次分析(true/false)
569572
RUN_IMMEDIATELY=true

data_provider/base.py

Lines changed: 166 additions & 80 deletions
Large diffs are not rendered by default.

data_provider/longbridge_fetcher.py

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
logger = logging.getLogger(__name__)
3737

3838
_DEFAULT_STATIC_INFO_TTL = 86400 # 24h
39+
_DEFAULT_CONNECTION_COOLDOWN_SECONDS = 15
3940

4041

4142
def _static_info_ttl_seconds() -> int:
@@ -49,6 +50,17 @@ def _static_info_ttl_seconds() -> int:
4950
return _DEFAULT_STATIC_INFO_TTL
5051

5152

53+
def _connection_cooldown_seconds() -> int:
54+
"""Cooldown after connection-close errors to avoid reconnect thrashing."""
55+
raw = os.getenv("LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS", "").strip()
56+
if raw == "":
57+
return _DEFAULT_CONNECTION_COOLDOWN_SECONDS
58+
try:
59+
return max(0, int(raw))
60+
except ValueError:
61+
return _DEFAULT_CONNECTION_COOLDOWN_SECONDS
62+
63+
5264
_REGION_URL_MAP: Dict[str, Dict[str, str]] = {
5365
"cn": {
5466
"http_url": "https://openapi.longbridge.cn",
@@ -271,6 +283,7 @@ def __init__(self):
271283
self._config = None
272284
self._ctx_lock = threading.Lock()
273285
self._available = None
286+
self._cooldown_until = 0.0
274287
# {symbol: (StaticInfo, timestamp)}
275288
self._static_cache: Dict[str, Any] = {}
276289
self._static_cache_lock = threading.Lock()
@@ -285,6 +298,33 @@ def _invalidate_ctx(self):
285298
self._ctx = None
286299
self._config = None
287300

301+
def _mark_connection_cooldown(self, exc: Exception) -> None:
302+
cooldown_seconds = _connection_cooldown_seconds()
303+
self._invalidate_ctx()
304+
if cooldown_seconds <= 0:
305+
return
306+
self._cooldown_until = time.time() + cooldown_seconds
307+
logger.warning(
308+
"[Longbridge] 检测到连接异常,进入 %ss 冷却期以避免频繁重连: %s",
309+
cooldown_seconds,
310+
exc,
311+
)
312+
313+
def is_available_for_request(self, capability: str = "") -> bool:
314+
"""Report request-time availability including temporary cooldown."""
315+
if not self._is_available():
316+
return False
317+
if self._cooldown_until > time.time():
318+
logger.debug(
319+
"[Longbridge] %s 冷却中,暂时跳过请求,剩余 %.1fs",
320+
capability or "request",
321+
self._cooldown_until - time.time(),
322+
)
323+
return False
324+
if self._cooldown_until:
325+
self._cooldown_until = 0.0
326+
return True
327+
288328
def _is_available(self) -> bool:
289329
"""Check if Longbridge credentials are configured."""
290330
if self._available is not None:
@@ -417,7 +457,7 @@ def _get_static_info(self, symbol: str) -> Optional[Any]:
417457
except Exception as e:
418458
logger.debug(f"[Longbridge] static_info({symbol}) 失败: {e}")
419459
if self._is_connection_error(e):
420-
self._invalidate_ctx()
460+
self._mark_connection_cooldown(e)
421461
return None
422462

423463
# ------------------------------------------------------------------
@@ -499,7 +539,7 @@ def _compute_volume_ratio(self, symbol: str, today_volume: int) -> Optional[floa
499539

500540
def get_realtime_quote(self, stock_code: str) -> Optional[UnifiedRealtimeQuote]:
501541
"""Fetch realtime quote from Longbridge, computing derived fields."""
502-
if not self._is_available():
542+
if not self.is_available_for_request("realtime_quote"):
503543
return None
504544

505545
symbol = _to_longbridge_symbol(stock_code)
@@ -519,8 +559,7 @@ def get_realtime_quote(self, stock_code: str) -> Optional[UnifiedRealtimeQuote]:
519559
except Exception as e:
520560
logger.info(f"[Longbridge] quote({symbol}) 失败: {e}")
521561
if self._is_connection_error(e):
522-
logger.warning("[Longbridge] 检测到连接已断开,将在下次调用时重建连接")
523-
self._invalidate_ctx()
562+
self._mark_connection_cooldown(e)
524563
return None
525564

526565
price = safe_float(getattr(q, "last_done", None))
@@ -627,6 +666,9 @@ def _fetch_raw_data(
627666
self, stock_code: str, start_date: str, end_date: str
628667
) -> pd.DataFrame:
629668
"""Fetch historical candlesticks from Longbridge."""
669+
if not self.is_available_for_request("daily_data"):
670+
raise RuntimeError("Longbridge temporarily unavailable for daily_data")
671+
630672
symbol = _to_longbridge_symbol(stock_code)
631673
if symbol is None:
632674
raise ValueError(f"Cannot convert {stock_code} to Longbridge symbol")
@@ -650,8 +692,7 @@ def _fetch_raw_data(
650692
)
651693
except Exception as e:
652694
if self._is_connection_error(e):
653-
logger.warning("[Longbridge] 检测到连接已断开,将在下次调用时重建连接")
654-
self._invalidate_ctx()
695+
self._mark_connection_cooldown(e)
655696
raise
656697

657698
if not candles:

data_provider/pytdx_fetcher.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import logging
1818
import re
19+
import time
1920
from contextlib import contextmanager
2021
from typing import Optional, Generator, List, Tuple
2122

@@ -28,11 +29,20 @@
2829
before_sleep_log,
2930
)
3031

31-
from .base import BaseFetcher, DataFetchError, STANDARD_COLUMNS, is_bse_code, _is_hk_market
32+
from .base import (
33+
BaseFetcher,
34+
DataFetchError,
35+
DataSourceUnavailableError,
36+
STANDARD_COLUMNS,
37+
is_bse_code,
38+
_is_hk_market,
39+
)
3240
import os
3341

3442
logger = logging.getLogger(__name__)
3543

44+
_PYTDX_CONNECTION_COOLDOWN_SECONDS = 15.0
45+
3646

3747
def _parse_hosts_from_env() -> Optional[List[Tuple[str, int]]]:
3848
"""
@@ -139,6 +149,23 @@ def __init__(self, hosts: Optional[List[Tuple[str, int]]] = None):
139149
self._current_host_idx = 0
140150
self._stock_list_cache = None # 股票列表缓存
141151
self._stock_name_cache = {} # 股票名称缓存 {code: name}
152+
self._unavailable_until = 0.0
153+
self._last_unavailable_reason = ""
154+
155+
def _is_in_connection_cooldown(self) -> bool:
156+
return time.time() < self._unavailable_until
157+
158+
def _mark_connection_cooldown(self, reason: str) -> None:
159+
self._unavailable_until = time.time() + _PYTDX_CONNECTION_COOLDOWN_SECONDS
160+
self._last_unavailable_reason = str(reason or "").strip()
161+
logger.info(
162+
"Pytdx 连接失败,进入冷却 %.0fs: %s",
163+
_PYTDX_CONNECTION_COOLDOWN_SECONDS,
164+
self._last_unavailable_reason or "unknown",
165+
)
166+
167+
def is_available_for_request(self, capability: str = "") -> bool:
168+
return not self._is_in_connection_cooldown()
142169

143170
def _get_pytdx(self):
144171
"""
@@ -167,6 +194,11 @@ def _pytdx_session(self) -> Generator:
167194
with self._pytdx_session() as api:
168195
# 在这里执行数据查询
169196
"""
197+
if self._is_in_connection_cooldown():
198+
raise DataSourceUnavailableError(
199+
f"Pytdx temporarily unavailable: {self._last_unavailable_reason or 'connection cooldown'}"
200+
)
201+
170202
TdxHq_API = self._get_pytdx()
171203
if TdxHq_API is None:
172204
raise DataFetchError("pytdx 库未安装")
@@ -191,6 +223,7 @@ def _pytdx_session(self) -> Generator:
191223
continue
192224

193225
if not connected:
226+
self._mark_connection_cooldown("Pytdx 无法连接任何服务器")
194227
raise DataFetchError("Pytdx 无法连接任何服务器")
195228

196229
yield api
@@ -400,7 +433,7 @@ def get_stock_name(self, stock_code: str) -> Optional[str]:
400433
return name
401434

402435
except Exception as e:
403-
logger.warning(f"Pytdx 获取股票名称失败 {stock_code}: {e}")
436+
logger.debug(f"Pytdx 获取股票名称失败 {stock_code}: {e}")
404437

405438
return None
406439

docs/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
1111

1212
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
1313
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
14+
- [修复] 未配置 Tushare / Longbridge 凭据时不再实例化对应可选 fetcher,避免缺失凭据的数据源进入候选集。
15+
- [修复] Longbridge 遇到连接关闭类异常后会进入冷却期,并在美股/港股实时与日线请求中临时跳过该数据源,避免请求级频繁重连。
16+
- [修复] Pytdx 股票名称查询在全部服务器不可达时会短暂冷却,并在冷却期内跳过重复探测,减少无效拨号与告警噪音。
17+
- [修复] 调度模式未显式设置 `SCHEDULE_RUN_IMMEDIATELY` 时,会继续继承 `RUN_IMMEDIATELY` 的运行时覆盖语义,避免被持久化 `.env` 别名反向覆盖。
18+
- [文档] 补充 Longbridge 冷却开关与调度启动兼容语义说明。
1419

1520
- [新功能] Web 系统设置页开放 `.env` 配置备份导入/导出,复用键级覆盖、配置版本冲突保护和重载链路;Web 端在 `ADMIN_AUTH_ENABLED=false` 时该入口为禁用状态。
1621
- [chore] 精简仓库根目录:将文档图片资源迁入 `docs/assets/`,将东方财富请求补丁迁入 `src/patches/`,并下移 CI 专用依赖文件与技能适配服务。

docs/full-guide.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ daily_stock_analysis/
145145
| `LONGBRIDGE_APP_SECRET` | Longbridge App Secret | 可选 |
146146
| `LONGBRIDGE_ACCESS_TOKEN` | Longbridge Access Token | 可选 |
147147
| `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` | 长桥 `static_info` 进程内缓存秒数(默认 86400,0=不缓存) | 可选 |
148+
| `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` | 长桥连接关闭类异常后的冷却秒数(默认 15;冷却期内临时跳过 Longbridge,避免频繁重连) | 可选 |
148149
| `LONGBRIDGE_HTTP_URL` | HTTP 接口地址(默认 `https://openapi.longbridge.com`| 可选 |
149150
| `LONGBRIDGE_QUOTE_WS_URL` | 行情 WebSocket 地址(默认 `wss://openapi-quote.longbridge.com/v2`| 可选 |
150151
| `LONGBRIDGE_TRADE_WS_URL` | 交易 WebSocket 地址(默认 `wss://openapi-trade.longbridge.com/v2`| 可选 |
@@ -156,6 +157,8 @@ daily_stock_analysis/
156157

157158
> **GitHub Actions:** 仓库自带 `daily_analysis.yml` 已把上表中的 `LONGBRIDGE_*` 映射到任务环境。若未在 **Settings → Secrets and variables → Actions** 中配置 `LONGBRIDGE_APP_KEY``LONGBRIDGE_APP_SECRET``LONGBRIDGE_ACCESS_TOKEN`,CI 内不会调用长桥(日志中一般看不到 `[Longbridge]` 相关行情行)。可选接入点变量(如 `LONGBRIDGE_REGION`)可放在 **Variables****Secrets**
158159
160+
> **Longbridge 运行时行为:** 未配置凭据时不会实例化 Longbridge 这个可选 fetcher;若运行时遇到 `client is closed``context closed``connection closed` 等连接关闭类异常,会进入冷却期(默认 15 秒,可用 `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` 调整),冷却期内美股/港股的实时与日线请求会自动跳过 Longbridge,退回 YFinance / AkShare 等兜底链路。
161+
159162
> 补充说明
160163
- TUSHARE_TOKEN,当此参数配置后,但不具备港股日线接口权限时,也会出现港股数据查询不出来或者错误的情况,和老版本提示不支持港股效果相同
161164

@@ -306,7 +309,7 @@ daily_stock_analysis/
306309
| `LONGBRIDGE_APP_KEY` | [Longbridge OpenAPI](https://open.longbridge.com/) App Key;配置后美股/港股的量比、换手率、PE 等 YFinance 缺失字段会自动从长桥补充 | - | 可选 |
307310
| `LONGBRIDGE_APP_SECRET` | Longbridge App Secret | - | 可选 |
308311
| `LONGBRIDGE_ACCESS_TOKEN` | Longbridge Access Token | - | 可选 |
309-
| `LONGBRIDGE_*`(可选) | 见官方 [环境变量](https://open.longbridge.com/zh-CN/docs/getting-started#环境变量);另有 `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` | - | 可选 |
312+
| `LONGBRIDGE_*`(可选) | 见官方 [环境变量](https://open.longbridge.com/zh-CN/docs/getting-started#环境变量);另有 `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` | - | 可选 |
310313
| `ENABLE_REALTIME_QUOTE` | 启用实时行情(关闭后使用历史收盘价分析) | `true` | 可选 |
311314
| `ENABLE_REALTIME_TECHNICAL_INDICATORS` | 盘中实时技术面:启用时用实时价计算 MA5/MA10/MA20 与多头排列(Issue #234);关闭则用昨日收盘 | `true` | 可选 |
312315
| `ENABLE_CHIP_DISTRIBUTION` | 启用筹码分布分析(该接口不稳定,云端部署建议关闭)。GitHub Actions 用户需在 Repository Variables 中设置 `ENABLE_CHIP_DISTRIBUTION=true` 方可启用;workflow 默认关闭。 | `true` | 可选 |
@@ -630,7 +633,8 @@ python main.py --schedule --no-run-immediately
630633
|--------|------|:-------:|:-----:|
631634
| `SCHEDULE_ENABLED` | 是否启用定时任务 | `false` | `true` |
632635
| `SCHEDULE_TIME` | 每日执行时间 (HH:MM) | `18:00` | `09:30` |
633-
| `SCHEDULE_RUN_IMMEDIATELY` | 启动服务时是否立即运行一次 | `true` | `false` |
636+
| `SCHEDULE_RUN_IMMEDIATELY` | 定时模式启动时是否立即运行一次;未显式设置时沿用 `RUN_IMMEDIATELY` 的运行时覆盖语义 | `true` | `false` |
637+
| `RUN_IMMEDIATELY` | 非定时模式启动时是否立即运行一次;同时作为未显式设置 `SCHEDULE_RUN_IMMEDIATELY` 时的 legacy 回退 | `true` | `false` |
634638
| `TRADING_DAY_CHECK_ENABLED` | 交易日检查:非交易日跳过执行;设为 `false` 可强制执行 | `true` | `false` |
635639

636640
例如在 Docker 中配置:
@@ -640,6 +644,8 @@ python main.py --schedule --no-run-immediately
640644
docker run -e SCHEDULE_ENABLED=true -e SCHEDULE_RUN_IMMEDIATELY=false ...
641645
```
642646

647+
> 兼容说明:如果运行时显式传入 `RUN_IMMEDIATELY`,但没有单独传 `SCHEDULE_RUN_IMMEDIATELY`,内置调度模式会继续继承前者,避免被 `.env` 中持久化的 `SCHEDULE_RUN_IMMEDIATELY` 旧值反向覆盖。
648+
643649
#### 交易日判断(Issue #373)
644650

645651
默认根据自选股市场(A 股 / 港股 / 美股)和 `MARKET_REVIEW_REGION` 判断是否为交易日:
@@ -898,9 +904,11 @@ PUSHOVER_API_TOKEN=your_api_token
898904
- 美股/港股数据兜底,补充 YFinance 缺失的量比、换手率、PE 等字段
899905
- 需从 [open.longbridge.com](https://open.longbridge.com/) 注册并获取 App Key / App Secret / Access Token
900906
- 设置 `LONGBRIDGE_APP_KEY`、`LONGBRIDGE_APP_SECRET`、`LONGBRIDGE_ACCESS_TOKEN`
907+
- 可选设置 `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` 控制连接关闭类异常后的冷却秒数(默认 15)
901908
- 接入点可配 `LONGBRIDGE_HTTP_URL`、`LONGBRIDGE_QUOTE_WS_URL`、`LONGBRIDGE_TRADE_WS_URL`、`LONGBRIDGE_REGION`
902909
- 其余可选参数见官方 [环境变量说明](https://open.longbridge.com/zh-CN/docs/getting-started#环境变量)
903910
- 仅在 YFinance(美股)或 AkShare(港股)返回数据不完整时自动触发,不影响 A 股链路
911+
- 未配置凭据时不会实例化该可选数据源;若运行时出现连接关闭类异常,会在冷却期内临时跳过 Longbridge,避免请求级频繁重连
904912

905913
### 东财接口频繁失败时的处理
906914

docs/full-guide_EN.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,13 +315,16 @@ For the P0 notification baseline and diagnostics, see [Notification Baseline](no
315315
| `MARKET_REVIEW_REGION` | Market review region: cn (A-shares), hk (HK stocks), us (US stocks), both (all three markets) | `cn` |
316316
| `SCHEDULE_ENABLED` | Enable scheduled tasks | `false` |
317317
| `SCHEDULE_TIME` | Scheduled execution time | `18:00` |
318+
| `SCHEDULE_RUN_IMMEDIATELY` | Run once immediately when scheduler mode starts; when unset it keeps following the legacy `RUN_IMMEDIATELY` runtime override | `true` |
319+
| `RUN_IMMEDIATELY` | Run once immediately for non-scheduler startup; also acts as the legacy fallback when `SCHEDULE_RUN_IMMEDIATELY` is unset | `true` |
318320
| `LOG_DIR` | Log directory | `./logs` |
319321

320322
> Behavior notes:
321323
> - When `TICKFLOW_API_KEY` is configured, CN market review first tries TickFlow for main indices. Market breadth also tries TickFlow only when the current TickFlow plan supports universe queries.
322324
> - TickFlow behavior is capability-based rather than just key-based: limited plans can still enhance main CN indices, while plans with `CN_Equity_A` universe query support also enhance market breadth.
323325
> - The official quickstart documents `quotes.get(universes=["CN_Equity_A"])`, but online smoke tests confirmed two additional real-world constraints: universe access depends on plan permissions, and `quotes.get(symbols=[...])` has a per-request symbol limit.
324326
> - TickFlow currently returns `change_pct` / `amplitude` as ratio values; this integration normalizes them to the project's percent convention so they match AkShare / Tushare / efinance semantics.
327+
> - In scheduler mode, if runtime env explicitly sets `RUN_IMMEDIATELY` but does not set `SCHEDULE_RUN_IMMEDIATELY`, the scheduler keeps inheriting the legacy runtime override instead of being pulled back to a persisted `.env` alias value.
325328
> - CN market review reports now use a post-market workstation layout with fixed market light, market temperature, index detail, sector Top tables, news catalysts, next-session plan, and risk sections. Missing data sources degrade by omitting or simplifying only the affected block.
326329
> - Per-stock analysis, realtime quote priority, and sector rankings fallback remain unchanged.
327330
@@ -779,6 +782,13 @@ System defaults to AkShare (free), also supports other data sources:
779782
- Supports US/HK stock data
780783
- US stock historical and real-time data both use YFinance exclusively to avoid technical indicator errors from akshare's US stock adjustment issues
781784

785+
### Longbridge
786+
- Optional fallback for US/HK stocks, mainly used to supplement fields that YFinance may miss
787+
- Configure `LONGBRIDGE_APP_KEY`, `LONGBRIDGE_APP_SECRET`, and `LONGBRIDGE_ACCESS_TOKEN`
788+
- Optional knobs: `LONGBRIDGE_STATIC_INFO_TTL_SECONDS` (default `86400`) and `LONGBRIDGE_CONNECTION_COOLDOWN_SECONDS` (default `15`)
789+
- If credentials are absent, the optional Longbridge fetcher is not instantiated
790+
- When runtime errors such as `client is closed`, `context closed`, or `connection closed` occur, Longbridge enters a short cooldown window and US/HK daily or realtime requests automatically fall back to YFinance / AkShare instead of reconnecting on every request
791+
782792
---
783793

784794
## Advanced Features
@@ -791,7 +801,7 @@ Use `hk` prefix for HK stock codes:
791801
STOCK_LIST=600519,hk00700,hk01810
792802
```
793803

794-
HK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths.
804+
HK daily history skips efinance, pytdx, baostock, and other built-in providers that do not support HK daily data, avoiding mismatches between HK symbols and non-HK market data. AkShare/Tushare/YFinance/Longbridge continue to provide HK fallback paths. If Longbridge is inside its connection cooldown window, the route temporarily skips it and continues with the remaining HK-capable fallbacks.
795805

796806
### Multi-Model Switching
797807

0 commit comments

Comments
 (0)