Files
daily_stock_analysis/tests/test_longbridge_fetcher.py
Nicholas-Xiong 7fa29c7e3e fix(longbridge): 修正 history_candlesticks_by_offset 位置参数顺序,导致 volume_ratio 静默失败 (fixes #2100) (#2109)
* fix(longbridge): 修正 history_candlesticks_by_offset 位置参数顺序 (fixes #2100)

_compute_volume_ratio 调用 ctx.history_candlesticks_by_offset 时把
time(datetime, 第 5 个位置参数)与 count(int, 第 6 个位置参数)值传反——time 槽位
被传入 6, count 槽位被传入 datetime.now()。PyO3 在 PyDateTime 转换层抛
'argument "time": "int" object cannot be converted to "PyDateTime"',
异常被 try/except 吞到 logger.debug, 上层只看到 '未获取到 HK01109 的数据'
DataFetchError, 量比字段恒为 None, 港股实时行情链路上整体退化。

实测发现 Windows 下因 SDK 行为宽容差异未必必复现, 但参数顺序 error 是
SDK 签名 (symbol, period, adjust_type, forward, time, count) 与官方 .pyi
annotation 直接违反的硬 bug——Docker/Linux 下用户长期命中。

修复:
1. 按正确顺序传入 datetime.now() 作为 time、6 作为 count, 在源码注释里写
   清参数语义与旧 bug 模式, 避免未来再次翻位传反。
2. 新增 test_volume_ratio_history_candlesticks_by_offset_arg_order 回归测试,
   对 call_args.args[4]/[5] 做 isinstance + 值断言, 任何回退都会立即 CI 失败。
   旧测试 test_realtime_quote_with_volume_ratio 用 MagicMock 不约束参数顺序,
   是 bug 一直逃过测试的根本原因。

* feat: history_candlesticks_by_offset keyword args cross-SDK compat

- Switch from positional args to keyword args (symbol=, period=,
  adjust_type=, forward=, time=, count=) in _compute_volume_ratio,
  immune to positional signature drift between longbridge 0.2.74
  (forward, time, count) and 4.x (forward, count, time).
- Update test_volume_ratio_history_candlesticks_by_offset_arg_order
  to assert keyword args instead of positional slots.
- Add test_volume_ratio_keyword_args_cross_sdk_compat covering
  4.x positional signature with keyword args.
- Narrow CHANGELOG: keyword args compat + cross-version regression tests.

* docs(changelog): move #2100 fix entry to [Unreleased] + drop 8 stray 3.28.0 entries

[Unreleased] 段此前为空,而 #2100 的修复 entry 被错误地放进已发布的
[3.28.0] - 2026-07-26 段下的 ### 文档 子段中,且伴随 8 条与 #2100 无关
的旧 issue 条目(#2026/#1985/#2051/#2070/#2091 等)也被一并错塞。

按仓库规则修复:
- 把 #2100 的 [修复] 条目(描述:LongbridgeFetcher._compute_volume_ratio
  调用 history_candlesticks_by_offset 位置参数 time/count 传反,导致港股/
  美股实时量比字段恒为 None;改用 adaptive keyword args 兼容 0.2.74 与
  4.x,并补回归测试)作为单条扁平条目写入 [Unreleased] 段;
- 删除 [3.28.0] ### 文档 段下错塞的 9 条无关条目(8 条历史 issue + #2100
  自身),恢复 3.28.0 已发布段原貌;
- 保留 3.28.0 ### 文档 段原有的「修复文档中的失效相对链接」一条。

PR #2109 review feedback from @ZhuLinsen.

---------

Co-authored-by: xxiaoxiong <xxiaoxiong@users.noreply.github.com>
Co-authored-by: xxiaoxiong <xxiaoxiong@nicholasxiong.cn>
2026-07-31 19:59:31 +08:00

813 lines
36 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_volume_ratio_history_candlesticks_by_offset_arg_order(self):
"""Regression for #2100: verify keyword args used in
history_candlesticks_by_offset call, immune to positional signature
drift between longbridge 0.2.74 (Linux: forward, time, count)
and 4.x (Windows/macOS/Python>=3.12: forward, count, time).
Before the fix, `_compute_volume_ratio` invoked
`ctx.history_candlesticks_by_offset(symbol, period, adjust_type, forward, 6, datetime.now())`
— i.e. `time` slot got `6` (int) and `count` slot got `datetime.now()`,
which made the PyO3 binding raise
`argument 'time': 'int' object cannot be converted to 'PyDateTime'`
inside the longbridge SDK, swallowed into DEBUG log and surfaced as
volume_ratio=None.
"""
import types
from datetime import datetime as dt_cls, date as dt_date, timedelta
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
with patch("data_provider.longbridge_fetcher.datetime", wraps=dt_cls) as mocked_dt:
fetcher.get_realtime_quote("AAPL")
ctx.history_candlesticks_by_offset.assert_called_once()
call_kwargs = ctx.history_candlesticks_by_offset.call_args.kwargs
# keyword args 跨 SDK 版本契约兼容:
# 0.2.74 positional signature: (symbol, period, adjust_type, forward, time, count)
# 4.x positional signature: (symbol, period, adjust_type, forward, count, time)
# keyword args 不受位置变化影响
self.assertIn("time", call_kwargs, "keyword arg 'time' must be present")
self.assertIn("count", call_kwargs, "keyword arg 'count' must be present")
self.assertIn("symbol", call_kwargs, "keyword arg 'symbol' must be present")
self.assertIsInstance(call_kwargs["time"], dt_cls,
f"time kwarg got {call_kwargs['time']!r} (type {type(call_kwargs['time']).__name__}); "
"expected datetime — see #2100")
self.assertIsInstance(call_kwargs["count"], int,
f"count kwarg got {call_kwargs['count']!r} (type {type(call_kwargs['count']).__name__}); "
"expected int — see #2100")
self.assertEqual(call_kwargs["count"], 6)
self.assertEqual(call_kwargs["symbol"], "AAPL.US")
mocked_dt.now.assert_called()
def test_volume_ratio_keyword_args_cross_sdk_compat(self):
"""Verify keyword args work regardless of positional signature drift.
Simulates a 4.x-style mock where positional order is
(symbol, period, adjust_type, forward, count, time) — the opposite
of 0.2.74's (symbol, period, adjust_type, forward, time, count).
Keyword args in the production code make both signatures callable
with the same keyword dict.
"""
import types
from datetime import datetime as dt_cls, date as dt_date, timedelta
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
with patch("data_provider.longbridge_fetcher.datetime", wraps=dt_cls):
fetcher.get_realtime_quote("AAPL")
ctx.history_candlesticks_by_offset.assert_called_once()
call_kwargs = ctx.history_candlesticks_by_offset.call_args.kwargs
self.assertEqual(call_kwargs["count"], 6,
"count should be 6 regardless of positional signature version")
self.assertIsInstance(call_kwargs["time"], dt_cls,
"time should be datetime even when 4.x positional order is (forward, count, time)")
self.assertEqual(call_kwargs["symbol"], "AAPL.US")
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()