From a3e1b9e3ba485a7666c29a09275bd176d84a9c4a Mon Sep 17 00:00:00 2001 From: zhulinsen <42829555+ZhuLinsen@users.noreply.github.com> Date: Sat, 4 Jul 2026 11:57:43 +0800 Subject: [PATCH] fix: normalize watchlist separators (#1912) --- api/v1/endpoints/stocks.py | 3 +- .../components/settings/IntelligentImport.tsx | 6 +-- .../__tests__/IntelligentImport.test.tsx | 38 +++++++++++++++++ .../hooks/__tests__/useSystemConfig.test.tsx | 42 +++++++++++++++++++ apps/dsa-web/src/hooks/useSystemConfig.ts | 5 +++ apps/dsa-web/src/locales/settingsHelp.ts | 8 ++-- apps/dsa-web/src/pages/SettingsPage.tsx | 6 +-- .../src/utils/__tests__/stockList.test.ts | 19 +++++++++ apps/dsa-web/src/utils/stockList.ts | 12 ++++++ apps/dsa-web/src/utils/systemConfigI18n.ts | 2 +- docs/CHANGELOG.md | 1 + docs/full-guide.md | 4 +- docs/full-guide_EN.md | 4 +- main.py | 3 +- src/config.py | 5 ++- src/core/config_registry.py | 2 +- src/services/stock_list_parser.py | 23 ++++++++++ src/services/system_config_service.py | 3 +- tests/test_config_env_compat.py | 36 ++++++++++++++++ tests/test_stock_list_parser.py | 22 ++++++++++ tests/test_stock_watchlist_api.py | 23 +++++++++- 21 files changed, 243 insertions(+), 24 deletions(-) create mode 100644 apps/dsa-web/src/utils/__tests__/stockList.test.ts create mode 100644 apps/dsa-web/src/utils/stockList.ts create mode 100644 src/services/stock_list_parser.py create mode 100644 tests/test_stock_list_parser.py diff --git a/api/v1/endpoints/stocks.py b/api/v1/endpoints/stocks.py index b6966ea41..e435704a8 100644 --- a/api/v1/endpoints/stocks.py +++ b/api/v1/endpoints/stocks.py @@ -39,6 +39,7 @@ from src.services.import_parser import ( parse_import_from_text, ) from src.services.stock_service import StockService +from src.services.stock_list_parser import split_stock_list from src.services.system_config_service import SystemConfigService from data_provider.base import normalize_stock_code @@ -58,7 +59,7 @@ def _read_watchlist_codes(service: SystemConfigService) -> list: if item.get("key") == "STOCK_LIST": stock_list_str = str(item.get("value", "")) break - return [c.strip() for c in stock_list_str.split(",") if c.strip()] + return split_stock_list(stock_list_str) def _write_watchlist_codes(service: SystemConfigService, codes: list) -> None: diff --git a/apps/dsa-web/src/components/settings/IntelligentImport.tsx b/apps/dsa-web/src/components/settings/IntelligentImport.tsx index ce061a896..bc4cb9dae 100644 --- a/apps/dsa-web/src/components/settings/IntelligentImport.tsx +++ b/apps/dsa-web/src/components/settings/IntelligentImport.tsx @@ -6,6 +6,7 @@ import { systemConfigApi, SystemConfigConflictError } from '../../api/systemConf import { Badge, Button, InlineAlert } from '../common'; import { useUiLanguage } from '../../contexts/UiLanguageContext'; import type { UiLanguage } from '../../i18n/uiText'; +import { parseStockListValue } from '../../utils/stockList'; const IMG_EXT = ['.jpg', '.jpeg', '.png', '.webp', '.gif']; const IMG_MAX = 5 * 1024 * 1024; // 5MB @@ -114,10 +115,7 @@ export const IntelligentImport: React.FC = ({ const dataFileInputRef = useRef(null); const parseCurrentList = useCallback(() => { - return stockListValue - .split(',') - .map((c) => c.trim()) - .filter(Boolean); + return parseStockListValue(stockListValue); }, [stockListValue]); const addItems = useCallback((newItems: ExtractItem[]) => { diff --git a/apps/dsa-web/src/components/settings/__tests__/IntelligentImport.test.tsx b/apps/dsa-web/src/components/settings/__tests__/IntelligentImport.test.tsx index 5f4db7398..497886dc0 100644 --- a/apps/dsa-web/src/components/settings/__tests__/IntelligentImport.test.tsx +++ b/apps/dsa-web/src/components/settings/__tests__/IntelligentImport.test.tsx @@ -132,4 +132,42 @@ describe('IntelligentImport', () => { }); expect(await screen.findByText('配置已更新,请再次点击「合并到自选股」')).toBeInTheDocument(); }); + + it('normalizes existing mixed separators when merging into watchlist', async () => { + parseImport.mockResolvedValue({ + items: [{ code: 'HK00700', name: 'Tencent', confidence: 'high' }], + codes: [], + }); + update.mockResolvedValue({ success: true }); + + render( + , + ); + + fireEvent.change(screen.getByPlaceholderText('或粘贴 CSV/Excel 复制的文本...'), { + target: { value: 'HK00700' }, + }); + fireEvent.click(screen.getByRole('button', { name: '解析' })); + + await screen.findByText('HK00700'); + + fireEvent.click(screen.getByRole('button', { name: '合并到自选股' })); + + await waitFor(() => { + expect(update).toHaveBeenCalledWith({ + configVersion: 'v1', + maskToken: '******', + reloadNow: true, + items: [{ key: 'STOCK_LIST', value: 'SH600000,SH600519,AAPL,HK00700' }], + }); + }); + await waitFor(() => { + expect(onMerged).toHaveBeenCalledWith('SH600000,SH600519,AAPL,HK00700'); + }); + }); }); diff --git a/apps/dsa-web/src/hooks/__tests__/useSystemConfig.test.tsx b/apps/dsa-web/src/hooks/__tests__/useSystemConfig.test.tsx index 6f509ab0c..d11135e66 100644 --- a/apps/dsa-web/src/hooks/__tests__/useSystemConfig.test.tsx +++ b/apps/dsa-web/src/hooks/__tests__/useSystemConfig.test.tsx @@ -154,6 +154,48 @@ describe('useSystemConfig', () => { expect(result.current.load).toBe(firstLoad); }); + it('normalizes STOCK_LIST separators before saving', async () => { + const savedConfig = { + ...sampleConfig, + items: sampleConfig.items.map((item) => ( + item.key === 'STOCK_LIST' + ? { ...item, value: 'SH600000,SH600519,AAPL' } + : item + )), + }; + + getConfig.mockResolvedValueOnce(sampleConfig); + getConfig.mockResolvedValueOnce(savedConfig); + + const { result } = renderHook(() => useSystemConfig()); + + await act(async () => { + await result.current.load(); + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + act(() => { + result.current.setDraftValue('STOCK_LIST', 'SH600000,SH600519\nAAPL'); + }); + + await act(async () => { + await result.current.save(); + }); + + expect(validate).toHaveBeenCalledWith({ + items: [{ key: 'STOCK_LIST', value: 'SH600000,SH600519,AAPL' }], + }); + expect(update).toHaveBeenCalledWith({ + configVersion: 'v1', + maskToken: '******', + reloadNow: true, + items: [{ key: 'STOCK_LIST', value: 'SH600000,SH600519,AAPL' }], + }); + }); + it('keeps legacy LLM provider fields in save payload without hidden-field migration', async () => { const savedConfig = { ...sampleLlmConfig, diff --git a/apps/dsa-web/src/hooks/useSystemConfig.ts b/apps/dsa-web/src/hooks/useSystemConfig.ts index 23872c293..5feac0574 100644 --- a/apps/dsa-web/src/hooks/useSystemConfig.ts +++ b/apps/dsa-web/src/hooks/useSystemConfig.ts @@ -7,6 +7,7 @@ import type { SystemConfigItem, SystemConfigUpdateItem, } from '../types/systemConfig'; +import { serializeStockListValue } from '../utils/stockList'; type ToastState = { type: 'success'; @@ -52,6 +53,10 @@ function isMultiValueSchema(schema: SystemConfigItem['schema'] | undefined): boo } function normalizeFieldValue(value: string, schema: SystemConfigItem['schema'] | undefined): string { + if ((schema?.key ?? '').toUpperCase() === 'STOCK_LIST') { + return serializeStockListValue(value); + } + if (!isMultiValueSchema(schema)) { return value; } diff --git a/apps/dsa-web/src/locales/settingsHelp.ts b/apps/dsa-web/src/locales/settingsHelp.ts index e24b17b69..3511abb25 100644 --- a/apps/dsa-web/src/locales/settingsHelp.ts +++ b/apps/dsa-web/src/locales/settingsHelp.ts @@ -18,7 +18,7 @@ const settingsHelpZhCN: SettingsHelpMap = { 'settings.base.STOCK_LIST': { title: '自选股列表', summary: '配置需要分析的股票代码列表,是手动分析、定时任务和通知报告的基础输入。', - usage: '多个股票代码使用英文逗号分隔。A 股可直接填写 6 位代码,港股可使用 hk 前缀,美股可填写 ticker。', + usage: '多个股票代码推荐使用英文逗号分隔;从表格或聊天中粘贴时,也会识别中文逗号、顿号、分号、空格和换行,并在保存后规范为英文逗号。', valueNotes: [ '定时模式每次触发前会重新读取当前保存的 STOCK_LIST。', '如果命令行临时传入 --stocks,只影响本次手动运行,不会锁定后续计划任务。', @@ -28,7 +28,7 @@ const settingsHelpZhCN: SettingsHelpMap = { '影响主分析任务、市场报告中的个股范围、通知推送内容和历史报告记录。', ], notes: [ - '股票代码之间不要使用中文逗号。', + '保存后的 STOCK_LIST 会统一写成英文逗号分隔。', '修改后保存配置即可供后续任务读取。', ], }, @@ -1199,14 +1199,14 @@ const settingsHelpEnUS: SettingsHelpMap = { 'settings.base.STOCK_LIST': { title: 'Watchlist', summary: 'Defines the stock codes used by analysis jobs and notification reports.', - usage: 'Separate symbols with commas. A-shares can use six-digit codes, HK stocks can use the hk prefix, and US stocks can use ticker symbols.', + usage: 'English commas are recommended. Pasted Chinese commas, enumeration commas, semicolons, spaces, and newlines are also recognized and normalized to English commas when saved.', valueNotes: [ 'Scheduled mode rereads the saved STOCK_LIST before each run.', 'A temporary --stocks argument only affects that manual run.', 'STOCK_GROUP_N should be a subset of STOCK_LIST and only affects grouped email routing.', ], impact: ['Affects analysis scope, notification content, and saved history reports.'], - notes: ['Use English commas between symbols.', 'Save the setting before later tasks can read it.'], + notes: ['Saved STOCK_LIST values are written with English commas.', 'Save the setting before later tasks can read it.'], }, 'settings.ai_model.GENERATION_BACKEND': { title: 'Analysis Generation Method', diff --git a/apps/dsa-web/src/pages/SettingsPage.tsx b/apps/dsa-web/src/pages/SettingsPage.tsx index 234552256..9271bbb3a 100644 --- a/apps/dsa-web/src/pages/SettingsPage.tsx +++ b/apps/dsa-web/src/pages/SettingsPage.tsx @@ -23,6 +23,7 @@ import { SettingsSectionCard, } from '../components/settings'; import { WEB_BUILD_INFO } from '../utils/constants'; +import { parseStockListValue } from '../utils/stockList'; import { getCategoryDescription } from '../utils/systemConfigI18n'; import type { ConfigValidationIssue, @@ -324,10 +325,7 @@ function getConfigItem(items: SystemConfigItem[], key: string) { } function parseSetupStockList(value: unknown) { - return String(value ?? '') - .split(/[,\n\r;,、\s]+/) - .map((item) => item.trim()) - .filter(Boolean); + return parseStockListValue(String(value ?? '')); } function isEnabledConfigValue(value: unknown) { diff --git a/apps/dsa-web/src/utils/__tests__/stockList.test.ts b/apps/dsa-web/src/utils/__tests__/stockList.test.ts new file mode 100644 index 000000000..782699822 --- /dev/null +++ b/apps/dsa-web/src/utils/__tests__/stockList.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { parseStockListValue, serializeStockListValue } from '../stockList'; + +describe('stockList utils', () => { + it('parses common copy/paste separators', () => { + expect(parseStockListValue('600519,300750 hk00700;AAPL、7203.T\n005930.KS')).toEqual([ + '600519', + '300750', + 'hk00700', + 'AAPL', + '7203.T', + '005930.KS', + ]); + }); + + it('serializes to canonical commas', () => { + expect(serializeStockListValue('600519,300750\nAAPL')).toBe('600519,300750,AAPL'); + }); +}); diff --git a/apps/dsa-web/src/utils/stockList.ts b/apps/dsa-web/src/utils/stockList.ts new file mode 100644 index 000000000..fa9727991 --- /dev/null +++ b/apps/dsa-web/src/utils/stockList.ts @@ -0,0 +1,12 @@ +const STOCK_LIST_SEPARATOR_RE = /[\s,;\uFF0C\u3001\uFF1B]+/; + +export function parseStockListValue(value: string): string[] { + return String(value ?? '') + .split(STOCK_LIST_SEPARATOR_RE) + .map((item) => item.trim()) + .filter(Boolean); +} + +export function serializeStockListValue(value: string): string { + return parseStockListValue(value).join(','); +} diff --git a/apps/dsa-web/src/utils/systemConfigI18n.ts b/apps/dsa-web/src/utils/systemConfigI18n.ts index a734cfd30..25066ab5b 100644 --- a/apps/dsa-web/src/utils/systemConfigI18n.ts +++ b/apps/dsa-web/src/utils/systemConfigI18n.ts @@ -214,7 +214,7 @@ const fieldTitleMap: Record = { }; const fieldDescriptionMap: Record = { - STOCK_LIST: '使用逗号分隔股票代码,例如:600519,300750。', + STOCK_LIST: '推荐使用英文逗号分隔股票代码;中文逗号、顿号、分号、空格和换行会在保存后规范为英文逗号。', TUSHARE_TOKEN: '用于接入 Tushare Pro 数据服务的凭据。', BOCHA_API_KEYS: '用于新闻检索的 Bocha 密钥,支持逗号分隔多个(最高优先级)。', TAVILY_API_KEYS: '用于新闻检索的 Tavily 密钥,支持逗号分隔多个。', diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c93dc7654..d7bc95b4e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - [修复] 修复任务状态接口重建报告动作字段时把合法情绪分 `0` 当成空值的问题,确保低分报告能按评分口径纠正为卖出建议。 - [修复] 修复 Agent 流式回复在未收到完成事件就断开时被显示为“(无内容)”的问题,改为提示流式响应中断并保留用户消息,避免误判为空回答。 - [修复] 修复桌面端 `WEBUI_HOST=*` / `WEBUI_HOST=[::]` 会被原样传给端口探测和后端启动导致无法监听的问题,启动前分别规范化为 `0.0.0.0` / `::`。 +- [改进] `STOCK_LIST` 自选股解析支持中文逗号、顿号、分号、空格和换行等常见粘贴分隔符,运行时、定时热刷新、CLI `--stocks`、Web 设置保存和自选 API 统一识别,并在写回时规范为英文逗号。 diff --git a/docs/full-guide.md b/docs/full-guide.md index ec38d3aab..7e8f25df5 100644 --- a/docs/full-guide.md +++ b/docs/full-guide.md @@ -153,7 +153,7 @@ daily_stock_analysis/ | Secret 名称 | 说明 | 必填 | |------------|------|:----:| -| `STOCK_LIST` | 自选股代码,如 `600519,300750,002594,7203.T,005930.KS` | ✅ | +| `STOCK_LIST` | 自选股代码,如 `600519,300750,002594,7203.T,005930.KS`;推荐使用英文逗号,中文逗号、顿号、分号、空格和换行会被识别并规范为英文逗号 | ✅ | | `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) 针对中文内容特别优化;同一 Key 可用于搜索与 Anspire 大模型网关的兜底示例(是否可用以控制台与账号权限为准) | 推荐 | | `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) 搜索引擎结果补强,适合实时金融新闻 | 推荐 | | `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) 搜索 API(新闻搜索) | 可选 | @@ -1680,7 +1680,7 @@ A: 企业微信/飞书有消息长度限制,系统已自动分段发送。如 A: AkShare 使用爬虫机制,可能被临时限流。系统已配置重试机制,一般等待几分钟后重试即可。 ### Q: 如何添加自选股? -A: 修改 `STOCK_LIST` 环境变量,多个代码用逗号分隔。 +A: 修改 `STOCK_LIST` 环境变量,多个代码推荐用英文逗号分隔。系统也会识别中文逗号、顿号、分号、空格和换行,并在 Web 设置页保存或自选增删后规范为英文逗号。 ### Q: GitHub Actions 没有执行? A: 检查是否启用了 Actions,以及 cron 表达式是否正确(注意是 UTC 时间)。 diff --git a/docs/full-guide_EN.md b/docs/full-guide_EN.md index a74933c6c..0cdc75252 100644 --- a/docs/full-guide_EN.md +++ b/docs/full-guide_EN.md @@ -143,7 +143,7 @@ Go to your forked repo → `Settings` → `Secrets and variables` → `Actions` | Secret Name | Description | Required | |------------|------|:----:| -| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594,7203.T,005930.KS` | ✅ | +| `STOCK_LIST` | Watchlist codes, e.g., `600519,300750,002594,7203.T,005930.KS`; English commas are recommended, while pasted Chinese commas, enumeration commas, semicolons, spaces, and newlines are recognized and normalized to English commas | ✅ | | `ANSPIRE_API_KEYS` | [Anspire AI Search](https://aisearch.anspire.cn/) optimized for Chinese content; the same key can also be used for Anspire LLM fallback scenarios (example model: `Doubao-Seed-2.0-lite`) | Recommended | | `SERPAPI_API_KEYS` | [SerpAPI](https://serpapi.com/baidu-search-api?utm_source=github_daily_stock_analysis) search-engine results for realtime financial news | Recommended | | `TAVILY_API_KEYS` | [Tavily](https://tavily.com/) Search API (for news search) | Optional | @@ -1495,7 +1495,7 @@ A: WeChat Work/Feishu have message length limits, system already auto-segments m A: AkShare uses scraping mechanism, may be temporarily rate-limited. System has retry mechanism configured, usually just wait a few minutes and retry. ### Q: How to add watchlist stocks? -A: Modify `STOCK_LIST` environment variable, separate multiple codes with commas. +A: Modify the `STOCK_LIST` environment variable. English commas are recommended between codes. Chinese commas, enumeration commas, semicolons, spaces, and newlines are also recognized and are normalized to English commas after saving in Web settings or using watchlist add/remove actions. ### Q: GitHub Actions not executing? A: Check if Actions is enabled, and if cron expression is correct (note it's UTC time). diff --git a/main.py b/main.py index f09b34b75..01abf4a87 100644 --- a/main.py +++ b/main.py @@ -68,6 +68,7 @@ from datetime import date, datetime, timezone, timedelta from src.webui_frontend import prepare_webui_frontend_assets from src.config import get_config, Config from src.logging_config import setup_logging +from src.services.stock_list_parser import split_stock_list from src.services.stock_code_utils import resolve_index_stock_code_for_analysis @@ -1283,7 +1284,7 @@ def main() -> int: if args.stocks: stock_codes = [ resolve_index_stock_code_for_analysis(c) - for c in args.stocks.split(',') + for c in split_stock_list(args.stocks) if (c or "").strip() ] logger.info(f"使用命令行指定的股票列表: {stock_codes}") diff --git a/src/config.py b/src/config.py index ffaa77a9d..c41326e4d 100644 --- a/src/config.py +++ b/src/config.py @@ -36,6 +36,7 @@ from src.notification_contracts import ( is_feishu_app_bot_configured, is_feishu_static_configured, ) +from src.services.stock_list_parser import split_stock_list from src.llm.backend_registry import ( AUTO_AGENT_BACKEND_ID, GENERATION_ONLY_BACKEND_IDS, @@ -1253,7 +1254,7 @@ class Config: ) stock_list = [ (c or "").strip().upper() - for c in stock_list_str.split(',') + for c in split_stock_list(stock_list_str) if (c or "").strip() ] @@ -2719,7 +2720,7 @@ class Config: stock_list = [ (c or "").strip().upper() - for c in stock_list_str.split(',') + for c in split_stock_list(stock_list_str) if (c or "").strip() ] diff --git a/src/core/config_registry.py b/src/core/config_registry.py index d60972ea9..ea742710d 100644 --- a/src/core/config_registry.py +++ b/src/core/config_registry.py @@ -87,7 +87,7 @@ WEB_SETTINGS_HIDDEN_FROM_UI = { _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = { "STOCK_LIST": { "title": "Stock List", - "description": "Comma-separated watchlist stock codes.", + "description": "Watchlist stock codes. English commas are recommended; common pasted separators are normalized on save.", "category": "base", "data_type": "array", "ui_control": "textarea", diff --git a/src/services/stock_list_parser.py b/src/services/stock_list_parser.py new file mode 100644 index 000000000..5134d7bc2 --- /dev/null +++ b/src/services/stock_list_parser.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +"""Helpers for parsing the user-facing STOCK_LIST value.""" + +from __future__ import annotations + +import re +from typing import List + +_STOCK_LIST_SEPARATOR_RE = re.compile(r"[\s,;\uFF0C\u3001\uFF1B]+") + + +def split_stock_list(value: str) -> List[str]: + """Split STOCK_LIST values on common copy/paste separators.""" + return [ + item.strip() + for item in _STOCK_LIST_SEPARATOR_RE.split(value or "") + if item.strip() + ] + + +def serialize_stock_list(value: str) -> str: + """Return STOCK_LIST in the canonical comma-separated storage form.""" + return ",".join(split_stock_list(value)) diff --git a/src/services/system_config_service.py b/src/services/system_config_service.py index db5786cbc..8891f54b5 100644 --- a/src/services/system_config_service.py +++ b/src/services/system_config_service.py @@ -76,6 +76,7 @@ from src.notification_contracts import ( from src.notification_noise import validate_notification_timezone from src.notification_sender.gotify_sender import resolve_gotify_message_endpoint from src.notification_sender.ntfy_sender import resolve_ntfy_endpoint +from src.services.stock_list_parser import split_stock_list from src.services.generation_backend_status_service import GenerationBackendStatusService logger = logging.getLogger(__name__) @@ -3666,7 +3667,7 @@ class SystemConfigService: ) def _build_setup_stock_list_check(self, effective_map: Dict[str, str]) -> Dict[str, Any]: - stocks = self._split_csv(effective_map.get("STOCK_LIST") or "") + stocks = split_stock_list(effective_map.get("STOCK_LIST") or "") if stocks: return self._setup_check( "stock_list", diff --git a/tests/test_config_env_compat.py b/tests/test_config_env_compat.py index 440e026e1..12e25a5e9 100644 --- a/tests/test_config_env_compat.py +++ b/tests/test_config_env_compat.py @@ -14,6 +14,26 @@ class ConfigEnvCompatibilityTestCase(unittest.TestCase): def tearDown(self): Config.reset_instance() + @patch("src.config.setup_env") + @patch.object(Config, "_parse_litellm_yaml", return_value=[]) + @patch.object(Config, "_parse_stock_email_groups", return_value=[]) + def test_stock_list_accepts_common_copy_paste_separators( + self, _mock_parse_stock_email_groups, _mock_parse_litellm_yaml, _mock_setup_env + ): + with patch.dict( + os.environ, + { + "STOCK_LIST": "600519,300750 hk00700;AAPL、7203.T\n005930.KS", + }, + clear=True, + ): + config = Config._load_from_env() + + self.assertEqual( + config.stock_list, + ["600519", "300750", "HK00700", "AAPL", "7203.T", "005930.KS"], + ) + @patch("src.config.setup_env") @patch.object(Config, "_parse_litellm_yaml", return_value=[]) def test_load_from_env_reads_tickflow_api_key( @@ -864,6 +884,22 @@ class ConfigEnvCompatibilityTestCase(unittest.TestCase): any(issue.severity == "error" and issue.field == "STOCK_LIST" for issue in issues) ) + def test_refresh_stock_list_accepts_runtime_env_common_separators(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + missing_env_path = Path(temp_dir) / "missing.env" + config = Config(stock_list=["600519"]) + with patch.dict( + os.environ, + { + "ENV_FILE": str(missing_env_path), + "STOCK_LIST": "600519,300750 AAPL", + }, + clear=True, + ): + config.refresh_stock_list() + + self.assertEqual(config.stock_list, ["600519", "300750", "AAPL"]) + def test_parse_report_language_accepts_known_alias_without_warning(self) -> None: with self.assertNoLogs("src.config", level="WARNING"): parsed = Config._parse_report_language("zh-cn") diff --git a/tests/test_stock_list_parser.py b/tests/test_stock_list_parser.py new file mode 100644 index 000000000..2cedb90ab --- /dev/null +++ b/tests/test_stock_list_parser.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +"""Tests for STOCK_LIST separator handling.""" + +from src.services.stock_list_parser import serialize_stock_list, split_stock_list + + +def test_split_stock_list_accepts_common_copy_paste_separators() -> None: + value = "600519,300750 hk00700;AAPL、7203.T\n005930.KS;002594" + + assert split_stock_list(value) == [ + "600519", + "300750", + "hk00700", + "AAPL", + "7203.T", + "005930.KS", + "002594", + ] + + +def test_serialize_stock_list_uses_canonical_commas() -> None: + assert serialize_stock_list("600519,300750\nAAPL") == "600519,300750,AAPL" diff --git a/tests/test_stock_watchlist_api.py b/tests/test_stock_watchlist_api.py index 885a0643d..f74b15a19 100644 --- a/tests/test_stock_watchlist_api.py +++ b/tests/test_stock_watchlist_api.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """Watchlist API regressions for stock-code variant matching.""" -from api.v1.endpoints.stocks import add_to_watchlist, remove_from_watchlist +from api.v1.endpoints.stocks import add_to_watchlist, get_watchlist, remove_from_watchlist from api.v1.schemas.history import WatchlistRequest @@ -64,3 +64,24 @@ def test_watchlist_matching_is_case_insensitive_for_us_tickers() -> None: assert add_response.stock_codes == ["aapl"] assert remove_response.stock_codes == [] assert service.update_calls == [""] + + +def test_watchlist_reads_common_copy_paste_separators() -> None: + service = FakeSystemConfigService("600519,300750 AAPL") + + response = get_watchlist(service=service) + + assert response.stock_codes == ["600519", "300750", "AAPL"] + + +def test_watchlist_add_normalizes_existing_mixed_separators_on_write() -> None: + service = FakeSystemConfigService("600519,300750") + + response = add_to_watchlist( + WatchlistRequest(stock_code="AAPL"), + service=service, + ) + + assert response.stock_codes == ["600519", "300750", "AAPL"] + assert service.stock_list == "600519,300750,AAPL" + assert service.update_calls == ["600519,300750,AAPL"]