Skip to content

Commit 79b1402

Browse files
authored
fix: 北交所股票处理差异,异常 (ZhuLinsen#1234) (ZhuLinsen#1235)
* fix(issue-1234): [bug]-北交所股票处理差异,异常
1 parent fedf79a commit 79b1402

10 files changed

Lines changed: 182 additions & 9 deletions

File tree

apps/dsa-web/src/components/StockAutocomplete/__tests__/StockAutocomplete.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@ const hkSuggestion = {
7979
score: 100,
8080
};
8181

82+
const bseSuggestion = {
83+
canonicalCode: "920493.BJ",
84+
displayCode: "920493",
85+
nameZh: "示例北交所股票",
86+
market: "BSE" as const,
87+
matchType: "exact" as const,
88+
matchField: "code" as const,
89+
score: 100,
90+
};
91+
8292
describe('StockAutocomplete', () => {
8393
const mockOnChange = vi.fn();
8494
const mockOnSubmit = vi.fn();
@@ -416,6 +426,40 @@ describe('StockAutocomplete', () => {
416426
expect(mockOnChange).toHaveBeenCalledWith('00700');
417427
expect(mockOnSubmit).toHaveBeenCalledWith('00700.HK', '腾讯控股', 'autocomplete');
418428
});
429+
430+
it('submits the highlighted BSE suggestion using the canonical .BJ code', () => {
431+
autocompleteHookImpl = () => ({
432+
query: '',
433+
setQuery: vi.fn(),
434+
suggestions: [bseSuggestion],
435+
isOpen: true,
436+
highlightedIndex: 0,
437+
setHighlightedIndex: vi.fn(),
438+
highlightPrevious: vi.fn(),
439+
highlightNext: vi.fn(),
440+
handleSelect: vi.fn(),
441+
close: vi.fn(),
442+
reset: vi.fn(),
443+
isComposing: false,
444+
setIsComposing: vi.fn(),
445+
runtimeFallback: false,
446+
error: null,
447+
});
448+
449+
render(
450+
<StockAutocomplete
451+
value="920493"
452+
onChange={mockOnChange}
453+
onSubmit={mockOnSubmit}
454+
/>
455+
);
456+
457+
const input = screen.getByDisplayValue('920493');
458+
fireEvent.keyDown(input, { key: 'Enter' });
459+
460+
expect(mockOnChange).toHaveBeenCalledWith('920493');
461+
expect(mockOnSubmit).toHaveBeenCalledWith('920493.BJ', '示例北交所股票', 'autocomplete');
462+
});
419463
});
420464

421465
describe('runtime boundary', () => {

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
2424
- [文档] 新增设置页配置帮助维护说明,明确帮助元数据字段、首批覆盖范围、事实源和多语言文案同步规则。
2525
- [测试] 补充设置项帮助元数据、API schema、前端弹窗交互测试,并修复 Bot 名称路由与调度时间 provider 测试的离线 CI 稳定性问题。
2626
- [修复] 港股日线跳过不支持港股的内置历史数据源,避免港股代码错配到非港股市场数据。
27+
- [修复] 修正分析 API 对北交所 `BJ` 前缀与 `.BJ` 后缀股票代码的校验,保持前端自动补全与 Tushare `ts_code` 调用格式一致。
2728

2829
## [3.15.0] - 2026-05-05
2930

docs/full-guide.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1156,7 +1156,7 @@ python main.py --serve-only --host 0.0.0.0 --port 8888
11561156
| 类型 | 格式 | 示例 |
11571157
|------|------|------|
11581158
| A股 | 6位数字 | `600519`、`000001`、`300750` |
1159-
| 北交所 | 8/4/92 开头 6 位 | `920748`、`838163`、`430047` |
1159+
| 北交所 | 8/4/92 开头 6 位,支持 `BJ` 前缀或 `.BJ` 后缀 | `920748`、`BJ920493`、`920493.BJ` |
11601160
| 港股 | hk + 5位数字 | `hk00700`、`hk09988` |
11611161
| 美股 | 1-5 字母(可选 .X 后缀) | `AAPL`、`TSLA`、`BRK.B` |
11621162
| 美股指数 | SPX/DJI/IXIC 等 | `SPX`、`DJI`、`NASDAQ`、`VIX` |

docs/full-guide_EN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1000,7 +1000,7 @@ python main.py --serve-only --host 0.0.0.0 --port 8888
10001000
| Type | Format | Examples |
10011001
|------|------|------|
10021002
| A-shares | 6-digit number | `600519`, `000001`, `300750` |
1003-
| BSE (Beijing) | 8/4/92 prefix, 6-digit | `920748`, `838163`, `430047` |
1003+
| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |
10041004
| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |
10051005

10061006
### Notes

docs/openclaw-skill-integration.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575
| 类型 | 格式 | 示例 |
7676
|------|------|------|
7777
| A股 | 6位数字 | `600519``000001``300750` |
78-
| 北交所 | 8/4/92 开头 6 位 | `920748``838163``430047` |
78+
| 北交所 | 8/4/92 开头 6 位,支持 `BJ` 前缀或 `.BJ` 后缀 | `920748``BJ920493``920493.BJ` |
7979
| 港股 | hk + 5位数字 | `hk00700``hk09988` |
8080
| 美股 | 1-5 字母(可选 .X 后缀) | `AAPL``TSLA``BRK.B` |
8181
| 美股指数 | SPX/DJI/IXIC 等 | `SPX``DJI``NASDAQ``VIX` |
@@ -145,6 +145,7 @@ metadata:
145145
## 股票代码格式
146146

147147
- A股:6位数字(600519、000001)
148+
- 北交所:8/4/92 开头 6 位,支持 BJ 前缀或 .BJ 后缀(920748、BJ920493、920493.BJ)
148149
- 港股:hk + 5位数字(hk00700)
149150
- 美股:1–5 字母(AAPL、TSLA、BRK.B)
150151
- 美股指数:SPX、DJI、IXIC 等

src/services/stock_code_utils.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,30 +8,42 @@
88
import re
99
from typing import Optional
1010

11+
from data_provider.base import is_bse_code
12+
1113

1214
# Known exchange prefixes (case-insensitive) and the digit lengths they accept.
1315
# e.g. SH600519 -> 600519, HK00700 -> 00700
1416
_PREFIX_DIGIT_LENS: dict = {
1517
"SH": (6,),
1618
"SZ": (6,),
1719
"SS": (6,),
20+
"BJ": (6,),
1821
"HK": (1, 2, 3, 4, 5),
1922
}
2023

2124
_SUFFIX_DIGIT_LENS: dict = {
2225
".SH": (6,),
2326
".SZ": (6,),
2427
".SS": (6,),
28+
".BJ": (6,),
2529
".HK": (1, 2, 3, 4, 5),
2630
}
2731

2832

33+
def _valid_exchange_code(exchange: str, base: str, digit_lens: tuple[int, ...]) -> bool:
34+
if not (base.isdigit() and len(base) in digit_lens):
35+
return False
36+
if exchange == "BJ":
37+
return is_bse_code(base)
38+
return True
39+
40+
2941
def _strip_exchange_prefix(text: str) -> Optional[str]:
3042
"""Strip leading exchange prefix (SH/SZ/HK etc.) and return the bare digits, or None."""
3143
for prefix, digit_lens in _PREFIX_DIGIT_LENS.items():
3244
if text.startswith(prefix):
3345
base = text[len(prefix):]
34-
if base.isdigit() and len(base) in digit_lens:
46+
if _valid_exchange_code(prefix, base, digit_lens):
3547
return base.zfill(5) if prefix == "HK" else base
3648
return None
3749

@@ -41,7 +53,8 @@ def _strip_exchange_suffix(text: str) -> Optional[str]:
4153
for suffix, digit_lens in _SUFFIX_DIGIT_LENS.items():
4254
if text.endswith(suffix):
4355
base = text[: -len(suffix)].strip()
44-
if base.isdigit() and len(base) in digit_lens:
56+
exchange = suffix.lstrip(".")
57+
if _valid_exchange_code(exchange, base, digit_lens):
4558
return base.zfill(5) if suffix == ".HK" else base
4659
return None
4760

@@ -57,7 +70,7 @@ def is_code_like(value: str) -> bool:
5770
return True
5871
if re.match(r"^[A-Z]{1,5}(?:\.(?:US|[A-Z]))?$", text):
5972
return True
60-
# Support exchange-prefixed codes: SH600519, SZ000001, HK00700
73+
# Support exchange-prefixed codes: SH600519, SZ000001, BJ920493, HK00700
6174
if _strip_exchange_prefix(text) is not None:
6275
return True
6376
return False
@@ -68,8 +81,8 @@ def normalize_code(raw: str) -> Optional[str]:
6881
6982
Supports:
7083
- Plain digit codes: 600519, 00700
71-
- Suffix format: 600519.SH, 600519.SZ, 00700.HK
72-
- Prefix format: SH600519, SZ000001, HK00700 (case-insensitive)
84+
- Suffix format: 600519.SH, 600519.SZ, 920493.BJ, 00700.HK
85+
- Prefix format: SH600519, SZ000001, BJ920493, HK00700 (case-insensitive)
7386
- US ticker symbols: AAPL, TSLA
7487
"""
7588
text = raw.strip().upper()
@@ -82,7 +95,7 @@ def normalize_code(raw: str) -> Optional[str]:
8295
stripped_suffix = _strip_exchange_suffix(text)
8396
if stripped_suffix is not None:
8497
return stripped_suffix
85-
# Support exchange-prefixed codes: SH600519 -> 600519, HK00700 -> 00700
98+
# Support exchange-prefixed codes: SH600519 -> 600519, BJ920493 -> 920493
8699
stripped = _strip_exchange_prefix(text)
87100
if stripped is not None:
88101
return stripped

tests/test_analysis_api_contract.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,73 @@ def test_trigger_analysis_accepts_hk_suffix_code_from_autocomplete(self) -> None
689689
notify=True,
690690
)
691691

692+
def test_trigger_analysis_accepts_bse_suffix_code_from_autocomplete(self) -> None:
693+
if trigger_analysis is None:
694+
self.skipTest("fastapi is not installed in this test environment")
695+
696+
queue = MagicMock()
697+
queue.submit_tasks_batch.return_value = ([], [])
698+
699+
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue), \
700+
patch("api.v1.endpoints.analysis.resolve_name_to_code") as resolve_mock:
701+
response = trigger_analysis(
702+
request=SimpleNamespace(
703+
stock_code="920493.BJ",
704+
stock_codes=None,
705+
stock_name="示例北交所股票",
706+
original_query="920493",
707+
selection_source="autocomplete",
708+
report_type="detailed",
709+
force_refresh=False,
710+
async_mode=True,
711+
notify=True,
712+
),
713+
config=SimpleNamespace(),
714+
)
715+
716+
self.assertEqual(response.status_code, 202)
717+
resolve_mock.assert_not_called()
718+
queue.submit_tasks_batch.assert_called_once_with(
719+
stock_codes=["920493.BJ"],
720+
stock_name="示例北交所股票",
721+
original_query="920493",
722+
selection_source="autocomplete",
723+
report_type="detailed",
724+
force_refresh=False,
725+
notify=True,
726+
)
727+
728+
def test_trigger_analysis_rejects_non_bse_code_with_bj_exchange_hint(self) -> None:
729+
if trigger_analysis is None:
730+
self.skipTest("fastapi is not installed in this test environment")
731+
732+
for bad_code in ("600519.BJ", "BJ600519"):
733+
with self.subTest(bad_code=bad_code):
734+
queue = MagicMock()
735+
736+
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue), \
737+
patch("api.v1.endpoints.analysis.resolve_name_to_code") as resolve_mock:
738+
with self.assertRaises(Exception) as exc:
739+
trigger_analysis(
740+
request=SimpleNamespace(
741+
stock_code=bad_code,
742+
stock_codes=None,
743+
stock_name=None,
744+
original_query=bad_code,
745+
selection_source="manual",
746+
report_type="detailed",
747+
force_refresh=False,
748+
async_mode=True,
749+
notify=True,
750+
),
751+
config=SimpleNamespace(),
752+
)
753+
754+
self.assertEqual(exc.exception.status_code, 400)
755+
self.assertEqual(exc.exception.detail["error"], "validation_error")
756+
resolve_mock.assert_not_called()
757+
queue.submit_tasks_batch.assert_not_called()
758+
692759
def test_trigger_analysis_accepts_hk_prefixed_code(self) -> None:
693760
if trigger_analysis is None:
694761
self.skipTest("fastapi is not installed in this test environment")

tests/test_name_to_code_resolver.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ def test_a_share_5_digits(self):
3333
def test_a_share_6_digits(self):
3434
assert _is_code_like("300750") is True
3535

36+
def test_bse_with_exchange_hint(self):
37+
assert _is_code_like("920493.BJ") is True
38+
assert _is_code_like("BJ920493") is True
39+
40+
def test_bj_exchange_hint_rejects_non_bse_code(self):
41+
assert _is_code_like("600519.BJ") is False
42+
assert _is_code_like("BJ600519") is False
43+
3644
def test_hk_5_digits(self):
3745
assert _is_code_like("00700") is True
3846

@@ -61,6 +69,14 @@ def test_preserves_valid_a_share(self):
6169
def test_strips_suffix(self):
6270
assert _normalize_code("600519.SH") == "600519"
6371
assert _normalize_code("000001.SZ") == "000001"
72+
assert _normalize_code("920493.BJ") == "920493"
73+
74+
def test_strips_bse_prefix(self):
75+
assert _normalize_code("BJ920493") == "920493"
76+
77+
def test_bj_exchange_hint_rejects_non_bse_code(self):
78+
assert _normalize_code("600519.BJ") is None
79+
assert _normalize_code("BJ600519") is None
6480

6581
def test_preserves_us_stock(self):
6682
assert _normalize_code("AAPL") == "AAPL"
@@ -99,6 +115,7 @@ class TestResolveNameToCode:
99115
def test_code_like_input_returned_normalized(self):
100116
assert resolve_name_to_code("600519") == "600519"
101117
assert resolve_name_to_code("600519.SH") == "600519"
118+
assert resolve_name_to_code("920493.BJ") == "920493"
102119
assert resolve_name_to_code(" AAPL ") == "AAPL"
103120

104121
def test_local_map_exact_match(self):

tests/test_stock_code_bse.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ def test_bse_returns_bj_suffix(self):
120120
self.assertEqual(fetcher._convert_stock_code("838163"), "838163.BJ")
121121
self.assertEqual(fetcher._convert_stock_code("430047"), "430047.BJ")
122122

123+
def test_bse_explicit_exchange_hint_is_preserved(self):
124+
"""BSE prefix/suffix forms should keep the BJ Tushare ts_code."""
125+
fetcher = TushareFetcher()
126+
self.assertEqual(fetcher._convert_stock_code("920493.BJ"), "920493.BJ")
127+
self.assertEqual(fetcher._convert_stock_code("BJ920493"), "920493.BJ")
128+
123129

124130
@unittest.skipIf(not _AKSHARE_IMPORTS_OK, f"akshare fetcher imports failed: {_AKSHARE_IMPORT_ERROR}")
125131
class TestAkshareToSinaTxSymbol(unittest.TestCase):

tests/test_stock_code_utils.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ def test_suffix_sh(self):
2727
def test_suffix_sz(self):
2828
assert is_code_like("000001.SZ") is True
2929

30+
def test_suffix_bj(self):
31+
assert is_code_like("920493.BJ") is True
32+
33+
def test_suffix_bj_rejects_non_bse_base(self):
34+
assert is_code_like("600519.BJ") is False
35+
3036
def test_suffix_lowercase(self):
3137
assert is_code_like("600519.sh") is True
3238

@@ -56,6 +62,12 @@ def test_prefix_sh_lower(self):
5662
def test_prefix_sz(self):
5763
assert is_code_like("SZ000001") is True
5864

65+
def test_prefix_bj(self):
66+
assert is_code_like("BJ920493") is True
67+
68+
def test_prefix_bj_rejects_non_bse_base(self):
69+
assert is_code_like("BJ600519") is False
70+
5971
def test_prefix_hk(self):
6072
assert is_code_like("HK00700") is True
6173

@@ -104,6 +116,12 @@ def test_suffix_sh_strips(self):
104116
def test_suffix_sz_strips(self):
105117
assert normalize_code("000001.SZ") == "000001"
106118

119+
def test_suffix_bj_strips(self):
120+
assert normalize_code("920493.BJ") == "920493"
121+
122+
def test_suffix_bj_rejects_non_bse_base(self):
123+
assert normalize_code("600519.BJ") is None
124+
107125
def test_suffix_ss_strips(self):
108126
assert normalize_code("600000.SS") == "600000"
109127

@@ -132,6 +150,12 @@ def test_prefix_sh_lower(self):
132150
def test_prefix_sz(self):
133151
assert normalize_code("SZ000001") == "000001"
134152

153+
def test_prefix_bj(self):
154+
assert normalize_code("BJ920493") == "920493"
155+
156+
def test_prefix_bj_rejects_non_bse_base(self):
157+
assert normalize_code("BJ600519") is None
158+
135159
def test_prefix_hk(self):
136160
assert normalize_code("HK00700") == "00700"
137161

0 commit comments

Comments
 (0)