Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,16 @@ const hkSuggestion = {
score: 100,
};

const bseSuggestion = {
canonicalCode: "920493.BJ",
displayCode: "920493",
nameZh: "示例北交所股票",
market: "BSE" as const,
matchType: "exact" as const,
matchField: "code" as const,
score: 100,
};

describe('StockAutocomplete', () => {
const mockOnChange = vi.fn();
const mockOnSubmit = vi.fn();
Expand Down Expand Up @@ -416,6 +426,40 @@ describe('StockAutocomplete', () => {
expect(mockOnChange).toHaveBeenCalledWith('00700');
expect(mockOnSubmit).toHaveBeenCalledWith('00700.HK', '腾讯控股', 'autocomplete');
});

it('submits the highlighted BSE suggestion using the canonical .BJ code', () => {
autocompleteHookImpl = () => ({
query: '',
setQuery: vi.fn(),
suggestions: [bseSuggestion],
isOpen: true,
highlightedIndex: 0,
setHighlightedIndex: vi.fn(),
highlightPrevious: vi.fn(),
highlightNext: vi.fn(),
handleSelect: vi.fn(),
close: vi.fn(),
reset: vi.fn(),
isComposing: false,
setIsComposing: vi.fn(),
runtimeFallback: false,
error: null,
});

render(
<StockAutocomplete
value="920493"
onChange={mockOnChange}
onSubmit={mockOnSubmit}
/>
);

const input = screen.getByDisplayValue('920493');
fireEvent.keyDown(input, { key: 'Enter' });

expect(mockOnChange).toHaveBeenCalledWith('920493');
expect(mockOnSubmit).toHaveBeenCalledWith('920493.BJ', '示例北交所股票', 'autocomplete');
});
});

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

## [3.15.0] - 2026-05-05

Expand Down
2 changes: 1 addition & 1 deletion docs/full-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1156,7 +1156,7 @@ python main.py --serve-only --host 0.0.0.0 --port 8888
| 类型 | 格式 | 示例 |
|------|------|------|
| A股 | 6位数字 | `600519`、`000001`、`300750` |
| 北交所 | 8/4/92 开头 6 位 | `920748`、`838163`、`430047` |
| 北交所 | 8/4/92 开头 6 位,支持 `BJ` 前缀或 `.BJ` 后缀 | `920748`、`BJ920493`、`920493.BJ` |
| 港股 | hk + 5位数字 | `hk00700`、`hk09988` |
| 美股 | 1-5 字母(可选 .X 后缀) | `AAPL`、`TSLA`、`BRK.B` |
| 美股指数 | SPX/DJI/IXIC 等 | `SPX`、`DJI`、`NASDAQ`、`VIX` |
Expand Down
2 changes: 1 addition & 1 deletion docs/full-guide_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1000,7 +1000,7 @@ python main.py --serve-only --host 0.0.0.0 --port 8888
| Type | Format | Examples |
|------|------|------|
| A-shares | 6-digit number | `600519`, `000001`, `300750` |
| BSE (Beijing) | 8/4/92 prefix, 6-digit | `920748`, `838163`, `430047` |
| BSE (Beijing) | 8/4/92 prefix, 6-digit; supports `BJ` prefix or `.BJ` suffix | `920748`, `BJ920493`, `920493.BJ` |
| HK stocks | hk + 5-digit number | `hk00700`, `hk09988` |

### Notes
Expand Down
3 changes: 2 additions & 1 deletion docs/openclaw-skill-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
| 类型 | 格式 | 示例 |
|------|------|------|
| A股 | 6位数字 | `600519`、`000001`、`300750` |
| 北交所 | 8/4/92 开头 6 位 | `920748`、`838163`、`430047` |
| 北交所 | 8/4/92 开头 6 位,支持 `BJ` 前缀或 `.BJ` 后缀 | `920748`、`BJ920493`、`920493.BJ` |
| 港股 | hk + 5位数字 | `hk00700`、`hk09988` |
| 美股 | 1-5 字母(可选 .X 后缀) | `AAPL`、`TSLA`、`BRK.B` |
| 美股指数 | SPX/DJI/IXIC 等 | `SPX`、`DJI`、`NASDAQ`、`VIX` |
Expand Down Expand Up @@ -145,6 +145,7 @@ metadata:
## 股票代码格式

- A股:6位数字(600519、000001)
- 北交所:8/4/92 开头 6 位,支持 BJ 前缀或 .BJ 后缀(920748、BJ920493、920493.BJ)
- 港股:hk + 5位数字(hk00700)
- 美股:1–5 字母(AAPL、TSLA、BRK.B)
- 美股指数:SPX、DJI、IXIC 等
Expand Down
25 changes: 19 additions & 6 deletions src/services/stock_code_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,42 @@
import re
from typing import Optional

from data_provider.base import is_bse_code


# Known exchange prefixes (case-insensitive) and the digit lengths they accept.
# e.g. SH600519 -> 600519, HK00700 -> 00700
_PREFIX_DIGIT_LENS: dict = {
"SH": (6,),
"SZ": (6,),
"SS": (6,),
"BJ": (6,),
"HK": (1, 2, 3, 4, 5),
}

_SUFFIX_DIGIT_LENS: dict = {
".SH": (6,),
".SZ": (6,),
".SS": (6,),
".BJ": (6,),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject non-BSE codes with BJ exchange hints

When a user submits a non-BSE six-digit code with a BJ hint, e.g. 600519.BJ (or BJ600519 via the new prefix entry), this length-only rule makes is_code_like accept it and the analysis API bypasses name resolution; downstream TushareFetcher._convert_stock_code preserves any dotted suffix unchanged, so the request is sent as the invalid 600519.BJ instead of being rejected or treated as Shanghai. The repo already defines BSE ranges in is_bse_code, so the BJ prefix/suffix path should validate those ranges rather than accepting any 6 digits.

Useful? React with 👍 / 👎.

".HK": (1, 2, 3, 4, 5),
}


def _valid_exchange_code(exchange: str, base: str, digit_lens: tuple[int, ...]) -> bool:
if not (base.isdigit() and len(base) in digit_lens):
return False
if exchange == "BJ":
return is_bse_code(base)
return True


def _strip_exchange_prefix(text: str) -> Optional[str]:
"""Strip leading exchange prefix (SH/SZ/HK etc.) and return the bare digits, or None."""
for prefix, digit_lens in _PREFIX_DIGIT_LENS.items():
if text.startswith(prefix):
base = text[len(prefix):]
if base.isdigit() and len(base) in digit_lens:
if _valid_exchange_code(prefix, base, digit_lens):
return base.zfill(5) if prefix == "HK" else base
return None

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

Expand All @@ -57,7 +70,7 @@ def is_code_like(value: str) -> bool:
return True
if re.match(r"^[A-Z]{1,5}(?:\.(?:US|[A-Z]))?$", text):
return True
# Support exchange-prefixed codes: SH600519, SZ000001, HK00700
# Support exchange-prefixed codes: SH600519, SZ000001, BJ920493, HK00700
if _strip_exchange_prefix(text) is not None:
return True
return False
Expand All @@ -68,8 +81,8 @@ def normalize_code(raw: str) -> Optional[str]:

Supports:
- Plain digit codes: 600519, 00700
- Suffix format: 600519.SH, 600519.SZ, 00700.HK
- Prefix format: SH600519, SZ000001, HK00700 (case-insensitive)
- Suffix format: 600519.SH, 600519.SZ, 920493.BJ, 00700.HK
- Prefix format: SH600519, SZ000001, BJ920493, HK00700 (case-insensitive)
- US ticker symbols: AAPL, TSLA
"""
text = raw.strip().upper()
Expand All @@ -82,7 +95,7 @@ def normalize_code(raw: str) -> Optional[str]:
stripped_suffix = _strip_exchange_suffix(text)
if stripped_suffix is not None:
return stripped_suffix
# Support exchange-prefixed codes: SH600519 -> 600519, HK00700 -> 00700
# Support exchange-prefixed codes: SH600519 -> 600519, BJ920493 -> 920493
stripped = _strip_exchange_prefix(text)
if stripped is not None:
return stripped
Expand Down
67 changes: 67 additions & 0 deletions tests/test_analysis_api_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,73 @@ def test_trigger_analysis_accepts_hk_suffix_code_from_autocomplete(self) -> None
notify=True,
)

def test_trigger_analysis_accepts_bse_suffix_code_from_autocomplete(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")

queue = MagicMock()
queue.submit_tasks_batch.return_value = ([], [])

with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue), \
patch("api.v1.endpoints.analysis.resolve_name_to_code") as resolve_mock:
response = trigger_analysis(
request=SimpleNamespace(
stock_code="920493.BJ",
stock_codes=None,
stock_name="示例北交所股票",
original_query="920493",
selection_source="autocomplete",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
),
config=SimpleNamespace(),
)

self.assertEqual(response.status_code, 202)
resolve_mock.assert_not_called()
queue.submit_tasks_batch.assert_called_once_with(
stock_codes=["920493.BJ"],
stock_name="示例北交所股票",
original_query="920493",
selection_source="autocomplete",
report_type="detailed",
force_refresh=False,
notify=True,
)

def test_trigger_analysis_rejects_non_bse_code_with_bj_exchange_hint(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")

for bad_code in ("600519.BJ", "BJ600519"):
with self.subTest(bad_code=bad_code):
queue = MagicMock()

with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue), \
patch("api.v1.endpoints.analysis.resolve_name_to_code") as resolve_mock:
with self.assertRaises(Exception) as exc:
trigger_analysis(
request=SimpleNamespace(
stock_code=bad_code,
stock_codes=None,
stock_name=None,
original_query=bad_code,
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
),
config=SimpleNamespace(),
)

self.assertEqual(exc.exception.status_code, 400)
self.assertEqual(exc.exception.detail["error"], "validation_error")
resolve_mock.assert_not_called()
queue.submit_tasks_batch.assert_not_called()

def test_trigger_analysis_accepts_hk_prefixed_code(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
Expand Down
17 changes: 17 additions & 0 deletions tests/test_name_to_code_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ def test_a_share_5_digits(self):
def test_a_share_6_digits(self):
assert _is_code_like("300750") is True

def test_bse_with_exchange_hint(self):
assert _is_code_like("920493.BJ") is True
assert _is_code_like("BJ920493") is True

def test_bj_exchange_hint_rejects_non_bse_code(self):
assert _is_code_like("600519.BJ") is False
assert _is_code_like("BJ600519") is False

def test_hk_5_digits(self):
assert _is_code_like("00700") is True

Expand Down Expand Up @@ -61,6 +69,14 @@ def test_preserves_valid_a_share(self):
def test_strips_suffix(self):
assert _normalize_code("600519.SH") == "600519"
assert _normalize_code("000001.SZ") == "000001"
assert _normalize_code("920493.BJ") == "920493"

def test_strips_bse_prefix(self):
assert _normalize_code("BJ920493") == "920493"

def test_bj_exchange_hint_rejects_non_bse_code(self):
assert _normalize_code("600519.BJ") is None
assert _normalize_code("BJ600519") is None

def test_preserves_us_stock(self):
assert _normalize_code("AAPL") == "AAPL"
Expand Down Expand Up @@ -99,6 +115,7 @@ class TestResolveNameToCode:
def test_code_like_input_returned_normalized(self):
assert resolve_name_to_code("600519") == "600519"
assert resolve_name_to_code("600519.SH") == "600519"
assert resolve_name_to_code("920493.BJ") == "920493"
assert resolve_name_to_code(" AAPL ") == "AAPL"

def test_local_map_exact_match(self):
Expand Down
6 changes: 6 additions & 0 deletions tests/test_stock_code_bse.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ def test_bse_returns_bj_suffix(self):
self.assertEqual(fetcher._convert_stock_code("838163"), "838163.BJ")
self.assertEqual(fetcher._convert_stock_code("430047"), "430047.BJ")

def test_bse_explicit_exchange_hint_is_preserved(self):
"""BSE prefix/suffix forms should keep the BJ Tushare ts_code."""
fetcher = TushareFetcher()
self.assertEqual(fetcher._convert_stock_code("920493.BJ"), "920493.BJ")
self.assertEqual(fetcher._convert_stock_code("BJ920493"), "920493.BJ")


@unittest.skipIf(not _AKSHARE_IMPORTS_OK, f"akshare fetcher imports failed: {_AKSHARE_IMPORT_ERROR}")
class TestAkshareToSinaTxSymbol(unittest.TestCase):
Expand Down
24 changes: 24 additions & 0 deletions tests/test_stock_code_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ def test_suffix_sh(self):
def test_suffix_sz(self):
assert is_code_like("000001.SZ") is True

def test_suffix_bj(self):
assert is_code_like("920493.BJ") is True

def test_suffix_bj_rejects_non_bse_base(self):
assert is_code_like("600519.BJ") is False

def test_suffix_lowercase(self):
assert is_code_like("600519.sh") is True

Expand Down Expand Up @@ -56,6 +62,12 @@ def test_prefix_sh_lower(self):
def test_prefix_sz(self):
assert is_code_like("SZ000001") is True

def test_prefix_bj(self):
assert is_code_like("BJ920493") is True

def test_prefix_bj_rejects_non_bse_base(self):
assert is_code_like("BJ600519") is False

def test_prefix_hk(self):
assert is_code_like("HK00700") is True

Expand Down Expand Up @@ -104,6 +116,12 @@ def test_suffix_sh_strips(self):
def test_suffix_sz_strips(self):
assert normalize_code("000001.SZ") == "000001"

def test_suffix_bj_strips(self):
assert normalize_code("920493.BJ") == "920493"

def test_suffix_bj_rejects_non_bse_base(self):
assert normalize_code("600519.BJ") is None

def test_suffix_ss_strips(self):
assert normalize_code("600000.SS") == "600000"

Expand Down Expand Up @@ -132,6 +150,12 @@ def test_prefix_sh_lower(self):
def test_prefix_sz(self):
assert normalize_code("SZ000001") == "000001"

def test_prefix_bj(self):
assert normalize_code("BJ920493") == "920493"

def test_prefix_bj_rejects_non_bse_base(self):
assert normalize_code("BJ600519") is None

def test_prefix_hk(self):
assert normalize_code("HK00700") == "00700"

Expand Down
Loading