Files
daily_stock_analysis/tests/test_longbridge_fetcher.py
Nicholas-Xiong 02717771a1 fix: 修复 YfinanceFetcher 4-5 位裸港股码路由到 .SZ 的 bug(fixes #2091) (#2097)
* fix: route 4-5 digit bare HK codes to .HK in YfinanceFetcher (fixes #2091)

Issue #2091: 5-digit HK listings without an explicit 'HK' prefix (e.g.
02513 for Zhipu) fell through to the 'cannot determine market, default
to .SZ' tail branch in YfinanceFetcher._convert_stock_code(), producing
'02513.SZ' which Yahoo Finance rejects with 404, breaking the daily OHLC
chain and leaving LLM-driven stop-loss / MA levels unreliable.

Fix: insert a new branch ahead of the .SZ fallback that routes 4-5 digit
pure-numeric codes to '.HK' using the same zero-padding logic as the
existing HK-prefix branch (lstrip leading zeros, zfill to 4).

Safety:
  - A-share codes are always 6 digits, so no A-share rule is shadowed.
  - BSE codes are 6 digits (4xxxxx / 8xxxxx / 920xxx) and routed earlier
    via is_bse_code(); the new branch only sees 4-5 digit inputs.
  - ETF branches (15xx/16xx/18xx/51xx/52xx/56xx/58xx) all expect 6-digit
    codes and run earlier; no overlap.
  - JP/KR/TW/US suffix codes are routed earlier; unaffected.
  - Codes already carrying .SS/.SZ/.HK/.BJ pass through verbatim earlier.
  - 1-3 digit numerics continue to fall through to the .SZ default to
    preserve prior behaviour; the fix is intentionally scoped to 4-5
    digits per maintainer note in issue #2091, avoiding speculative
    expansion of the HK rule to inputs users never fetch in practice.

Tests: tests/test_yfinance_hk_bare_code.py covers:
  - HK-prefix still routes to .HK (4 cases, regression guard)
  - Bare 4-5 digit numeric -> .HK with correct zero padding (5 cases)
  - A-share 600/601/603/688 (.SS) and 000/002/300 (.SZ) unchanged (7 cases)
  - BSE 4xxxxx / 8xxxxx / 920xxx routes to .BJ unchanged (3 cases)
  - ETF (510300.SS / 159915.SZ), JP/KR/US suffix, and pre-suffixed codes
    pass through unchanged (7 cases)
All 23 new tests pass; 71 yfinance / convert_stock_code tests total
across the wider related test set pass with no regression.

* fix: 修复 _is_hk_market 4位裸港股码路由 + DataFetcherManager 回归测试

修复 maintainer 在 PR #2097 review 中指出的 OR-COR-bfddfd66 blocker:
_DataFetcherManager.get_daily_data() 仍按 A 股链路路由 4 位裸港股码。

根因:data_provider/base.py::_is_hk_market() 只把 5 位裸数字识别
为港股,4 位裸数字 (0001 长和 / 0941 中国移动) 被路由到 cn 分支,
导致 AkshareFetcher 走 stock_zh_a_hist、BaostockFetcher 兜成 sz.0001、
TushareFetcher 转成 0001.SZ——issue #2091 在主调用路径上未真正关闭。

修复:_is_hk_market 的裸数字分支从 len(normalized) == 5 改为
4 <= len(normalized) <= 5,与 YfinanceFetcher._convert_stock_code
的 4-5 位分支保持一致。A 股 (6 位) / BSE (6 位) / ETF (51/15 开头)
不受影响,因为它们走不同前缀或位数判定。

回归测试:新增 tests/test_data_fetcher_manager_hk_bare_code.py
- _is_hk_market('0001'/'0941'/'0078') -> True
- 4位裸港股码经 DataFetcherManager 只路由到 HK-capable fetcher
  (YfinanceFetcher/AkshareFetcher/TushareFetcher),Efinance/Tencent/
  TickFlow/Pytdx/Baostock 完全不被调用
- 5位裸港股码行为不变 (00700 -> YfinanceFetcher)
- 6位 A 股仍路由到 CN-only fetcher,YfinanceFetcher 不被调用

跨 7 个相关测试文件 186 passed,全量 135 passed,无回归。

* fix(#2091): 同步 akshare_fetcher._is_hk_code 到 4-5 位裸港股码

Review blocker OR-COR-ea09dfe8 (#2097): manager 层 _is_hk_market()
已在前序 commit 放开到 4-5 位裸数字,但 AkshareFetcher._is_hk_code()
仍只接受 5 位。DataFetcherManager 默认优先级下 AkshareFetcher 优先
于 YfinanceFetcher 执行,0001 在 manager 被判为 HK 后于
AkshareFetcher 内部因 _is_hk_code('0001')==False 落到 _fetch_stock_data
A股链路,两套市场契约冲突。

同步放宽 _is_hk_code:
- 无前缀裸数字从 len==5 改为 4<=len<=5
- docstring 补充 OR-COR-ea09dfe8 说明

新增两类 provider-level regression tests:
1. TestAkshareFetcherIsHkCodeContract: 直接断言 _is_hk_code 对 4/5
   位裸码、6位裸码(排除)、前缀后缀的解码结果
2. TestAkshareFetcherRoutingCallsHkBranch: patch _fetch_hk_data /
   _fetch_stock_data,驱动 _fetch_raw_data('0001') 验证真实分流到
   HK 分支而非 A股分支,防止 _is_hk_code 被重收紧后静默回归

* fix(#2091): 同步 longbridge_fetcher._is_hk_code 到 4-5 位裸港股码

OR-COR-ea09dfe8 关闭:DataFetcherManager 路由层 (data_provider/base.py)
已放宽到 4-5 位裸港股码,把 LongbridgeFetcher 视为 HK-capable provider
保留进港股链路;但 longbridge_fetcher._is_hk_code 仍只接受 5 位裸数字,
导致 4 位裸港股 (0001 长和 / 0941 中国移动) 在配置了 Longbridge 的真实
日线/实时链路上 _to_longbridge_symbol 返回 None,日线 fallback 抛
ValueError、实时兜底被静默跳过。

修复与 base._is_hk_market 的市场契约对齐:
- _is_hk_code: 4-5 位裸数字判定为港股 (与 base._is_hk_market 一致);
  .HK 后缀严格校验后缀 base 部分为 1-5 位数字 (之前无条件 True,会把
  类似 "XXX.HK" 也误判,本次顺带收紧);HK 前缀分支保持不变。
- _to_longbridge_symbol: 在 _is_hk_code 通过后的 .HK 后缀输入仍走
  原路径直接 return upper,行为不变;4 位裸码现在能正确 zfill(4) 到
  "0001.HK" / "0941.HK"。

回归测试:
- tests/test_longbridge_fetcher.py 新增 TestSymbolConversion
  .test_hk_stock_4digit_bare_code_issue_2091: 显式 assert 0001/0941
  _is_hk_code=True + _to_longbridge_symbol="0001.HK"/"0941.HK"。
- 现有 TestSymbolConversion 7 测试 + 全文件 30 测试 全通过。
- tests/test_yfinance_hk_bare_code.py + test_data_fetcher_manager_hk_bare_code.py
  共 39 测试 全通过。
- 总计新相关回归 69/69 pass。

本地确定性复现 (reviewer 报告的 head 行为):
- 修复前: _is_hk_code("0001") == False, _to_longbridge_symbol("0001") == None
- 修复后: _is_hk_code("0001") == True,  _to_longbridge_symbol("0001") == "0001.HK"
- _is_hk_code("0941") == True,  _to_longbridge_symbol("0941") == "0941.HK"
- _is_hk_code("00700") == True, _to_longbridge_symbol("00700") == "0700.HK"
- _is_hk_code("00700.HK") == True, _to_longbridge_symbol("00700.HK") == "00700.HK"
- _is_hk_code("HK00700") == True, _to_longbridge_symbol("HK00700") == "0700.HK"
- _is_hk_code("600690") == False (6 位 A 股不误判), _to_longbridge_symbol("600690") == None
- _is_hk_code("AAPL") == False,    _to_longbridge_symbol("AAPL") == "AAPL.US"

注:本轮只关闭 OR-COR-ea09dfe8 阻断;非阻断建议 (CHANGELOG #2063 条目
移出、PR 描述 sync、docstring 中 review blocker 编号清理) 将在下一条
commit 单独处理。

* docs(#2097): 清理评审叙事 + 收敛 CHANGELOG 范围

回应 reviewer 非阻断建议 (上一条 commit a251e4f8 提到的"下一 commit 处理"):

1. docs/CHANGELOG.md
   - 移除本 PR 主题 (#2091 yfinance/HK bare-code 路由) 之外混入的
     "parse_analysis_target() / #2063 Phase 1" 条目;该 feat 属于
     commit 0313dd3c (issue #2063 Phase 1) 单独 PR 的范围,不应在本 PR
     中夹带。
   - 把 #2091 条目改写为反映本 PR 实际范围:三处 _is_hk_code 同步
     (YfinanceFetcher 在 4e915a4f、AkshareFetcher 在 e2bf3a88、
     LongbridgeFetcher 在 a251e4f8) + DataFetcherManager 港股路由
     回归测试 (9c473787),统一描述为"DataFetcherManager 港股路由"
     而非只提 yfinance 单侧,与当前累计 diff 一致。

2. data_provider/base.py:_is_hk_market / akshare_fetcher.py:_is_hk_code
   docstring 清理:移除"Review blocker OR-COR-... (PR #2097 / issue
   #2091)"评审叙事、PR 编号、blocker 编号、过程性"之前只接受..."。
   改为稳定的市场识别规则说明:支持哪种形式 (.HK 后缀 / HK 前缀 /
   4-5 位裸数字)、与 provider 内 _is_hk_code 的位数契约对齐即可。
   长期保留的产品代码不应写入评审过程叙事。

行为无变化:仅 docstring + CHANGELOG 文本修改,无代码路径修改。
回归:tests/test_longbridge_fetcher.py + tests/test_yfinance_hk_bare_code.py
+ tests/test_data_fetcher_manager_hk_bare_code.py 共 69/69 pass。
2026-07-26 11:20:12 +08:00

705 lines
31 KiB
Python

# -*- coding: utf-8 -*-
"""
Unit tests for LongbridgeFetcher integration.
Real API / credentials: use ``tests/longbridge_live_smoke.py`` (not this file).
Verifies:
1. Symbol conversion logic (AAPL -> AAPL.US, HK00700 -> 0700.HK)
2. get_realtime_quote builds correct UnifiedRealtimeQuote with computed fields
3. _supplement_from_longbridge merges missing fields into yfinance quote
4. Graceful degradation when credentials are missing
"""
import os
import base64
import sys
import tempfile
import time
import types
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch, MagicMock, PropertyMock
from dataclasses import dataclass
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from data_provider.longbridge_fetcher import (
LongbridgeFetcher,
_to_longbridge_symbol,
_is_us_code,
_is_hk_code,
)
from data_provider.realtime_types import UnifiedRealtimeQuote, RealtimeSource
class TestSymbolConversion(unittest.TestCase):
"""Test internal stock code -> Longbridge symbol conversion."""
def test_us_stock(self):
self.assertEqual(_to_longbridge_symbol("AAPL"), "AAPL.US")
self.assertEqual(_to_longbridge_symbol("TSLA"), "TSLA.US")
self.assertEqual(_to_longbridge_symbol("NVDA"), "NVDA.US")
self.assertEqual(_to_longbridge_symbol("GLD"), "GLD.US")
def test_us_stock_already_suffixed(self):
self.assertEqual(_to_longbridge_symbol("AAPL.US"), "AAPL.US")
def test_hk_stock_with_prefix(self):
self.assertEqual(_to_longbridge_symbol("HK00700"), "0700.HK")
self.assertEqual(_to_longbridge_symbol("HK09988"), "9988.HK")
self.assertEqual(_to_longbridge_symbol("HK01810"), "1810.HK")
def test_hk_stock_pure_digits(self):
self.assertEqual(_to_longbridge_symbol("00700"), "0700.HK")
self.assertEqual(_to_longbridge_symbol("09988"), "9988.HK")
def test_hk_stock_4digit_bare_code_issue_2091(self):
"""4 位裸港股码 (0001 长和 / 0941 中国移动) 必须路由到 .HK 后缀。
与 ``data_provider.base._is_hk_market`` 的 4-5 位裸港股契约一致,
Longbridge 作为 HK-capable provider 也必须接受同一输入,避免
上游路由判 HK、下游 provider 静默跳过的部分调用链失败。
"""
self.assertTrue(_is_hk_code("0001"))
self.assertTrue(_is_hk_code("0941"))
self.assertEqual(_to_longbridge_symbol("0001"), "0001.HK")
self.assertEqual(_to_longbridge_symbol("0941"), "0941.HK")
def test_hk_stock_already_suffixed(self):
self.assertEqual(_to_longbridge_symbol("0700.HK"), "0700.HK")
def test_a_share_returns_none(self):
self.assertIsNone(_to_longbridge_symbol("600519"))
self.assertIsNone(_to_longbridge_symbol("000001"))
def test_code_detection(self):
self.assertTrue(_is_us_code("AAPL"))
self.assertTrue(_is_us_code("TSLA"))
self.assertFalse(_is_us_code("600519"))
self.assertTrue(_is_hk_code("HK00700"))
self.assertTrue(_is_hk_code("00700"))
self.assertFalse(_is_hk_code("AAPL"))
class TestLongbridgeFetcherNoCredentials(unittest.TestCase):
"""Verify graceful degradation when credentials are absent."""
def setUp(self):
self.fetcher = LongbridgeFetcher()
self.fetcher._available = False
def test_returns_none_without_creds(self):
result = self.fetcher.get_realtime_quote("AAPL")
self.assertIsNone(result)
def test_is_available_false(self):
self.assertFalse(self.fetcher._is_available())
class TestLongbridgeAuthSelection(unittest.TestCase):
"""Verify OAuth and Legacy auth selection without real SDK calls."""
def _install_mock_longbridge(self):
mock_lb_module = types.ModuleType("longbridge")
mock_lb_openapi = types.ModuleType("longbridge.openapi")
mock_config = MagicMock()
mock_quote_context = MagicMock(return_value="quote-context")
mock_oauth_builder = MagicMock()
mock_lb_openapi.Config = mock_config
mock_lb_openapi.QuoteContext = mock_quote_context
mock_lb_openapi.OAuthBuilder = mock_oauth_builder
return mock_lb_module, mock_lb_openapi, mock_config, mock_quote_context, mock_oauth_builder
def _config(
self,
*,
app_key="",
app_secret="",
access_token="",
oauth_client_id="",
):
return SimpleNamespace(
longbridge_app_key=app_key,
longbridge_app_secret=app_secret,
longbridge_access_token=access_token,
longbridge_oauth_client_id=oauth_client_id,
)
@patch("src.config.get_config")
def test_is_available_with_oauth_client_id(self, mock_get_config):
mock_get_config.return_value = self._config(oauth_client_id="client-1")
fetcher = LongbridgeFetcher()
self.assertTrue(fetcher._is_available())
@patch("src.config.get_config")
def test_oauth_uses_token_cache_without_legacy_fallback(self, mock_get_config):
mock_get_config.return_value = self._config(oauth_client_id="client-1")
modules = self._install_mock_longbridge()
mock_lb_module, mock_lb_openapi, mock_config, mock_quote_context, mock_oauth_builder = modules
mock_oauth_builder.return_value.build.return_value = "oauth-token"
mock_config.from_oauth.return_value = "oauth-config"
with tempfile.TemporaryDirectory() as tmpdir:
token_cache = Path(tmpdir) / "client-1"
token_cache.write_text('{"refresh_token":"valid-token"}', encoding="utf-8")
with patch.dict("sys.modules", {"longbridge": mock_lb_module, "longbridge.openapi": mock_lb_openapi}), patch.dict(
os.environ,
{
"LONGBRIDGE_OAUTH_CLIENT_ID": "client-1",
"LONGBRIDGE_APP_KEY": "",
"LONGBRIDGE_APP_SECRET": "",
"LONGBRIDGE_ACCESS_TOKEN": "",
},
), patch("data_provider.longbridge_fetcher._longbridge_config_kwargs", return_value={}), patch(
"data_provider.longbridge_fetcher._oauth_token_cache_path",
return_value=token_cache,
):
fetcher = LongbridgeFetcher()
ctx = fetcher._get_ctx()
self.assertEqual(ctx, "quote-context")
mock_oauth_builder.assert_called_once_with("client-1")
mock_config.from_oauth.assert_called_once_with("oauth-token")
mock_config.from_apikey_env.assert_not_called()
mock_quote_context.assert_called_once_with("oauth-config")
@patch("src.config.get_config")
def test_oauth_uses_app_key_as_client_id_when_access_token_missing(self, mock_get_config):
mock_get_config.return_value = self._config(app_key="app-key", app_secret="app-secret")
modules = self._install_mock_longbridge()
mock_lb_module, mock_lb_openapi, mock_config, mock_quote_context, mock_oauth_builder = modules
mock_oauth_builder.return_value.build.return_value = "oauth-token"
mock_config.from_oauth.return_value = "oauth-config"
with tempfile.TemporaryDirectory() as tmpdir:
token_cache = Path(tmpdir) / "app-key"
token_cache.write_text('{"refresh_token":"valid-token"}', encoding="utf-8")
with patch.dict("sys.modules", {"longbridge": mock_lb_module, "longbridge.openapi": mock_lb_openapi}), patch.dict(
os.environ,
{
"LONGBRIDGE_OAUTH_CLIENT_ID": "",
"LONGBRIDGE_APP_KEY": "app-key",
"LONGBRIDGE_APP_SECRET": "app-secret",
"LONGBRIDGE_ACCESS_TOKEN": "",
},
), patch("data_provider.longbridge_fetcher._longbridge_config_kwargs", return_value={}), patch(
"data_provider.longbridge_fetcher._oauth_token_cache_path",
return_value=token_cache,
):
fetcher = LongbridgeFetcher()
ctx = fetcher._get_ctx()
self.assertEqual(ctx, "quote-context")
mock_oauth_builder.assert_called_once_with("app-key")
mock_config.from_oauth.assert_called_once_with("oauth-token")
mock_config.from_apikey_env.assert_not_called()
mock_config.from_apikey.assert_not_called()
mock_quote_context.assert_called_once_with("oauth-config")
@patch("src.config.get_config")
def test_oauth_without_cache_does_not_call_legacy_when_legacy_incomplete(self, mock_get_config):
mock_get_config.return_value = self._config(oauth_client_id="client-1")
modules = self._install_mock_longbridge()
mock_lb_module, mock_lb_openapi, mock_config, _, mock_oauth_builder = modules
with tempfile.TemporaryDirectory() as tmpdir:
missing_cache = Path(tmpdir) / "client-1"
with patch.dict("sys.modules", {"longbridge": mock_lb_module, "longbridge.openapi": mock_lb_openapi}), patch.dict(
os.environ,
{
"LONGBRIDGE_OAUTH_CLIENT_ID": "client-1",
"LONGBRIDGE_APP_KEY": "",
"LONGBRIDGE_APP_SECRET": "",
"LONGBRIDGE_ACCESS_TOKEN": "",
},
), patch("data_provider.longbridge_fetcher._longbridge_config_kwargs", return_value={}), patch(
"data_provider.longbridge_fetcher._oauth_token_cache_path",
return_value=missing_cache,
):
fetcher = LongbridgeFetcher()
ctx = fetcher._get_ctx()
self.assertIsNone(ctx)
mock_oauth_builder.assert_not_called()
mock_config.from_apikey_env.assert_not_called()
mock_config.from_apikey.assert_not_called()
@patch("src.config.get_config")
def test_oauth_sdk_without_oauth_api_fails_closed_with_clear_log(self, mock_get_config):
mock_get_config.return_value = self._config(oauth_client_id="client-1")
mock_lb_module = types.ModuleType("longbridge")
mock_lb_openapi = types.ModuleType("longbridge.openapi")
mock_config = MagicMock()
mock_quote_context = MagicMock(return_value="quote-context")
mock_lb_openapi.Config = mock_config
mock_lb_openapi.QuoteContext = mock_quote_context
with tempfile.TemporaryDirectory() as tmpdir:
token_cache = Path(tmpdir) / "client-1"
token_cache.write_text('{"refresh_token":"valid-token"}', encoding="utf-8")
with patch.dict("sys.modules", {"longbridge": mock_lb_module, "longbridge.openapi": mock_lb_openapi}), patch.dict(
os.environ,
{
"LONGBRIDGE_OAUTH_CLIENT_ID": "client-1",
"LONGBRIDGE_APP_KEY": "",
"LONGBRIDGE_APP_SECRET": "",
"LONGBRIDGE_ACCESS_TOKEN": "",
},
), patch("data_provider.longbridge_fetcher._longbridge_config_kwargs", return_value={}), patch(
"data_provider.longbridge_fetcher._oauth_token_cache_path",
return_value=token_cache,
), self.assertLogs("data_provider.longbridge_fetcher", level="WARNING") as logs:
fetcher = LongbridgeFetcher()
ctx = fetcher._get_ctx()
self.assertIsNone(ctx)
self.assertIn("不支持 OAuth 2.0", "\n".join(logs.output))
mock_quote_context.assert_not_called()
@patch("src.config.get_config")
def test_oauth_invalid_cache_content_skips_oauth_reauth_and_fails_closed(self, mock_get_config):
mock_get_config.return_value = self._config(oauth_client_id="client-1")
modules = self._install_mock_longbridge()
mock_lb_module, mock_lb_openapi, mock_config, _, mock_oauth_builder = modules
with tempfile.TemporaryDirectory() as tmpdir:
invalid_cache = Path(tmpdir) / "client-1"
invalid_cache.write_text("invalid-json", encoding="utf-8")
with patch.dict("sys.modules", {"longbridge": mock_lb_module, "longbridge.openapi": mock_lb_openapi}), patch.dict(
os.environ,
{
"LONGBRIDGE_OAUTH_CLIENT_ID": "client-1",
"LONGBRIDGE_APP_KEY": "",
"LONGBRIDGE_APP_SECRET": "",
"LONGBRIDGE_ACCESS_TOKEN": "",
},
), patch("data_provider.longbridge_fetcher._longbridge_config_kwargs", return_value={}), patch(
"data_provider.longbridge_fetcher._oauth_token_cache_path",
return_value=invalid_cache,
):
fetcher = LongbridgeFetcher()
ctx = fetcher._get_ctx()
self.assertIsNone(ctx)
mock_oauth_builder.assert_not_called()
mock_config.from_apikey_env.assert_not_called()
mock_config.from_apikey.assert_not_called()
@patch("src.config.get_config")
def test_oauth_overwrites_invalid_cache_from_base64_secret(self, mock_get_config):
mock_get_config.return_value = self._config(oauth_client_id="client-1")
modules = self._install_mock_longbridge()
mock_lb_module, mock_lb_openapi, mock_config, _, mock_oauth_builder = modules
mock_oauth_builder.return_value.build.return_value = "oauth-token"
mock_config.from_oauth.return_value = "oauth-config"
with tempfile.TemporaryDirectory() as tmpdir:
invalid_cache = Path(tmpdir) / "client-1"
invalid_cache.write_text("invalid-json", encoding="utf-8")
encoded_cache = base64.b64encode(b'{"refresh_token":"refreshed"}').decode("ascii")
with patch.dict("sys.modules", {"longbridge": mock_lb_module, "longbridge.openapi": mock_lb_openapi}), patch.dict(
os.environ,
{
"LONGBRIDGE_OAUTH_CLIENT_ID": "client-1",
"LONGBRIDGE_OAUTH_TOKEN_CACHE_B64": encoded_cache,
"LONGBRIDGE_APP_KEY": "",
"LONGBRIDGE_APP_SECRET": "",
"LONGBRIDGE_ACCESS_TOKEN": "",
},
), patch("data_provider.longbridge_fetcher._longbridge_config_kwargs", return_value={}), patch(
"data_provider.longbridge_fetcher._oauth_token_cache_path",
return_value=invalid_cache,
):
fetcher = LongbridgeFetcher()
ctx = fetcher._get_ctx()
self.assertEqual(invalid_cache.read_bytes(), b'{"refresh_token":"refreshed"}')
self.assertEqual(ctx, "quote-context")
mock_oauth_builder.assert_called_once_with("client-1")
mock_config.from_oauth.assert_called_once_with("oauth-token")
mock_config.from_apikey_env.assert_not_called()
mock_config.from_apikey.assert_not_called()
@patch("src.config.get_config")
def test_oauth_replaces_existing_cache_when_base64_secret_differs(self, mock_get_config):
mock_get_config.return_value = self._config(oauth_client_id="client-1")
modules = self._install_mock_longbridge()
mock_lb_module, mock_lb_openapi, mock_config, _, mock_oauth_builder = modules
mock_oauth_builder.return_value.build.return_value = "oauth-token"
mock_config.from_oauth.return_value = "oauth-config"
with tempfile.TemporaryDirectory() as tmpdir:
token_cache = Path(tmpdir) / "client-1"
token_cache.write_text('{"refresh_token":"old-but-json-valid"}', encoding="utf-8")
encoded_cache = base64.b64encode(b'{"refresh_token":"fresh"}').decode("ascii")
with patch.dict("sys.modules", {"longbridge": mock_lb_module, "longbridge.openapi": mock_lb_openapi}), patch.dict(
os.environ,
{
"LONGBRIDGE_OAUTH_CLIENT_ID": "client-1",
"LONGBRIDGE_OAUTH_TOKEN_CACHE_B64": encoded_cache,
"LONGBRIDGE_APP_KEY": "",
"LONGBRIDGE_APP_SECRET": "",
"LONGBRIDGE_ACCESS_TOKEN": "",
},
), patch("data_provider.longbridge_fetcher._longbridge_config_kwargs", return_value={}), patch(
"data_provider.longbridge_fetcher._oauth_token_cache_path",
return_value=token_cache,
):
fetcher = LongbridgeFetcher()
ctx = fetcher._get_ctx()
self.assertEqual(token_cache.read_bytes(), b'{"refresh_token":"fresh"}')
self.assertEqual(ctx, "quote-context")
mock_config.from_oauth.assert_called_once_with("oauth-token")
mock_config.from_apikey_env.assert_not_called()
mock_config.from_apikey.assert_not_called()
@patch("src.config.get_config")
def test_oauth_callback_reauth_request_fails_closed_in_headless(self, mock_get_config):
mock_get_config.return_value = self._config(oauth_client_id="client-1")
modules = self._install_mock_longbridge()
mock_lb_module, mock_lb_openapi, mock_config, _, mock_oauth_builder = modules
def _require_oauth_reauth_request(show_url):
show_url("https://longbridge.oauth/login")
raise RuntimeError("re-auth requested")
mock_oauth_builder.return_value.build.side_effect = _require_oauth_reauth_request
with tempfile.TemporaryDirectory() as tmpdir:
token_cache = Path(tmpdir) / "client-1"
token_cache.write_text('{"refresh_token":"expired-token"}', encoding="utf-8")
with patch.dict("sys.modules", {"longbridge": mock_lb_module, "longbridge.openapi": mock_lb_openapi}), patch.dict(
os.environ,
{
"LONGBRIDGE_OAUTH_CLIENT_ID": "client-1",
"LONGBRIDGE_APP_KEY": "",
"LONGBRIDGE_APP_SECRET": "",
"LONGBRIDGE_ACCESS_TOKEN": "",
},
), patch("data_provider.longbridge_fetcher._longbridge_config_kwargs", return_value={}), patch(
"data_provider.longbridge_fetcher._oauth_token_cache_path",
return_value=token_cache,
):
fetcher = LongbridgeFetcher()
ctx = fetcher._get_ctx()
self.assertIsNone(ctx)
mock_oauth_builder.assert_called_once_with("client-1")
mock_config.from_apikey_env.assert_not_called()
mock_config.from_apikey.assert_not_called()
@patch("src.config.get_config")
def test_oauth_restores_token_cache_from_base64_secret(self, mock_get_config):
mock_get_config.return_value = self._config(oauth_client_id="client-1")
modules = self._install_mock_longbridge()
mock_lb_module, mock_lb_openapi, mock_config, _, mock_oauth_builder = modules
mock_oauth_builder.return_value.build.return_value = "oauth-token"
mock_config.from_oauth.return_value = "oauth-config"
with tempfile.TemporaryDirectory() as tmpdir:
token_cache = Path(tmpdir) / "client-1"
encoded_cache = base64.b64encode(b'{"refresh_token":"test"}').decode("ascii")
with patch.dict("sys.modules", {"longbridge": mock_lb_module, "longbridge.openapi": mock_lb_openapi}), patch.dict(
os.environ,
{
"LONGBRIDGE_OAUTH_CLIENT_ID": "client-1",
"LONGBRIDGE_OAUTH_TOKEN_CACHE_B64": encoded_cache,
"LONGBRIDGE_APP_KEY": "",
"LONGBRIDGE_APP_SECRET": "",
"LONGBRIDGE_ACCESS_TOKEN": "",
},
), patch("data_provider.longbridge_fetcher._longbridge_config_kwargs", return_value={}), patch(
"data_provider.longbridge_fetcher._oauth_token_cache_path",
return_value=token_cache,
):
fetcher = LongbridgeFetcher()
ctx = fetcher._get_ctx()
self.assertEqual(token_cache.read_bytes(), b'{"refresh_token":"test"}')
self.assertEqual(ctx, "quote-context")
mock_config.from_oauth.assert_called_once_with("oauth-token")
@patch("src.config.get_config")
def test_oauth_failure_can_fallback_to_complete_legacy_credentials(self, mock_get_config):
mock_get_config.return_value = self._config(
app_key="app-key",
app_secret="app-secret",
access_token="access-token",
oauth_client_id="client-1",
)
modules = self._install_mock_longbridge()
mock_lb_module, mock_lb_openapi, mock_config, mock_quote_context, mock_oauth_builder = modules
mock_oauth_builder.return_value.build.side_effect = RuntimeError("bad cache")
mock_config.from_apikey_env.return_value = "legacy-config"
with tempfile.TemporaryDirectory() as tmpdir:
token_cache = Path(tmpdir) / "client-1"
token_cache.write_text("{}", encoding="utf-8")
with patch.dict("sys.modules", {"longbridge": mock_lb_module, "longbridge.openapi": mock_lb_openapi}), patch.dict(
os.environ,
{
"LONGBRIDGE_OAUTH_CLIENT_ID": "client-1",
"LONGBRIDGE_APP_KEY": "app-key",
"LONGBRIDGE_APP_SECRET": "app-secret",
"LONGBRIDGE_ACCESS_TOKEN": "access-token",
},
), patch("data_provider.longbridge_fetcher._longbridge_config_kwargs", return_value={}), patch(
"data_provider.longbridge_fetcher._oauth_token_cache_path",
return_value=token_cache,
):
fetcher = LongbridgeFetcher()
ctx = fetcher._get_ctx()
self.assertEqual(ctx, "quote-context")
mock_config.from_apikey_env.assert_called_once()
mock_quote_context.assert_called_once_with("legacy-config")
class TestLongbridgeFetcherMocked(unittest.TestCase):
"""Test get_realtime_quote with mocked Longbridge SDK."""
def _make_fetcher_with_mock_ctx(self):
fetcher = LongbridgeFetcher()
fetcher._available = True
mock_ctx = MagicMock()
fetcher._ctx = mock_ctx
return fetcher, mock_ctx
def _make_mock_quote(self, **kwargs):
q = MagicMock()
defaults = {
"last_done": "253.79",
"prev_close": "246.63",
"open": "247.91",
"high": "255.48",
"low": "247.10",
"volume": 49549600,
"turnover": "12575000000",
}
defaults.update(kwargs)
for k, v in defaults.items():
setattr(q, k, v)
return q
def _make_mock_static(self, **kwargs):
s = MagicMock()
defaults = {
"name_cn": "苹果",
"name_en": "Apple Inc.",
"circulating_shares": 15000000000,
"total_shares": 16000000000,
"eps_ttm": "6.08",
"bps": "4.40",
}
defaults.update(kwargs)
for k, v in defaults.items():
setattr(s, k, v)
return s
def test_realtime_quote_basic(self):
"""Verify computed fields: turnover_rate, pe_ratio, etc."""
fetcher, ctx = self._make_fetcher_with_mock_ctx()
ctx.quote.return_value = [self._make_mock_quote()]
ctx.static_info.return_value = [self._make_mock_static()]
ctx.history_candlesticks_by_offset.return_value = []
quote = fetcher.get_realtime_quote("AAPL")
self.assertIsNotNone(quote)
self.assertEqual(quote.code, "AAPL")
self.assertEqual(quote.source, RealtimeSource.LONGBRIDGE)
self.assertAlmostEqual(quote.price, 253.79, places=2)
self.assertAlmostEqual(quote.change_pct, 2.90, places=0)
self.assertEqual(quote.name, "苹果")
# turnover_rate = volume / circulating_shares * 100
expected_turnover = 49549600 / 15000000000 * 100
self.assertAlmostEqual(quote.turnover_rate, expected_turnover, places=3)
# pe_ratio = price / eps_ttm
self.assertAlmostEqual(quote.pe_ratio, 253.79 / 6.08, places=1)
# pb_ratio = price / bps
self.assertAlmostEqual(quote.pb_ratio, 253.79 / 4.40, places=1)
# total_mv
self.assertAlmostEqual(quote.total_mv, 253.79 * 16000000000, places=0)
def test_turnover_falls_back_to_total_shares_when_circulating_zero(self):
"""US API often reports circulating_shares=0; use total_shares for turnover."""
fetcher, ctx = self._make_fetcher_with_mock_ctx()
ctx.quote.return_value = [self._make_mock_quote()]
static = self._make_mock_static()
static.circulating_shares = 0
static.total_shares = 16000000000
ctx.static_info.return_value = [static]
ctx.history_candlesticks_by_offset.return_value = []
quote = fetcher.get_realtime_quote("AAPL")
self.assertIsNotNone(quote)
vol = 49549600
self.assertAlmostEqual(quote.turnover_rate, vol / 16000000000 * 100, places=3)
def test_realtime_quote_with_volume_ratio(self):
"""Verify volume_ratio calculation from history."""
import types
from datetime import date as dt_date, timedelta
# Mock longbridge.openapi module so the internal import succeeds
mock_lb_module = types.ModuleType("longbridge")
mock_lb_openapi = types.ModuleType("longbridge.openapi")
mock_lb_openapi.Period = MagicMock()
mock_lb_openapi.AdjustType = MagicMock()
with patch.dict("sys.modules", {
"longbridge": mock_lb_module,
"longbridge.openapi": mock_lb_openapi,
}):
fetcher, ctx = self._make_fetcher_with_mock_ctx()
ctx.quote.return_value = [self._make_mock_quote(volume=50000000)]
ctx.static_info.return_value = [self._make_mock_static()]
base = dt_date.today() - timedelta(days=6)
mock_candles = []
for i, vol in enumerate([40000000, 38000000, 42000000, 41000000, 39000000]):
c = MagicMock()
c.volume = vol
past_date = base + timedelta(days=i)
c.timestamp = MagicMock()
c.timestamp.date.return_value = past_date
mock_candles.append(c)
ctx.history_candlesticks_by_offset.return_value = mock_candles
quote = fetcher.get_realtime_quote("AAPL")
self.assertIsNotNone(quote)
avg_vol = (40000000 + 38000000 + 42000000 + 41000000 + 39000000) / 5
expected_ratio = round(50000000 / avg_vol, 2)
self.assertEqual(quote.volume_ratio, expected_ratio)
def test_quote_api_failure_returns_none(self):
"""If ctx.quote() raises, return None gracefully."""
fetcher, ctx = self._make_fetcher_with_mock_ctx()
ctx.quote.side_effect = Exception("network error")
result = fetcher.get_realtime_quote("AAPL")
self.assertIsNone(result)
def test_connection_error_enters_cooldown_and_skips_immediate_retry(self):
"""Connection-close failures should not trigger reconnect on every stock."""
fetcher, ctx = self._make_fetcher_with_mock_ctx()
ctx.quote.side_effect = Exception("client is closed")
with patch("data_provider.longbridge_fetcher._connection_cooldown_seconds", return_value=30):
first = fetcher.get_realtime_quote("AAPL")
second = fetcher.get_realtime_quote("AAPL")
self.assertIsNone(first)
self.assertIsNone(second)
self.assertEqual(ctx.quote.call_count, 1)
self.assertIsNone(fetcher._ctx)
self.assertGreater(fetcher._cooldown_until, time.time())
def test_daily_data_skips_request_during_cooldown(self):
"""Daily requests should also respect the connection cooldown."""
fetcher, ctx = self._make_fetcher_with_mock_ctx()
fetcher._cooldown_until = time.time() + 30
with self.assertRaisesRegex(RuntimeError, "temporarily unavailable"):
fetcher._fetch_raw_data("AAPL", "2026-05-01", "2026-05-08")
ctx.history_candlesticks_by_date.assert_not_called()
def test_hk_stock_symbol(self):
"""HK stock should use .HK suffix."""
fetcher, ctx = self._make_fetcher_with_mock_ctx()
ctx.quote.return_value = [self._make_mock_quote()]
ctx.static_info.return_value = [self._make_mock_static(name_cn="腾讯控股")]
ctx.history_candlesticks_by_offset.return_value = []
quote = fetcher.get_realtime_quote("HK00700")
self.assertIsNotNone(quote)
self.assertEqual(quote.code, "HK00700")
ctx.quote.assert_called_with(["0700.HK"])
class TestSupplementFromLongbridge(unittest.TestCase):
"""Test the _supplement_from_longbridge method in DataFetcherManager."""
def test_merge_fills_missing_fields(self):
"""When yfinance quote is missing volume_ratio/turnover_rate, LB fills them."""
from data_provider.base import DataFetcherManager
yf_quote = UnifiedRealtimeQuote(
code="AAPL",
name="Apple",
source=RealtimeSource.FALLBACK,
price=253.79,
change_pct=2.9,
volume=49549600,
volume_ratio=None,
turnover_rate=None,
pe_ratio=None,
)
lb_quote = UnifiedRealtimeQuote(
code="AAPL",
name="苹果",
source=RealtimeSource.LONGBRIDGE,
price=253.79,
volume_ratio=1.25,
turnover_rate=0.33,
pe_ratio=41.7,
pb_ratio=57.7,
total_mv=4060640000000.0,
)
mock_lb_fetcher = MagicMock()
mock_lb_fetcher.name = "LongbridgeFetcher"
mock_lb_fetcher.get_realtime_quote.return_value = lb_quote
manager = DataFetcherManager(fetchers=[mock_lb_fetcher])
result = manager._supplement_from_longbridge("AAPL", yf_quote)
self.assertIsNotNone(result)
self.assertEqual(result.volume_ratio, 1.25)
self.assertEqual(result.turnover_rate, 0.33)
self.assertEqual(result.pe_ratio, 41.7)
# source should stay as original (yfinance/FALLBACK)
self.assertEqual(result.source, RealtimeSource.FALLBACK)
def test_sole_source_when_yfinance_fails(self):
"""When yfinance returns None, LB acts as sole source."""
from data_provider.base import DataFetcherManager
lb_quote = UnifiedRealtimeQuote(
code="AAPL",
source=RealtimeSource.LONGBRIDGE,
price=253.79,
volume_ratio=1.25,
turnover_rate=0.33,
)
mock_lb_fetcher = MagicMock()
mock_lb_fetcher.name = "LongbridgeFetcher"
mock_lb_fetcher.get_realtime_quote.return_value = lb_quote
manager = DataFetcherManager(fetchers=[mock_lb_fetcher])
result = manager._supplement_from_longbridge("AAPL", None)
self.assertIsNotNone(result)
self.assertEqual(result.source, RealtimeSource.LONGBRIDGE)
self.assertEqual(result.price, 253.79)
if __name__ == "__main__":
unittest.main()