diff --git a/dayu/web/README.md b/dayu/web/README.md index 8d3a5e85..d77eb0dc 100644 --- a/dayu/web/README.md +++ b/dayu/web/README.md @@ -25,9 +25,10 @@ `dayu/web/streamlit/` 当前按“组件 + 页面”拆分: -- `components/`:边栏与通用组件(如自选股列表)。 +- `components/`:边栏与通用组件(如自选股列表);自选股支持从 `workspace/portfolio` 经 `FsCompanyMetaRepository.scan_company_meta_inventory()` 一键合并写入 `watchlist.json`(侧边栏同步图标与自选股管理对话框内按钮)。 - `pages/main_page.py`:主功能区入口,组合三大 Tab(财报管理、交互式分析、分析报告)。 - `pages/filing_tab.py`:财报下载与处理页面。 +- `pages/filing/download_form_profile.py`:按 ticker 市场(`try_normalize_ticker`)切换 Web 下载多选为 SEC 表单或 A 股/港股财期(`FY`/`H1`/`Q1`–`Q4`),`download_panel` 消费其选项与默认勾选。 - `pages/chat_tab.py`:交互式分析页面编排(渲染、输入、历史加载、清空会话、流式轮询)。 - `pages/report_tab.py`:分析报告页面。 diff --git a/dayu/web/streamlit/components/sidebar.py b/dayu/web/streamlit/components/sidebar.py index ace26438..93cfa445 100644 --- a/dayu/web/streamlit/components/sidebar.py +++ b/dayu/web/streamlit/components/sidebar.py @@ -11,7 +11,13 @@ import streamlit as st -from dayu.web.streamlit.components.watchlist import WatchlistItem, load_watchlist_items, render_watchlist_manager +from dayu.web.streamlit.components.watchlist import ( + WatchlistItem, + load_watchlist_items, + merge_watchlist_from_portfolio, + reconcile_streamlit_selected_ticker_after_watchlist_change, + render_watchlist_manager, +) def render_sidebar( @@ -30,6 +36,12 @@ def render_sidebar( st.sidebar.title("大禹 Agent") + sync_msg_key = "watchlist_last_sync_message" + if sync_msg_key in st.session_state: + popped = st.session_state.pop(sync_msg_key) + if isinstance(popped, str) and popped.strip(): + st.sidebar.success(popped) + # 工作区信息展示(优化样式) workspace_resolved = workspace_root.resolve() @@ -79,16 +91,33 @@ def render_sidebar( if selected_ticker is not None and not any(item.ticker == selected_ticker for item in watchlist): st.session_state["selected_ticker"] = None - # 自选股标题行:左侧标题,右侧管理按钮(icon 按钮,更小) - col1, col2 = st.sidebar.columns([5, 1], vertical_alignment="center") + # 自选股标题行:左侧标题,右侧管理 / 从 portfolio 同步 + col1, col2, col3 = st.sidebar.columns([4, 1, 1], vertical_alignment="center") with col1: st.markdown("**❤️ 自选股**") - + with col2: if st.button("", key="manage_watchlist_btn", icon=":material/list_alt_add:", type="tertiary", help="管理自选股"): # 调用对话框函数(装饰器会自动处理) render_watchlist_manager(workspace_root) + with col3: + if st.button( + "", + key="sync_watchlist_from_portfolio_btn", + icon=":material/sync:", + type="tertiary", + help="从 workspace/portfolio 合并公司目录到自选股", + ): + try: + _, sync_msg = merge_watchlist_from_portfolio(workspace_root) + st.session_state["watchlist_last_sync_message"] = sync_msg + reconcile_streamlit_selected_ticker_after_watchlist_change(workspace_root) + st.session_state["watchlist_needs_refresh"] = True + st.rerun() + except Exception as exc: + st.sidebar.error(f"从 portfolio 同步失败: {exc}") + # 展示自选股列表 selected_item = None for item in watchlist: diff --git a/dayu/web/streamlit/components/watchlist.py b/dayu/web/streamlit/components/watchlist.py index 943dacca..c6cfd230 100644 --- a/dayu/web/streamlit/components/watchlist.py +++ b/dayu/web/streamlit/components/watchlist.py @@ -2,6 +2,10 @@ 在表格内完成自选股的添加、删除、编辑,保存后写入本地 JSON。 存储路径:workspace/.dayu/streamlit/watchlist.json。 + +支持从工作区 ``portfolio/`` 目录扫描公司文件夹,经 ``dayu.fins.storage`` +盘点元数据后合并进自选股列表(仅新增缺失条目,并可在存在有效 +``meta.json`` 时更新公司名称)。 """ from __future__ import annotations @@ -14,6 +18,26 @@ import pandas as pd import streamlit as st +from dayu.fins.domain.document_models import CompanyMetaInventoryEntry +from dayu.fins.storage.fs_company_meta_repository import FsCompanyMetaRepository +from dayu.fins.ticker_normalization import try_normalize_ticker + +_PORTFOLIO_SYNC_INVALID_META_NAME_SUFFIX = "(公司元数据无效)" + + +@dataclass(frozen=True) +class _PortfolioWatchlistCandidate: + """从 portfolio 盘点得到的单条自选股候选。 + + 属性: + ticker: 写入自选股的股票代码(优先取公司 meta 中的规范 ticker)。 + company_name: 展示用公司名称。 + official: 是否来自有效 ``meta.json``(为 True 时可覆盖已有条目的名称)。 + """ + + ticker: str + company_name: str + official: bool @dataclass(frozen=True) @@ -112,6 +136,210 @@ def save_watchlist_items(workspace_root: Path, items: list[WatchlistItem]) -> No json.dump(payload, f, ensure_ascii=False, indent=2) +def _merge_key_for_watchlist(ticker: str) -> str: + """将 ticker 转为自选股合并用键(归一化后大写,便于跨写法去重)。 + + 参数: + ticker: 原始股票代码或目录名。 + + 返回值: + 非空合并键;输入全空白时返回空串。 + + 异常: + 无。 + """ + + cleaned = ticker.strip() + if not cleaned: + return "" + normalized = try_normalize_ticker(cleaned) + if normalized is not None: + return normalized.canonical.upper() + return cleaned.upper() + + +def _apply_inventory_entry_to_discovery( + discovery: dict[str, _PortfolioWatchlistCandidate], + entry: CompanyMetaInventoryEntry, +) -> None: + """将单条盘点结果写入 discovery 映射(官方 meta 优先于缺失/无效目录)。 + + 参数: + discovery: 合并键到候选条目的可变映射。 + entry: 仓储返回的单条盘点记录。 + + 返回值: + 无。 + + 异常: + 无。 + """ + + if entry.status == "hidden_directory": + return + if entry.status == "available" and entry.company_meta is not None: + meta = entry.company_meta + ticker = meta.ticker.strip() + if not ticker: + return + merge_key = _merge_key_for_watchlist(ticker) + if not merge_key: + return + name = meta.company_name.strip() + discovery[merge_key] = _PortfolioWatchlistCandidate( + ticker=ticker, + company_name=name or ticker, + official=True, + ) + return + + raw_dir = entry.directory_name.strip() + if not raw_dir: + return + normalized = try_normalize_ticker(raw_dir) + ticker_raw = normalized.canonical if normalized is not None else raw_dir.strip() + merge_key = _merge_key_for_watchlist(ticker_raw) + if not merge_key: + return + existing = discovery.get(merge_key) + if existing is not None and existing.official: + return + if entry.status == "missing_meta": + discovery[merge_key] = _PortfolioWatchlistCandidate( + ticker=ticker_raw, + company_name=raw_dir, + official=False, + ) + return + if entry.status == "invalid_meta": + discovery[merge_key] = _PortfolioWatchlistCandidate( + ticker=ticker_raw, + company_name=f"{raw_dir}{_PORTFOLIO_SYNC_INVALID_META_NAME_SUFFIX}", + official=False, + ) + + +def merge_watchlist_from_portfolio(workspace_root: Path) -> tuple[bool, str]: + """从 ``workspace/portfolio`` 扫描公司目录并合并写入自选股 JSON。 + + 使用 ``FsCompanyMetaRepository.scan_company_meta_inventory()`` 枚举目录与 + 公司级 ``meta.json``,不直接拼接路径盲读文件。 + + 合并规则: + - 保留原自选股顺序;若盘点到有效 meta 且规范 ticker 或公司名称变化,则更新该条并刷新 ``updated_at``。 + - 盘点中存在而当前列表无相同合并键的条目,在末尾按合并键字典序追加。 + - 无有效 meta 的目录仍可按文件夹名加入列表(名称可能为目录名或带无效后缀)。 + + 参数: + workspace_root: 工作区根目录。 + + 返回值: + ``(True, 说明文案)``;当前实现不因业务条件返回 ``False``,预留与 UI 一致接口。 + + 异常: + OSError: 仓储初始化或扫描失败时抛出。 + json.JSONDecodeError: 自选股 JSON 损坏时由 ``load_watchlist_items`` 抛出。 + """ + + workspace = workspace_root.resolve() + repo = FsCompanyMetaRepository(workspace) + discovery: dict[str, _PortfolioWatchlistCandidate] = {} + for inv_entry in repo.scan_company_meta_inventory(): + _apply_inventory_entry_to_discovery(discovery, inv_entry) + + previous = load_watchlist_items(workspace) + now = datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + prev_by_key: dict[str, WatchlistItem] = {} + key_order: list[str] = [] + for item in previous: + merge_key = _merge_key_for_watchlist(item.ticker) + if not merge_key: + continue + if merge_key not in prev_by_key: + key_order.append(merge_key) + prev_by_key[merge_key] = item + + merged: list[WatchlistItem] = [] + meta_synced = 0 + for merge_key in key_order: + item = prev_by_key[merge_key] + cand = discovery.get(merge_key) + if cand is None: + merged.append(item) + continue + if cand.official: + if cand.ticker != item.ticker or cand.company_name != item.company_name: + meta_synced += 1 + merged.append( + WatchlistItem( + ticker=cand.ticker, + company_name=cand.company_name, + created_at=item.created_at, + updated_at=now, + ) + ) + else: + merged.append(item) + else: + merged.append(item) + + existing_keys = {_merge_key_for_watchlist(w.ticker) for w in merged if _merge_key_for_watchlist(w.ticker)} + added = 0 + for merge_key in sorted(discovery.keys()): + if merge_key in existing_keys: + continue + cand = discovery[merge_key] + merged.append( + WatchlistItem( + ticker=cand.ticker, + company_name=cand.company_name, + created_at=now, + updated_at=now, + ) + ) + existing_keys.add(merge_key) + added += 1 + + save_watchlist_items(workspace, merged) + parts: list[str] = [f"已写入 {len(merged)} 条自选股。"] + if added: + parts.append(f"从 portfolio 新增 {added} 条。") + if meta_synced: + parts.append(f"按有效 meta 同步 {meta_synced} 条(代码或名称)。") + if not added and not meta_synced: + parts.append("与 portfolio 比对:无新增条目,且无待同步的有效 meta 变更。") + return True, " ".join(parts) + + +def reconcile_streamlit_selected_ticker_after_watchlist_change(workspace_root: Path) -> None: + """在自选股文件变更后,将 ``selected_ticker`` 对齐到同合并键下的规范 ticker。 + + 参数: + workspace_root: 工作区根目录。 + + 返回值: + 无。 + + 异常: + 无:读盘失败时静默跳过。 + """ + + raw = st.session_state.get("selected_ticker") + if not isinstance(raw, str) or not raw.strip(): + return + try: + items = load_watchlist_items(workspace_root) + except Exception: + return + sel_key = _merge_key_for_watchlist(raw) + if not sel_key: + return + for it in items: + if _merge_key_for_watchlist(it.ticker) == sel_key: + st.session_state["selected_ticker"] = it.ticker + return + def _is_cell_empty(val: object) -> bool: """判断表格单元格是否为空(含 NaN、pd.NA、None)。 @@ -337,6 +565,24 @@ def render_watchlist_manager(workspace_root: Path) -> None: st.markdown("在表格中编辑;底部可新增行;删除行即移除该自选股。编辑后检查代码与名称是否正确,然后点击 **保存**。") + sync_col, _sync_spacer = st.columns([1, 3]) + with sync_col: + if st.button( + "从 portfolio 同步", + key="watchlist_sync_portfolio_btn", + help="扫描 workspace/portfolio 下公司目录,合并到自选股(保留已有顺序,可写入新代码并同步有效 meta 中的名称)", + ): + try: + _, sync_msg = merge_watchlist_from_portfolio(workspace_root) + st.session_state["watchlist_last_sync_message"] = sync_msg + reconcile_streamlit_selected_ticker_after_watchlist_change(workspace_root) + if editor_key in st.session_state: + del st.session_state[editor_key] + st.session_state["watchlist_needs_refresh"] = True + st.rerun() + except Exception as exc: + st.error(f"从 portfolio 同步失败: {exc}") + try: previous = load_watchlist_items(workspace_root) except Exception as exc: diff --git a/dayu/web/streamlit/pages/filing/download_form_profile.py b/dayu/web/streamlit/pages/filing/download_form_profile.py new file mode 100644 index 00000000..9cc9fe02 --- /dev/null +++ b/dayu/web/streamlit/pages/filing/download_form_profile.py @@ -0,0 +1,112 @@ +"""财报 Web 下载表单:按 ticker 市场区分 SEC 与 A 股/港股财期选项。 + +纯函数模块,不依赖 Streamlit,供 ``download_panel`` 与单测复用。 + +设计要点:市场判定与 ``dayu-cli download`` / Fins CN 链路一致,依赖 +``try_normalize_ticker`` 的 ``market`` 字段;港股与 A 股共用 +``DEFAULT_FORMS_CN`` 所列财期字面量(与 ``cn_form_utils`` 默认集合一致)。 +""" + +from __future__ import annotations + +from typing import Final, Literal + +from dayu.fins.pipelines.cn_form_utils import DEFAULT_FORMS_CN +from dayu.fins.score_sec_ci import FORM_PROFILES +from dayu.fins.ticker_normalization import try_normalize_ticker + +DownloadFormMarketKind = Literal["sec", "cn_hk"] +"""下载表单所服务的市场分类:美股 SEC 或 A 股/港股财期。""" + +_SEC_FORM_OPTIONS: Final[tuple[str, ...]] = tuple(FORM_PROFILES.keys()) +"""SEC 表单多选固定选项(与 ``FORM_PROFILES`` 键顺序一致)。""" + +_CN_HK_FORM_OPTIONS: Final[tuple[str, ...]] = DEFAULT_FORMS_CN +"""A 股与港股下载链路支持的财期字面量(与 ``cn_form_utils`` 默认集合一致)。""" + +_DEFAULT_SEC_FORMS: Final[tuple[str, ...]] = ("10-K", "10-Q") +_DEFAULT_CN_HK_FORMS: Final[tuple[str, ...]] = ("FY", "H1") + + +def classify_fins_download_form_market(ticker: str) -> DownloadFormMarketKind: + """根据 ticker 判定下载表单应使用 SEC 类型还是 A 股/港股财期类型。 + + 使用 ``try_normalize_ticker`` 解析市场;无法识别时回退为 ``sec``,与 + 历史 Web 默认(美股表单)一致。 + + 参数: + ticker: 股票代码或自选展示用代码。 + + 返回值: + ``\"sec\"`` 或 ``\"cn_hk\"``。 + + 异常: + 无。 + """ + + stripped = ticker.strip() + if not stripped: + return "sec" + normalized = try_normalize_ticker(stripped) + if normalized is None: + return "sec" + if normalized.market in ("CN", "HK"): + return "cn_hk" + return "sec" + + +def fins_download_form_options(market: DownloadFormMarketKind) -> tuple[str, ...]: + """返回指定市场下多选控件可用的表单/财期 token 列表。 + + 参数: + market: ``sec`` 或 ``cn_hk``。 + + 返回值: + 选项元组。 + + 异常: + 无。 + """ + + if market == "cn_hk": + return _CN_HK_FORM_OPTIONS + return _SEC_FORM_OPTIONS + + +def fins_download_default_form_selection(market: DownloadFormMarketKind) -> tuple[str, ...]: + """返回多选控件在未记忆用户选择时的默认勾选集合。 + + 参数: + market: ``sec`` 或 ``cn_hk``。 + + 返回值: + 默认勾选的表单或财期 token 元组。 + + 异常: + 无。 + """ + + if market == "cn_hk": + return _DEFAULT_CN_HK_FORMS + return _DEFAULT_SEC_FORMS + + +def fins_download_form_help_text(market: DownloadFormMarketKind) -> str: + """返回多选控件旁的帮助文案。 + + 参数: + market: ``sec`` 或 ``cn_hk``。 + + 返回值: + 帮助字符串。 + + 异常: + 无。 + """ + + if market == "cn_hk": + return ( + "A 股 / 港股使用财期代号:FY 年报、H1 半年报、Q1–Q4 季报;" + "与美股 SEC 的 10-K/10-Q 等不是同一套标签。" + ) + return "美股等 SEC 注册发行人使用的表单类型(如 10-K 年报、10-Q 季报)。" diff --git a/dayu/web/streamlit/pages/filing/download_panel.py b/dayu/web/streamlit/pages/filing/download_panel.py index c19ad6ff..a959ad3c 100644 --- a/dayu/web/streamlit/pages/filing/download_panel.py +++ b/dayu/web/streamlit/pages/filing/download_panel.py @@ -18,10 +18,15 @@ FinsCommand, FinsCommandName, ) -from dayu.fins.score_sec_ci import FORM_PROFILES from dayu.services.contracts import FinsSubmission, FinsSubmitRequest from dayu.services.protocols import FinsServiceProtocol from dayu.web.streamlit.components.watchlist import WatchlistItem +from dayu.web.streamlit.pages.filing.download_form_profile import ( + classify_fins_download_form_market, + fins_download_default_form_selection, + fins_download_form_help_text, + fins_download_form_options, +) from dayu.web.streamlit.pages.filing.download_progress import ( DownloadQueueEvent, DownloadStatus, @@ -33,7 +38,6 @@ run_download_stream_worker, ) -_DOWNLOAD_DEFAULT_FORM_TYPES: tuple[str, ...] = ("10-K", "10-Q") _DOWNLOAD_DEFAULT_LOOKBACK_YEARS = 3 _DOWNLOAD_RUNTIME_STATE_KEY = "download_runtime_handles" _DOWNLOAD_EVENT_BATCH_LIMIT = 128 @@ -449,12 +453,20 @@ def _render_download_settings( def _render_download_form_fields(ticker: str) -> _DownloadFormValues: """渲染下载设置表单字段并返回用户输入。""" + market = classify_fins_download_form_market(ticker) + form_options = fins_download_form_options(market) + default_forms = fins_download_default_form_selection(market) + label = ( + "选择要下载的财报表单类型(SEC)" + if market == "sec" + else "选择要下载的报告期间(A 股 / 港股)" + ) selected_form_types = st.multiselect( - "选择要下载的财报表单类型", - options=FORM_PROFILES.keys(), - default=list(_DOWNLOAD_DEFAULT_FORM_TYPES), - help="选择需要下载的 SEC 表单类型", - key=f"download_form_types_{ticker}", + label, + options=list(form_options), + default=list(default_forms), + help=fins_download_form_help_text(market), + key=f"download_form_types_{ticker}_{market}", ) today = datetime.date.today() diff --git a/tests/README.md b/tests/README.md index d556a586..9bcac588 100644 --- a/tests/README.md +++ b/tests/README.md @@ -134,6 +134,7 @@ pip install -r requirements.txt - `test_web_routes.py` 还要守住 Web 的客户端错误语义:像 `PromptService.submit()`、`ChatService.resume_pending_turn()` 这类已经在 Service 边界同步抛出的 `ValueError/KeyError`,router 必须映射成对应 `4xx`,且 `/api/chat/resume` 只能在 resume 成功后才创建后台消费任务,不能漏成 `500` 或先受理后失败。 - `test_web_routes.py` 还要守住 `/api/write` 的未支持语义:当 Web 当前不支持在线写作时,route 必须显式返回 `501`,不能再用 `202` / `accepted=true` 伪装成已受理。 - `test_streamlit_watchlist.py` 负责守住 Streamlit 自选股组件的本地持久化与表格合并边界:`workspace/.dayu/streamlit/watchlist.json` 读写、删除/编辑/新增合并、必填校验与 ticker 去重语义不能漂移。 +- `test_download_form_profile.py` 负责守住 Web 财报下载表单市场分流:`classify_fins_download_form_market` 对沪深/港股与美股返回 `cn_hk` / `sec`,且两套选项与默认勾选与 `cn_form_utils` / `FORM_PROFILES` 一致。 - `test_streamlit_chat_utils.py` 负责守住 `chat/utils.py` 的 session/trace 标识生成、流式文本提取与 Markdown 归一、事件折叠与副作用判定等纯函数语义。 - `test_streamlit_chat_stream_runtime.py` 负责守住 `chat/stream_runtime.py` 的首 chunk 超时、chunk 间超时、取消传播、worker 退出后 trailing event 消费与 error 事件收口语义。 - `test_streamlit_chat_tab.py` 负责守住 `chat_tab.py` 中 `ChatMessage` 稳定语义、`load_history_for_ticker` 的历史轮次映射与 fail-soft(空结果/异常)语义,以及 `perform_clear_session_history` 对 `KeyError / Rejected / Stale(含静默重试) / PartiallyApplied / 重试失败` 的 UI 分支语义。 diff --git a/tests/application/test_download_form_profile.py b/tests/application/test_download_form_profile.py new file mode 100644 index 00000000..9950babc --- /dev/null +++ b/tests/application/test_download_form_profile.py @@ -0,0 +1,54 @@ +"""Web 下载表单市场分类与选项单测。""" + +from __future__ import annotations + +import pytest + +from dayu.web.streamlit.pages.filing.download_form_profile import ( + classify_fins_download_form_market, + fins_download_default_form_selection, + fins_download_form_options, +) + + +@pytest.mark.unit +def test_classify_hk_ticker_uses_cn_hk_forms() -> None: + """港股代码应走 A 股/港股财期表单。""" + + assert classify_fins_download_form_market("0700") == "cn_hk" + assert classify_fins_download_form_market("00700.HK") == "cn_hk" + + +@pytest.mark.unit +def test_classify_cn_ticker_uses_cn_hk_forms() -> None: + """沪深代码应走 A 股/港股财期表单。""" + + assert classify_fins_download_form_market("600519") == "cn_hk" + assert classify_fins_download_form_market("000333") == "cn_hk" + + +@pytest.mark.unit +def test_classify_us_ticker_uses_sec_forms() -> None: + """美股代码应走 SEC 表单。""" + + assert classify_fins_download_form_market("AAPL") == "sec" + assert classify_fins_download_form_market("BRK-B") == "sec" + + +@pytest.mark.unit +def test_cn_hk_options_are_fiscal_periods() -> None: + """cn_hk 市场选项为 FY/H1/Q1–Q4。""" + + opts = fins_download_form_options("cn_hk") + assert opts == ("FY", "H1", "Q1", "Q2", "Q3", "Q4") + assert fins_download_default_form_selection("cn_hk") == ("FY", "H1") + + +@pytest.mark.unit +def test_sec_options_include_ten_k() -> None: + """sec 市场选项包含常见 SEC 表单。""" + + opts = fins_download_form_options("sec") + assert "10-K" in opts + assert "10-Q" in opts + assert fins_download_default_form_selection("sec") == ("10-K", "10-Q") diff --git a/tests/application/test_streamlit_watchlist.py b/tests/application/test_streamlit_watchlist.py index 81447dab..75c4648a 100644 --- a/tests/application/test_streamlit_watchlist.py +++ b/tests/application/test_streamlit_watchlist.py @@ -21,6 +21,7 @@ _apply_table_to_storage, _build_final_dataframe, load_watchlist_items, + merge_watchlist_from_portfolio, save_watchlist_items, ) @@ -212,3 +213,129 @@ def test_apply_table_to_storage_preserves_created_at(tmp_path: Path) -> None: assert loaded[0].company_name == "Apple Inc." assert loaded[0].created_at == "2025-01-01T00:00:00+00:00" assert loaded[0].updated_at != "2025-06-01T00:00:00+00:00" + + +@pytest.mark.unit +def test_merge_watchlist_from_portfolio_adds_missing_meta_directory(tmp_path: Path) -> None: + """验证仅有 portfolio 子目录且无 meta 时仍会追加自选股。""" + + ticker_dir = tmp_path / "portfolio" / "002738" + ticker_dir.mkdir(parents=True) + + ok, msg = merge_watchlist_from_portfolio(tmp_path) + assert ok is True + assert "新增" in msg + + loaded = load_watchlist_items(tmp_path) + assert len(loaded) == 1 + assert loaded[0].ticker == "002738" + assert loaded[0].company_name == "002738" + + +@pytest.mark.unit +def test_merge_watchlist_from_portfolio_updates_company_name_from_meta(tmp_path: Path) -> None: + """验证有效 meta.json 存在时同步公司名称与规范 ticker。""" + + meta_payload = { + "company_id": "us-test", + "company_name": "Example Listed Inc.", + "ticker": "EXAM", + "market": "US", + "resolver_version": "test", + "updated_at": "2026-01-01T00:00:00+00:00", + "ticker_aliases": [], + } + ticker_dir = tmp_path / "portfolio" / "exam" + ticker_dir.mkdir(parents=True) + (ticker_dir / "meta.json").write_text( + __import__("json").dumps(meta_payload, ensure_ascii=False), + encoding="utf-8", + ) + + save_watchlist_items( + tmp_path, + [ + WatchlistItem( + ticker="exam", + company_name="Old Name", + created_at="2025-01-01T00:00:00+00:00", + updated_at="2025-01-01T00:00:00+00:00", + ), + ], + ) + + ok, msg = merge_watchlist_from_portfolio(tmp_path) + assert ok is True + assert "同步" in msg + + loaded = load_watchlist_items(tmp_path) + assert len(loaded) == 1 + assert loaded[0].ticker == "EXAM" + assert loaded[0].company_name == "Example Listed Inc." + assert loaded[0].created_at == "2025-01-01T00:00:00+00:00" + + +@pytest.mark.unit +def test_merge_watchlist_from_portfolio_appends_new_after_existing(tmp_path: Path) -> None: + """验证合并时保留原顺序并在末尾追加 portfolio 中新增的代码。""" + + save_watchlist_items( + tmp_path, + [ + WatchlistItem( + ticker="MSFT", + company_name="Microsoft", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ), + ], + ) + + meta_payload = { + "company_id": "us-aapl", + "company_name": "Apple Inc.", + "ticker": "AAPL", + "market": "US", + "resolver_version": "test", + "updated_at": "2026-01-01T00:00:00+00:00", + "ticker_aliases": [], + } + ticker_dir = tmp_path / "portfolio" / "AAPL" + ticker_dir.mkdir(parents=True) + (ticker_dir / "meta.json").write_text( + __import__("json").dumps(meta_payload, ensure_ascii=False), + encoding="utf-8", + ) + + ok, msg = merge_watchlist_from_portfolio(tmp_path) + assert ok is True + assert "新增" in msg + + loaded = load_watchlist_items(tmp_path) + assert [i.ticker for i in loaded] == ["MSFT", "AAPL"] + assert loaded[1].company_name == "Apple Inc." + + +@pytest.mark.unit +def test_merge_watchlist_from_portfolio_noop_when_aligned(tmp_path: Path) -> None: + """portfolio 无额外公司目录时提示对齐且无新增。""" + + (tmp_path / "portfolio").mkdir(parents=True) + save_watchlist_items( + tmp_path, + [ + WatchlistItem( + ticker="MSFT", + company_name="Microsoft", + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ), + ], + ) + + ok, msg = merge_watchlist_from_portfolio(tmp_path) + assert ok is True + assert "无新增" in msg + + loaded = load_watchlist_items(tmp_path) + assert len(loaded) == 1