Files
daily_stock_analysis/tests/test_analysis_api_contract.py
青玉案 d0e66a1dc3 feat: 新闻检索为空时在报告中如实标注 (#2229)
* feat: 新闻检索为空时在报告中如实标注

消息面章节此前是「有内容才渲染」,检索一条没拿到时整段直接消失,
读报告的人无从判断是确实没新闻,还是检索静默失败了(搜索源限流、
未配置可用渠道等)。这把「抓取失败」呈现成了「确实没有新闻」。

- src/analyzer.py: AnalysisResult 新增 news_result_count,默认 None
- src/core/pipeline.py: 把 Step 4 已算好的计数交给结果对象
  (此前只进了 diagnostic context snapshot,报告层拿不到)
- src/notification.py: news_lines 为空且计数为 0 时,渲染明确提示,
  并说明结论未纳入新闻维度证据
- tests: 新增 5 条用例,含两条负例——计数为 None 时不得报警
  (那是未配置搜索渠道,不是失败)、拿到新闻时行为与改动前一致

不触碰任何检索路径,纯展示层增量。

* fix: 把新闻缺失提示放进真实渲染路径,并独立于模型输出判定

按 review 三条意见修正:

P1-1 提示只存在于 generate_daily_report,而正常流程从不调用它——
_send_single_stock_notification 与聚合报告走的是 dashboard / brief /
single_stock。原实现对所有标准 REPORT_TYPE 都不生效。
改为抽出共享判定 _empty_news_disclosure,四个渲染器统一接入。

P2 检索零命中但模型按 schema 写出了 market_sentiment / hot_topics 时,
原 elif 分支被跳过,报告会展示模型生成的情绪判断却隐瞒无新闻证据。
改为独立判定 news_result_count == 0,与模型是否产出文字无关。

P1-2 补 docs/CHANGELOG.md [Unreleased] 条目,并在
docs/data-source-stability.md 的「用户可见提示建议」一节记录该行为,
含 None / 0 / >0 三态语义表。

测试从 5 条增至 10 条,新增覆盖 dashboard、brief、single_stock 三个真实
渲染器,以及「模型有输出但检索为空」这一最糟组合。39 passed

* fix: 把新闻零命中披露覆盖到模板链路与企业微信入口

按 review 指出的 blocker 修正。此前只接了字符串拼接分支,遗漏两类活路径:

1. REPORT_RENDERER_ENABLED=true 时,generate_dashboard_report /
   generate_brief_report / generate_wechat_dashboard 会先 return render(...),
   模板链路一路不渲染披露;
2. generate_wechat_dashboard 的非模板 fallback 从未接入,而 pipeline 在
   企业微信非 brief 场景会直接调用它。

后果是同一份分析结果在部分渠道披露、在另一些渠道沉默。

改法不再逐点打补丁,而是抽出单一事实来源:

- 新增 src/services/empty_news.py 持有判定与中英文案
- src/notification.py 的 _empty_news_disclosure 改为委托该模块
- src/services/report_renderer.py 为每条结果预计算 empty_news_disclosure,
  三个平台模板共用
- templates/report_markdown.j2 / report_brief.j2 / report_wechat.j2 各加渲染分支
- generate_wechat_dashboard 的 fallback 正文接入披露

新增 6 条回归测试:模板链路三个平台各一条、企业微信入口一条,
外加两条负例(未执行检索时模板与企业微信均不得提示)。

本文件测试 10 → 16 全过;全量 5824 passed,9 个既有失败与本 PR 无关
(干净 main 上同样失败,属测试顺序依赖)。

* fix: 修正计数源头的两处缺口(自查发现)

按 review 的 merge-base..HEAD 方法自查全链路,发现此前几轮都只盯着渲染出口,
从未核对计数源头,而源头本身在两条路径上是错的:

1. src/core/pipeline.py: news_result_count 只在 intel_results 非空时赋值,
   搜索服务整体失败(正是所有搜索源限流全挂的场景)时停留在 None,
   语义为「未执行检索」,于是本 PR 想解决的头号场景反而不提示。
   改为检索一发起即置 0。

2. _analyze_with_agent: Agent 模式自行调用 search_stock_news 完成检索,
   却从不回写计数,该路径下零命中永远静默。改为按检索结果回写 0 或实际条数。

渲染层再周全,源头数据不对则全部落空。

新增 2 条测试锁住这两处语义(18 passed,此前 16)。
全量 5826 passed,9 个既有失败与本 PR 无关。

* fix: disclose missing news search configuration

* chore: remove unrelated agent guidance

* test: run all empty news tests directly

* fix: preserve empty news disclosure across reports

* fix: 让 Agent 模式的新闻披露跟随实际消费的证据

原问题:agent_arch=multi 等受支持的 Agent 配置下,报告可能声称「未纳入新闻
面证据」而分析其实用了新闻,或反过来该提示而不提示。

根因:news_result_count 取自 executor.run() 结束后为持久化情报补打的一次
search_stock_news()。真实情报由 IntelAgent 通过 search_comprehensive_intel
取得,两者不等价,因此披露与真实证据链可能相反。

修复点:新增 src/agent/news_evidence.py,以运行期证据作用域收集 Agent 搜索
工具的真实返回条数;搜索渠道不可用为 None(未执行检索),可用则从 0 起步、
拿到多少算多少。pipeline 在 executor.run() 前后开启并读取该作用域,事后的
持久化补查不再回写计数。

回归风险:工具在 ThreadPoolExecutor 中执行,runner.py 以
contextvars.copy_context() 提交,故作用域中必须是可变累加器对象,换成不可变
值会让父线程读不到;已加回归测试锁住该机制。原 test_agent_path_records_count
断言的正是被修复的错误行为,已替换为反向断言。

Refs #2225

* fix: 让新闻披露以实际证据为准而非搜索命中数

原问题:本地已落库的资讯池或社交情绪进入 news_context 参与分析后,报告仍可能
声称「未配置搜索渠道,本次分析未纳入新闻面证据」或「零命中」。

根因:news_context 由三路来源拼成——实时检索、社交情绪(美股)、本地资讯池,
但只有实时检索会更新 news_result_count。披露断言的是「结论有没有用到新闻面
证据」,而计数只是「搜索命中了几条」,两者是不同命题,后两路参与时必然失真。

修复点:AnalysisResult 新增 news_evidence_present,由 news_context 是否非空
得出,pipeline 两条路径共用 src/services/empty_news.news_evidence_present()
这一个判定函数。披露改为先看有无证据;确无证据时才用计数解释原因
(None=未配置渠道,0=检索零命中)。历史重建同步恢复该字段。

回归风险:旧记录没有该字段,按计数回退推断,与该记录当时的报告表现一致,不会
追溯改变旧报告;已有用例锁住。review 只点名了本地资讯池,社交情绪属同一缺陷类,
本次一并修复并加测试。另加源码断言:任一 pipeline 路径改回只传计数即失败。

Refs #2225

* fix: 按来源登记新闻证据,不让零命中占位文本冒充证据

原问题:普通分析链路在「搜索已执行但一条证据都没拿到」时,报告不再显示零命中
披露——正是本 PR 要修的核心场景,反而比改动前更差。

根因:src/search_service.py 的 format_intel_report() 即使所有维度失败或为空,
也会输出「【XX 情报搜索结果】」标题和每个维度的「未找到相关信息」占位文本,
整段永远非空。上一版把拼好的 news_context 整段交给 news_evidence_present()
判定,于是 news_result_count == 0 时 evidence 被翻成 true,披露被吞掉,错误
状态还会经 to_dict() 持久化,继续影响历史、详情 API 与 Web。

修复点:判定改为按来源逐个登记——实时检索的真实命中数、社交情绪内容、本地
资讯池内容,任一为真才算有证据;两条 pipeline 路径都不再传拼好的整段。
news_evidence_present() 的契约随之改为接收各来源,并在文档串里写明为什么不能
传整段。

回归风险:新增反例用真实的 format_intel_report() 产出占位文本(不用 mock),
断言其不得被判成证据、且报告必须出现零命中披露。另有源码断言:谁把整段
news_context 交回判定函数即失败。上一版两条测试实际在保护该缺陷(一条名为
「任何非空 context 都算证据」,一条要求必须传入 news_context),已一并纠正。

Refs #2225

---------

Co-authored-by: Mach-Chan <zz-b240@zz-b240deMacBook-Air.local>
2026-08-22 21:27:04 +08:00

3888 lines
156 KiB
Python

# -*- coding: utf-8 -*-
"""Regression tests for analysis API/report-type contracts."""
import asyncio
from concurrent.futures import Future
from datetime import datetime
import json
import os
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import ANY, MagicMock, patch
_ORIGINAL_ENVIRON = dict(os.environ)
_MODULE_TEMP_DIR = tempfile.TemporaryDirectory()
_MODULE_ENV_FILE = Path(_MODULE_TEMP_DIR.name) / ".env"
_MODULE_ENV_FILE.write_text("STOCK_LIST=600519,000001\n", encoding="utf-8")
os.environ["ENV_FILE"] = str(_MODULE_ENV_FILE)
from tests.litellm_stub import ensure_litellm_stub
ensure_litellm_stub()
try:
from api.app import create_app
from api.v1.endpoints import analysis as analysis_endpoint_module
from api.v1.endpoints.analysis import (
trigger_analysis,
trigger_market_review,
_handle_sync_analysis,
_build_analysis_report,
_load_sync_fundamental_sources,
get_analysis_status,
get_task_list,
)
except Exception: # pragma: no cover - optional dependency environments
create_app = None
analysis_endpoint_module = None
trigger_analysis = None
trigger_market_review = None
_handle_sync_analysis = None
_build_analysis_report = None
_load_sync_fundamental_sources = None
get_analysis_status = None
get_task_list = None
from src.enums import ReportType
from src.config import Config
from src.services.analysis_service import AnalysisService
from src.services.image_stock_extractor import _call_litellm_vision
from src.services.task_queue import AnalysisTaskQueue, TaskInfo as QueueTaskInfo, TaskStatus
def tearDownModule() -> None:
current_test = os.environ.get("PYTEST_CURRENT_TEST")
for key in list(os.environ):
if key == "PYTEST_CURRENT_TEST":
continue
if key not in _ORIGINAL_ENVIRON:
os.environ.pop(key, None)
os.environ.update(_ORIGINAL_ENVIRON)
if current_test is not None:
os.environ["PYTEST_CURRENT_TEST"] = current_test
_MODULE_TEMP_DIR.cleanup()
def _analysis_context_pack_overview() -> dict:
return {
"pack_version": "1.0",
"created_at": "2026-04-10T08:30:00+00:00",
"subject": {
"code": "600519",
"stock_name": "贵州茅台",
"market": "cn",
},
"blocks": [
{
"key": "quote",
"label": "行情",
"status": "available",
"source": "mock",
"warnings": [],
"missing_reasons": [],
},
{
"key": "news",
"label": "新闻",
"status": "missing",
"source": None,
"warnings": [],
"missing_reasons": ["news_context_missing"],
},
],
"counts": {
"available": 1,
"missing": 1,
"not_supported": 0,
"fallback": 0,
"stale": 0,
"estimated": 0,
"partial": 0,
"fetch_failed": 0,
},
"data_quality": {
"overall_score": 88,
"level": "good",
"block_scores": {
"quote": 100,
"daily_bars": 100,
"technical": 100,
"news": 35,
"fundamentals": 100,
"chip": 100,
},
"limitations": [],
},
"warnings": ["news_context_missing"],
"metadata": {
"trigger_source": "api",
"news_result_count": 0,
},
}
def _market_structure_context() -> dict:
return {
"schema_version": "market-structure-v1",
"status": "partial",
"market": "cn",
"market_theme_context": {
"schema_version": "market-theme-v1",
"status": "partial",
"market": "cn",
"active_themes": [{"name": "机器人概念"}],
},
"stock_market_position": {
"schema_version": "stock-market-position-v1",
"status": "partial",
"stock_code": "300024",
"market": "cn",
"primary_theme": {"name": "机器人概念"},
},
}
def _market_phase_summary() -> dict:
return {
"market": "cn",
"phase": "intraday",
"market_local_time": "2026-03-27T10:00:00+08:00",
"session_date": "2026-03-27",
"effective_daily_bar_date": "2026-03-26",
"is_trading_day": True,
"is_market_open_now": True,
"is_partial_bar": True,
"minutes_to_open": None,
"minutes_to_close": 300,
"trigger_source": "api",
"analysis_intent": "auto",
"warnings": ["partial_bar"],
}
class AnalysisApiContractTestCase(unittest.TestCase):
def test_market_review_region_is_authoritative_across_active_task_lifecycle(self) -> None:
if (
trigger_market_review is None
or get_analysis_status is None
or get_task_list is None
or analysis_endpoint_module is None
):
self.skipTest("analysis endpoint helpers unavailable in this environment")
class CapturingExecutor:
def __init__(self) -> None:
self.calls = []
def submit(self, fn, *args, **kwargs):
self.calls.append((fn, args, kwargs))
return Future()
cases = (
("us", None, "us"),
("us", "kr,jp", "jp,kr"),
)
original_queue_instance = AnalysisTaskQueue._instance
try:
for configured_region, request_region, expected_region in cases:
with self.subTest(
configured_region=configured_region,
request_region=request_region,
):
AnalysisTaskQueue._instance = None
queue = AnalysisTaskQueue(max_workers=1)
executor = CapturingExecutor()
queue._executor = executor
events = []
queue._broadcast_event = (
lambda event_type, data: events.append((event_type, data))
)
request = analysis_endpoint_module.MarketReviewRequest(
send_notification=False,
region=request_region,
)
config = Config(market_review_region=configured_region)
report = SimpleNamespace(
report=f"{expected_region} report",
market_review_payload={
"kind": "market_review",
"region": expected_region,
"sections": [],
},
)
with patch.object(
analysis_endpoint_module,
"_try_acquire_market_review_lock",
return_value=object(),
), patch.object(
analysis_endpoint_module,
"_build_market_review_runtime",
return_value=(MagicMock(), MagicMock(), MagicMock()),
), patch(
"src.core.market_review.run_market_review",
return_value=report,
) as run_market_review, patch(
"api.v1.endpoints.analysis.get_task_queue",
return_value=queue,
), patch.object(
analysis_endpoint_module,
"_release_market_review_lock",
):
accepted = trigger_market_review(request=request, config=config)
pending = get_analysis_status(accepted.task_id)
task_list = get_task_list(status=None, limit=20)
queue.update_task_progress(
accepted.task_id,
progress=5,
message="queued",
)
worker, args, kwargs = executor.calls[0]
worker(*args, **kwargs)
completed = get_analysis_status(accepted.task_id)
self.assertEqual(accepted.region, expected_region)
self.assertEqual(pending.status, "pending")
self.assertEqual(pending.region, expected_region)
self.assertEqual(task_list.tasks[0].region, expected_region)
self.assertEqual(completed.status, "completed")
self.assertEqual(completed.region, expected_region)
self.assertEqual(
completed.market_review_payload["region"],
expected_region,
)
self.assertEqual(
run_market_review.call_args.kwargs["override_region"],
expected_region,
)
for event_type in (
"task_created",
"task_progress",
"task_started",
"task_completed",
):
payload = next(
payload
for current_type, payload in events
if current_type == event_type
)
self.assertEqual(payload["region"], expected_region)
finally:
AnalysisTaskQueue._instance = original_queue_instance
def test_trigger_market_review_accepts_background_task(self) -> None:
if trigger_market_review is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
task_queue = MagicMock()
task_queue.submit_background_task.return_value = SimpleNamespace(task_id="market-task-1")
request = analysis_endpoint_module.MarketReviewRequest(send_notification=False)
config = SimpleNamespace(
trading_day_check_enabled=False,
report_language="zh",
market_review_region="cn",
)
lock_token = object()
with patch.object(
analysis_endpoint_module,
"_try_acquire_market_review_lock",
return_value=lock_token,
), patch("api.v1.endpoints.analysis.get_task_queue", return_value=task_queue):
response = trigger_market_review(
request=request,
config=config,
)
self.assertEqual(response.status, "accepted")
self.assertFalse(response.send_notification)
self.assertEqual(response.region, "cn")
self.assertEqual(response.task_id, "market-task-1")
task_queue.submit_background_task.assert_called_once()
args, kwargs = task_queue.submit_background_task.call_args
self.assertTrue(callable(args[0]))
self.assertEqual(kwargs["stock_code"], "market_review")
self.assertEqual(kwargs["stock_name"], "大盘复盘")
self.assertEqual(kwargs["message"], "大盘复盘任务已提交")
self.assertEqual(kwargs["region"], "cn")
def test_trigger_market_review_accepts_request_level_report_language(self) -> None:
if trigger_market_review is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
request = analysis_endpoint_module.MarketReviewRequest(
send_notification=True,
report_language="en",
)
config = SimpleNamespace(trading_day_check_enabled=False, report_language="zh", market_review_region="cn")
lock_token = object()
task_payload: dict[str, object] = {}
runtime_notifier = MagicMock()
runtime_search = MagicMock()
runtime_analyzer = MagicMock()
task_queue = MagicMock()
def _capture_background_task(task_fn, **kwargs):
task_payload["background_task"] = task_fn
return SimpleNamespace(task_id="market-task-1")
task_queue.submit_background_task.side_effect = _capture_background_task
with patch.object(
analysis_endpoint_module,
"_try_acquire_market_review_lock",
return_value=lock_token,
), patch.object(
analysis_endpoint_module,
"_build_market_review_runtime",
return_value=(runtime_notifier, runtime_analyzer, runtime_search),
), patch("src.core.market_review.run_market_review") as run_market_review, patch(
"api.v1.endpoints.analysis.get_task_queue",
return_value=task_queue,
), patch.object(
analysis_endpoint_module,
"_release_market_review_lock",
return_value=None,
):
trigger_market_review(request=request, config=config)
self.assertIn("background_task", task_payload)
task_payload["background_task"]()
call_kwargs = run_market_review.call_args.kwargs
self.assertEqual(call_kwargs["send_notification"], True)
self.assertEqual(call_kwargs["override_region"], "cn")
self.assertEqual(call_kwargs["trigger_source"], "api")
runtime_config = call_kwargs.get("config")
self.assertIsNotNone(runtime_config)
self.assertEqual(getattr(runtime_config, "report_language", None), "en")
self.assertIsNot(runtime_config, config)
def test_trigger_market_review_accepts_camel_case_report_language_alias(self) -> None:
if trigger_market_review is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
request = analysis_endpoint_module.MarketReviewRequest.model_validate({
"send_notification": True,
"reportLanguage": "en",
})
config = SimpleNamespace(trading_day_check_enabled=False, report_language="zh", market_review_region="cn")
task_payload: dict[str, object] = {}
runtime_notifier = MagicMock()
runtime_search = MagicMock()
runtime_analyzer = MagicMock()
task_queue = MagicMock()
def _capture_background_task(task_fn, **kwargs):
task_payload["background_task"] = task_fn
return SimpleNamespace(task_id="market-task-1")
task_queue.submit_background_task.side_effect = _capture_background_task
with patch.object(
analysis_endpoint_module,
"_try_acquire_market_review_lock",
return_value=object(),
), patch.object(
analysis_endpoint_module,
"_build_market_review_runtime",
return_value=(runtime_notifier, runtime_analyzer, runtime_search),
), patch("src.core.market_review.run_market_review") as run_market_review, patch(
"api.v1.endpoints.analysis.get_task_queue",
return_value=task_queue,
), patch.object(
analysis_endpoint_module,
"_release_market_review_lock",
return_value=None,
):
response = trigger_market_review(request=request, config=config)
self.assertEqual(response.status, "accepted")
self.assertIn("background_task", task_payload)
task_payload["background_task"]()
call_kwargs = run_market_review.call_args.kwargs
runtime_config = call_kwargs.get("config")
self.assertEqual(getattr(runtime_config, "report_language", None), "en")
self.assertEqual(call_kwargs["trigger_source"], "api")
def test_trigger_market_review_passes_canonical_request_region_without_mutating_config(self) -> None:
if trigger_market_review is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
request = analysis_endpoint_module.MarketReviewRequest.model_validate({
"send_notification": False,
"region": " KR, jp, KR ",
})
config = SimpleNamespace(report_language="zh", market_review_region="cn")
task_payload: dict[str, object] = {}
task_queue = MagicMock()
def _capture_background_task(task_fn, **kwargs):
task_payload["background_task"] = task_fn
return SimpleNamespace(task_id="market-task-region")
task_queue.submit_background_task.side_effect = _capture_background_task
with patch.object(
analysis_endpoint_module,
"_try_acquire_market_review_lock",
return_value=object(),
), patch.object(
analysis_endpoint_module,
"_build_market_review_runtime",
return_value=(MagicMock(), MagicMock(), MagicMock()),
), patch(
"src.core.market_review.run_market_review",
return_value="report",
) as run_market_review, patch(
"api.v1.endpoints.analysis.get_task_queue",
return_value=task_queue,
), patch.object(
analysis_endpoint_module,
"_release_market_review_lock",
return_value=None,
):
response = trigger_market_review(request=request, config=config)
task_payload["background_task"]()
self.assertEqual(response.task_id, "market-task-region")
self.assertEqual(response.region, "jp,kr")
self.assertEqual(task_queue.submit_background_task.call_args.kwargs["region"], "jp,kr")
self.assertEqual(run_market_review.call_args.kwargs["override_region"], "jp,kr")
self.assertEqual(config.market_review_region, "cn")
def test_market_review_background_uses_runtime_region_when_request_omits_override(self) -> None:
if analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
runtime_config = SimpleNamespace(market_review_region="us")
with patch.object(
analysis_endpoint_module,
"_build_market_review_runtime",
return_value=(MagicMock(), MagicMock(), MagicMock()),
), patch(
"src.core.market_review.run_market_review",
return_value="report",
) as run_market_review, patch.object(
analysis_endpoint_module,
"_release_market_review_lock",
):
analysis_endpoint_module._run_market_review_background(
send_notification=False,
effective_region="us",
config=runtime_config,
)
call_kwargs = run_market_review.call_args.kwargs
self.assertIs(call_kwargs["config"], runtime_config)
self.assertEqual(call_kwargs["override_region"], "us")
self.assertEqual(call_kwargs["config"].market_review_region, "us")
def test_trigger_market_review_rejects_duplicate_submission(self) -> None:
if trigger_market_review is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
task_queue = MagicMock()
request = analysis_endpoint_module.MarketReviewRequest(send_notification=True)
config = SimpleNamespace(
trading_day_check_enabled=False,
report_language="zh",
market_review_region="cn",
)
with patch.object(
analysis_endpoint_module,
"_try_acquire_market_review_lock",
return_value=None,
), patch("api.v1.endpoints.analysis.get_task_queue", return_value=task_queue):
with self.assertRaises(Exception) as ctx:
trigger_market_review(
request=request,
config=config,
)
self.assertEqual(getattr(ctx.exception, "status_code", None), 409)
task_queue.submit_background_task.assert_not_called()
def test_trigger_market_review_rejects_when_shared_lock_is_held(self) -> None:
if trigger_market_review is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
from src.core.market_review_lock import (
release_market_review_lock,
try_acquire_market_review_lock,
)
with tempfile.TemporaryDirectory() as temp_dir:
config = SimpleNamespace(
trading_day_check_enabled=False,
database_path=str(Path(temp_dir) / "stock_analysis.db"),
report_language="zh",
market_review_region="cn",
)
lock_token = try_acquire_market_review_lock(config)
self.assertIsNotNone(lock_token)
task_queue = MagicMock()
try:
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=task_queue):
with self.assertRaises(Exception) as ctx:
trigger_market_review(
request=analysis_endpoint_module.MarketReviewRequest(
send_notification=True
),
config=config,
)
finally:
release_market_review_lock(lock_token)
self.assertEqual(getattr(ctx.exception, "status_code", None), 409)
task_queue.submit_background_task.assert_not_called()
def test_trigger_market_review_submits_even_when_configured_markets_closed(self) -> None:
if trigger_market_review is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
task_queue = MagicMock()
task_queue.submit_background_task.return_value = SimpleNamespace(task_id="market-task-manual")
request = analysis_endpoint_module.MarketReviewRequest(send_notification=True)
config = SimpleNamespace(trading_day_check_enabled=True, market_review_region="cn")
lock_token = object()
with patch(
"src.core.trading_calendar.get_open_markets_today",
return_value=set(),
) as get_open_markets_today, patch(
"src.core.trading_calendar.compute_effective_region",
return_value="",
) as compute_effective_region, patch.object(
analysis_endpoint_module,
"_try_acquire_market_review_lock",
return_value=lock_token,
) as acquire, patch("api.v1.endpoints.analysis.get_task_queue", return_value=task_queue):
response = trigger_market_review(
request=request,
config=config,
)
self.assertEqual(response.status, "accepted")
self.assertEqual(response.task_id, "market-task-manual")
get_open_markets_today.assert_not_called()
compute_effective_region.assert_not_called()
acquire.assert_called_once_with(config)
task_queue.submit_background_task.assert_called_once()
def test_run_market_review_background_uses_configured_pipeline(self) -> None:
if analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
config = SimpleNamespace(
has_search_capability_enabled=lambda: True,
bocha_api_keys=["bocha"],
tavily_api_keys=["tavily"],
anspire_api_keys=["anspire"],
brave_api_keys=["brave"],
serpapi_keys=["serpapi"],
minimax_api_keys=["minimax"],
searxng_base_urls=["http://searxng.local"],
searxng_public_instances_enabled=False,
news_max_age_days=5,
news_strategy_profile="balanced",
gemini_api_key="gemini-key",
openai_api_key=None,
)
runtime_notifier = MagicMock()
runtime_search = MagicMock()
runtime_analyzer = MagicMock()
with patch.object(
analysis_endpoint_module,
"_build_market_review_runtime",
return_value=(runtime_notifier, runtime_analyzer, runtime_search),
), patch("src.core.market_review.run_market_review") as run_market_review:
analysis_endpoint_module._run_market_review_background(
send_notification=False,
effective_region="cn,us",
lock_token=None,
config=config,
)
run_market_review.assert_called_once_with(
notifier=runtime_notifier,
analyzer=runtime_analyzer,
search_service=runtime_search,
config=config,
send_notification=False,
override_region="cn,us",
return_structured=True,
trigger_source="api",
)
def test_market_review_runtime_initializes_analyzer_for_litellm_provider(self) -> None:
if analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
config = SimpleNamespace(
has_search_capability_enabled=lambda: False,
gemini_api_key=None,
openai_api_key=None,
litellm_model="anthropic/claude-sonnet-4-6",
llm_model_list=[],
anthropic_api_keys=["sk-ant-test-value"],
)
with patch("src.notification.NotificationService"), \
patch("src.analyzer.GeminiAnalyzer") as analyzer_cls:
analyzer_cls.return_value.is_available.return_value = True
_, analyzer, search_service = analysis_endpoint_module._build_market_review_runtime(config)
analyzer_cls.assert_called_once_with(config=config)
self.assertIs(analyzer, analyzer_cls.return_value)
self.assertIsNone(search_service)
def test_run_market_review_background_returns_non_empty_result_payload(self) -> None:
if analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
runtime_notifier = MagicMock()
runtime_search = MagicMock()
runtime_analyzer = MagicMock()
market_review_config = SimpleNamespace()
with patch.object(
analysis_endpoint_module,
"_build_market_review_runtime",
return_value=(runtime_notifier, runtime_analyzer, runtime_search),
), patch("src.core.market_review.run_market_review", return_value="report") as run_market_review:
result = analysis_endpoint_module._run_market_review_background(
send_notification=False,
effective_region="cn",
lock_token=None,
config=market_review_config,
)
self.assertEqual(result, {"result": "report", "region": "cn"})
run_market_review.assert_called_once_with(
notifier=runtime_notifier,
analyzer=runtime_analyzer,
search_service=runtime_search,
config=market_review_config,
send_notification=False,
override_region="cn",
return_structured=True,
trigger_source="api",
)
def test_run_market_review_uses_request_scoped_config_language(self) -> None:
from src.core.market_review import run_market_review
global_config = SimpleNamespace(report_language="zh", market_review_region="cn")
scoped_config = SimpleNamespace(report_language="en", market_review_region="cn")
notifier = MagicMock()
notifier.save_report_to_file.return_value = "market_review.md"
notifier.is_available.return_value = False
review_result = SimpleNamespace(
report="Market review body",
market_light_snapshot={},
structured_payload={
"kind": "market_review",
"language": "en",
"sections": [{"key": "summary", "title": "Summary", "markdown": "Market review body"}],
},
)
market_analyzer = MagicMock()
market_analyzer.run_daily_review_with_snapshot.return_value = review_result
with patch("src.core.market_review.get_config", return_value=global_config) as get_config_mock, \
patch("src.core.market_review.MarketAnalyzer", return_value=market_analyzer) as market_analyzer_cls, \
patch("src.core.market_review._persist_market_review_history") as persist:
result = run_market_review(
notifier=notifier,
search_service=MagicMock(),
send_notification=False,
return_structured=True,
config=scoped_config,
)
get_config_mock.assert_not_called()
market_analyzer_cls.assert_called_once()
self.assertIs(market_analyzer_cls.call_args.kwargs["config"], scoped_config)
self.assertEqual(result.market_review_payload["language"], "en")
self.assertEqual(persist.call_args.kwargs["config"].report_language, "en")
def test_get_analysis_status_returns_market_review_report_from_queue(self) -> None:
if get_analysis_status is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
queue = MagicMock()
queue.get_task.return_value = SimpleNamespace(
task_id="market-task-1",
region="cn,us",
stock_code="market_review",
stock_name="大盘复盘",
status=analysis_endpoint_module.TaskStatusEnum.COMPLETED,
progress=100,
result={
"result": "市场复盘报告示例文本",
"market_review_payload": {
"kind": "market_review",
"region": "cn,us",
"sections": [],
},
},
error=None,
original_query=None,
selection_source=None,
analysis_phase="auto",
)
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
status = get_analysis_status("market-task-1")
self.assertEqual(status.status, "completed")
self.assertEqual(status.market_review_report, "市场复盘报告示例文本")
self.assertEqual(status.market_review_payload["kind"], "market_review")
self.assertEqual(status.market_review_payload["region"], "cn,us")
self.assertEqual(status.region, "cn,us")
self.assertIsNone(status.result)
def test_get_analysis_status_accepts_cancel_states_from_queue(self) -> None:
if get_analysis_status is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
for task_status in (
analysis_endpoint_module.TaskStatusEnum.CANCEL_REQUESTED,
analysis_endpoint_module.TaskStatusEnum.CANCELLED,
):
with self.subTest(task_status=task_status.value):
queue = MagicMock()
queue.get_task.return_value = SimpleNamespace(
task_id=f"task-{task_status.value}",
region=None,
trace_id=f"trace-{task_status.value}",
stock_code="600519",
stock_name="贵州茅台",
status=task_status,
progress=42,
result=None,
error=None,
original_query=None,
selection_source=None,
analysis_phase="auto",
skills=[],
)
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
status = get_analysis_status(f"task-{task_status.value}")
self.assertEqual(status.status, task_status.value)
self.assertEqual(status.progress, 42)
self.assertIsNone(status.result)
def test_get_analysis_status_normalizes_completed_queue_result_contract(self) -> None:
if get_analysis_status is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
created_at = datetime(2026, 5, 21, 17, 40, 0)
queue = MagicMock()
queue.get_task.return_value = SimpleNamespace(
task_id="task-queue-1",
region=None,
stock_code="600519",
stock_name="贵州茅台",
status=analysis_endpoint_module.TaskStatusEnum.COMPLETED,
progress=100,
result={
"stock_code": "600519",
"stock_name": "贵州茅台",
"report": {
"meta": {"query_id": "task-queue-1", "stock_code": "600519"},
"summary": {"analysis_summary": "summary", "operation_advice": "不建议买入"},
},
},
error=None,
original_query=None,
selection_source=None,
analysis_phase="auto",
created_at=created_at,
completed_at=datetime(2026, 5, 21, 17, 45, 0),
)
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
status = get_analysis_status("task-queue-1")
self.assertEqual(status.status, "completed")
self.assertIsNotNone(status.result)
self.assertEqual(status.result.query_id, "task-queue-1")
self.assertEqual(status.result.stock_code, "600519")
self.assertEqual(status.result.stock_name, "贵州茅台")
self.assertEqual(status.result.created_at, created_at.isoformat())
self.assertEqual(
status.result.report["summary"]["analysis_summary"],
"summary",
)
self.assertEqual(status.result.report["summary"]["operation_advice"], "不建议买入")
self.assertEqual(status.result.report["summary"]["action"], "avoid")
self.assertEqual(status.result.report["summary"]["action_label"], "回避")
def test_get_analysis_status_prefers_raw_result_action_over_summary_action(self) -> None:
if get_analysis_status is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
created_at = datetime(2026, 5, 21, 17, 40, 0)
queue = MagicMock()
queue.get_task.return_value = SimpleNamespace(
task_id="task-queue-action-conflict",
region=None,
stock_code="600519",
stock_name="贵州茅台",
status=analysis_endpoint_module.TaskStatusEnum.COMPLETED,
progress=100,
result={
"stock_code": "600519",
"stock_name": "贵州茅台",
"report": {
"meta": {
"query_id": "task-queue-action-conflict",
"stock_code": "600519",
"report_type": "detailed",
"report_language": "zh",
},
"summary": {
"analysis_summary": "summary",
"operation_advice": "持有观察",
"action": "buy",
},
"details": {
"raw_result": {
"operation_advice": "持有观察",
"action": "watch",
"report_language": "zh",
},
},
},
},
error=None,
original_query=None,
selection_source=None,
analysis_phase="auto",
created_at=created_at,
completed_at=datetime(2026, 5, 21, 17, 45, 0),
)
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
status = get_analysis_status("task-queue-action-conflict")
self.assertEqual(status.status, "completed")
self.assertIsNotNone(status.result)
self.assertEqual(status.result.report["summary"]["action"], "watch")
self.assertEqual(status.result.report["summary"]["action_label"], "观望")
def test_get_analysis_status_preserves_zero_sentiment_score_when_aligning_action(self) -> None:
if get_analysis_status is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
created_at = datetime(2026, 5, 21, 17, 40, 0)
queue = MagicMock()
queue.get_task.return_value = SimpleNamespace(
task_id="task-queue-zero-score",
region=None,
stock_code="600519",
stock_name="贵州茅台",
status=analysis_endpoint_module.TaskStatusEnum.COMPLETED,
progress=100,
result={
"stock_code": "600519",
"stock_name": "贵州茅台",
"report": {
"meta": {
"query_id": "task-queue-zero-score",
"stock_code": "600519",
"report_type": "detailed",
"report_language": "zh",
},
"summary": {
"analysis_summary": "趋势显著恶化",
"operation_advice": "持有",
"sentiment_score": 0,
},
"details": {
"raw_result": {
"operation_advice": "持有",
"report_language": "zh",
},
},
},
},
error=None,
original_query=None,
selection_source=None,
analysis_phase="auto",
created_at=created_at,
completed_at=datetime(2026, 5, 21, 17, 45, 0),
)
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
status = get_analysis_status("task-queue-zero-score")
self.assertEqual(status.status, "completed")
self.assertIsNotNone(status.result)
self.assertEqual(status.result.report["summary"]["sentiment_score"], 0)
self.assertEqual(status.result.report["summary"]["action"], "sell")
self.assertEqual(status.result.report["summary"]["action_label"], "卖出")
def test_get_analysis_status_preserves_zero_sentiment_score_when_enriching_report(self) -> None:
if get_analysis_status is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
created_at = datetime(2026, 5, 21, 17, 40, 0)
queue = MagicMock()
queue.get_task.return_value = SimpleNamespace(
task_id="task-queue-zero-score-enriched",
region=None,
stock_code="600519",
stock_name="贵州茅台",
status=analysis_endpoint_module.TaskStatusEnum.COMPLETED,
progress=100,
result={
"stock_code": "600519",
"stock_name": "贵州茅台",
"report": {
"meta": {
"query_id": "task-queue-zero-score-enriched",
"stock_code": "600519",
"report_type": "detailed",
"report_language": "zh",
},
"summary": {
"analysis_summary": "趋势显著恶化",
"operation_advice": "持有",
"sentiment_score": 0,
},
"details": {
"raw_result": {
"operation_advice": "持有",
"report_language": "zh",
},
},
},
},
error=None,
original_query=None,
selection_source=None,
analysis_phase="auto",
created_at=created_at,
completed_at=datetime(2026, 5, 21, 17, 45, 0),
)
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue), \
patch(
"api.v1.endpoints.analysis._load_sync_fundamental_sources",
return_value=({}, None, None),
):
status = get_analysis_status("task-queue-zero-score-enriched")
self.assertEqual(status.status, "completed")
self.assertIsNotNone(status.result)
self.assertEqual(status.result.report["summary"]["sentiment_score"], 0)
self.assertEqual(status.result.report["summary"]["action"], "sell")
self.assertEqual(status.result.report["summary"]["action_label"], "卖出")
def test_get_analysis_status_enriches_in_memory_market_structure_from_raw_result(self) -> None:
if get_analysis_status is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
market_structure = _market_structure_context()
created_at = datetime(2026, 5, 21, 17, 40, 0)
queue = MagicMock()
queue.get_task.return_value = SimpleNamespace(
task_id="task-queue-market-structure-raw",
region=None,
stock_code="300024",
stock_name="机器人",
status=analysis_endpoint_module.TaskStatusEnum.COMPLETED,
progress=100,
result={
"stock_code": "300024",
"stock_name": "机器人",
"report": {
"meta": {
"query_id": "task-queue-market-structure-raw",
"stock_code": "300024",
"report_type": "detailed",
"report_language": "zh",
},
"summary": {"analysis_summary": "summary"},
"details": {"news_summary": "news"},
},
},
error=None,
original_query=None,
selection_source=None,
analysis_phase="auto",
created_at=created_at,
completed_at=datetime(2026, 5, 21, 17, 45, 0),
)
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue), \
patch(
"api.v1.endpoints.analysis._load_sync_fundamental_sources",
return_value=(
None,
None,
{
"model_used": "test-model",
"report_language": "zh",
"market_structure_context": market_structure,
},
),
) as load_sources:
status = get_analysis_status("task-queue-market-structure-raw")
self.assertEqual(status.status, "completed")
self.assertIsNotNone(status.result)
self.assertEqual(
status.result.report["details"]["market_structure"]["market_theme_context"]["active_themes"][0]["name"],
"机器人概念",
)
self.assertEqual(
status.result.report["details"]["raw_result"]["market_structure_context"]["market_theme_context"]["active_themes"][0]["name"],
"机器人概念",
)
self.assertNotIn(
"raw_result",
status.result.report["details"]["raw_result"],
)
load_sources.assert_called_once_with(
query_id="task-queue-market-structure-raw",
stock_code="300024",
)
def test_get_analysis_status_enriches_in_memory_market_structure_without_history_snapshot(self) -> None:
if get_analysis_status is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
market_structure = _market_structure_context()
service = AnalysisService()
task_result = service._build_analysis_response(
SimpleNamespace(
code="300024",
name="机器人",
current_price=999.9,
change_pct=1.1,
model_used="test-model",
analysis_summary="summary",
operation_advice="持有",
trend_prediction="震荡",
sentiment_score=80,
news_summary="news",
technical_analysis="tech",
fundamental_analysis="fundamental",
risk_warning="risk",
market_structure_context=market_structure,
to_dict=lambda: {
"analysis_summary": "summary",
"operation_advice": "持有",
"trend_prediction": "震荡",
"sentiment_score": 80,
"report_language": "zh",
"news_summary": "news",
"technical_analysis": "tech",
"fundamental_analysis": "fundamental",
"risk_warning": "risk",
"market_structure_context": market_structure,
},
),
"task-in-memory-no-history",
report_type="detailed",
)
created_at = datetime(2026, 5, 21, 17, 40, 0)
queue = MagicMock()
queue.get_task.return_value = SimpleNamespace(
task_id="task-in-memory-no-history",
region=None,
stock_code="300024",
stock_name="机器人",
status=analysis_endpoint_module.TaskStatusEnum.COMPLETED,
progress=100,
result=task_result,
error=None,
original_query=None,
selection_source=None,
analysis_phase="auto",
created_at=created_at,
completed_at=datetime(2026, 5, 21, 17, 45, 0),
)
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue), \
patch(
"api.v1.endpoints.analysis._load_sync_fundamental_sources",
return_value=(None, None, None),
):
status = get_analysis_status("task-in-memory-no-history")
self.assertEqual(status.status, "completed")
self.assertIsNotNone(status.result)
self.assertEqual(
status.result.report["details"]["market_structure"]["market_theme_context"]["active_themes"][0]["name"],
"机器人概念",
)
def test_get_analysis_status_preserves_queue_report_created_at_when_enriching(self) -> None:
if get_analysis_status is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
created_at = datetime(2026, 5, 21, 17, 40, 0)
queue = MagicMock()
queue.get_task.return_value = SimpleNamespace(
task_id="task-queue-2",
region=None,
stock_code="600519",
stock_name="贵州茅台",
status=analysis_endpoint_module.TaskStatusEnum.COMPLETED,
progress=100,
result={
"stock_code": "600519",
"stock_name": "贵州茅台",
"report": {
"meta": {"query_id": "task-queue-2", "stock_code": "600519"},
"summary": {"analysis_summary": "summary"},
},
},
error=None,
original_query=None,
selection_source=None,
analysis_phase="auto",
created_at=created_at,
completed_at=datetime(2026, 5, 21, 17, 45, 0),
)
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue), \
patch(
"api.v1.endpoints.analysis._load_sync_fundamental_sources",
return_value=({}, None, None),
):
status = get_analysis_status("task-queue-2")
self.assertEqual(status.status, "completed")
self.assertIsNotNone(status.result)
self.assertEqual(status.result.created_at, created_at.isoformat())
self.assertEqual(
status.result.report["meta"]["created_at"],
created_at.isoformat(),
)
def test_run_market_review_background_raises_when_report_is_empty(self) -> None:
if analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
runtime_notifier = MagicMock()
runtime_search = MagicMock()
runtime_analyzer = MagicMock()
with patch.object(
analysis_endpoint_module,
"_build_market_review_runtime",
return_value=(runtime_notifier, runtime_analyzer, runtime_search),
), patch("src.core.market_review.run_market_review", return_value=None):
with self.assertRaisesRegex(RuntimeError, "大盘复盘未返回可持久化报告"):
analysis_endpoint_module._run_market_review_background(
send_notification=False,
effective_region="cn",
lock_token=None,
config=SimpleNamespace(),
)
def test_run_market_review_background_releases_lock_on_runtime_build_failure(self) -> None:
if analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
lock_token = object()
with patch.object(
analysis_endpoint_module,
"_build_market_review_runtime",
side_effect=RuntimeError("runtime init failed"),
), patch.object(
analysis_endpoint_module,
"_release_market_review_lock",
) as release_market_review_lock:
with self.assertRaises(RuntimeError):
analysis_endpoint_module._run_market_review_background(
send_notification=False,
effective_region="cn",
lock_token=lock_token,
config=SimpleNamespace(),
)
release_market_review_lock.assert_called_once_with(lock_token)
def test_run_market_review_background_runtime_build_failure_marks_task_failed(self) -> None:
if analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
class _SyncExecutor:
def submit(self, fn, *args, **kwargs):
future = Future()
try:
future.set_result(fn(*args, **kwargs))
except Exception as exc: # pragma: no cover - exercised via assert below
future.set_exception(exc)
return future
queue = AnalysisTaskQueue(max_workers=1)
queue._executor = _SyncExecutor()
with patch.object(
analysis_endpoint_module,
"_build_market_review_runtime",
side_effect=RuntimeError("runtime init failed"),
), patch.object(analysis_endpoint_module, "_release_market_review_lock") as release_market_review_lock:
task = queue.submit_background_task(
lambda: analysis_endpoint_module._run_market_review_background(
send_notification=False,
effective_region="cn",
lock_token=object(),
config=SimpleNamespace(),
),
stock_code="market_review",
stock_name="大盘复盘",
message="大盘复盘任务已提交",
)
task_info = queue.get_task(task.task_id)
self.assertIsNotNone(task_info)
self.assertEqual(task_info.status, TaskStatus.FAILED)
self.assertEqual(task_info.error, "runtime init failed")
self.assertEqual(task_info.message, "任务失败: runtime init failed")
release_market_review_lock.assert_called_once()
def test_get_analysis_status_completed_db_snapshot_preserves_zero_change_pct(self) -> None:
if get_analysis_status is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
mock_queue = MagicMock()
mock_queue.get_task.return_value = None
mock_db = MagicMock()
mock_db.get_analysis_history.return_value = [
SimpleNamespace(
id=1,
code="600519",
name="贵州茅台",
report_type="detailed",
raw_result={"report_language": "zh", "model_used": "test-model"},
context_snapshot={
"enhanced_context": {
"realtime": {
"price": 1234.5,
"change_pct": 0.0,
"change_60d": 12.3,
}
},
"realtime_quote_raw": {"price": 1234.5, "change_pct": 9.9},
},
sentiment_score=80,
operation_advice="持有",
trend_prediction="看多",
analysis_summary="summary",
ideal_buy=None,
secondary_buy=None,
stop_loss=None,
take_profit=None,
created_at=None,
)
]
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=mock_queue), \
patch("src.storage.DatabaseManager.get_instance", return_value=mock_db):
result = get_analysis_status("task-1")
self.assertEqual(result.status, "completed")
self.assertEqual(result.result.report["meta"]["current_price"], 1234.5)
self.assertEqual(result.result.report["meta"]["change_pct"], 0.0)
def test_get_analysis_status_returns_market_review_report_from_db(self) -> None:
if get_analysis_status is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
mock_queue = MagicMock()
mock_queue.get_task.return_value = None
mock_db = MagicMock()
mock_db.get_analysis_history.return_value = [
SimpleNamespace(
id=10,
code="MARKET",
name="大盘复盘",
report_type="market_review",
raw_result={"raw_response": "# 🎯 大盘复盘\n\n复盘正文"},
context_snapshot={
"market_review_region": "jp,kr",
"market_review_payload": {
"kind": "market_review",
"region": "jp,kr",
"sections": [],
},
},
news_content="复盘正文",
created_at=None,
)
]
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=mock_queue), \
patch("src.storage.DatabaseManager.get_instance", return_value=mock_db):
result = get_analysis_status("market-task-1")
self.assertEqual(result.status, "completed")
self.assertEqual(result.market_review_report, "# 🎯 大盘复盘\n\n复盘正文")
self.assertEqual(result.region, "jp,kr")
self.assertEqual(result.market_review_payload["region"], "jp,kr")
self.assertIsNone(result.result)
def test_get_analysis_status_completed_db_snapshot_reads_change_pct_from_raw_when_price_present(self) -> None:
if get_analysis_status is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
mock_queue = MagicMock()
mock_queue.get_task.return_value = None
mock_db = MagicMock()
mock_db.get_analysis_history.return_value = [
SimpleNamespace(
id=2,
code="AAPL",
name="Apple",
report_type="detailed",
raw_result={"report_language": "en", "model_used": "test-model"},
context_snapshot={
"enhanced_context": {
"realtime": {
"price": 180.35,
"change_pct": None,
"change_60d": None,
}
},
"realtime_quote_raw": {"price": 180.35, "pct_chg": -1.25},
},
sentiment_score=72,
operation_advice="Hold",
trend_prediction="Neutral",
analysis_summary="summary",
ideal_buy=None,
secondary_buy=None,
stop_loss=None,
take_profit=None,
created_at=None,
)
]
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=mock_queue), \
patch("src.storage.DatabaseManager.get_instance", return_value=mock_db):
result = get_analysis_status("task-2")
self.assertEqual(result.status, "completed")
self.assertEqual(result.result.report["meta"]["current_price"], 180.35)
self.assertEqual(result.result.report["meta"]["change_pct"], -1.25)
def test_get_analysis_status_completed_db_snapshot_does_not_use_change_60d_as_intraday_change(self) -> None:
if get_analysis_status is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
mock_queue = MagicMock()
mock_queue.get_task.return_value = None
mock_db = MagicMock()
mock_db.get_analysis_history.return_value = [
SimpleNamespace(
id=3,
code="MSFT",
name="Microsoft",
report_type="detailed",
raw_result={"report_language": "en", "model_used": "test-model"},
context_snapshot={
"enhanced_context": {
"realtime": {
"price": 412.6,
"change_pct": None,
"change_60d": 14.8,
}
},
"realtime_quote_raw": {"price": 412.6},
},
sentiment_score=70,
operation_advice="Hold",
trend_prediction="Neutral",
analysis_summary="summary",
ideal_buy=None,
secondary_buy=None,
stop_loss=None,
take_profit=None,
created_at=None,
)
]
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=mock_queue), \
patch("src.storage.DatabaseManager.get_instance", return_value=mock_db):
result = get_analysis_status("task-3")
self.assertEqual(result.status, "completed")
self.assertEqual(result.result.report["meta"]["current_price"], 412.6)
self.assertIsNone(result.result.report["meta"]["change_pct"])
def test_report_type_full_maps_to_full_pipeline_mode(self) -> None:
service = object.__new__(AnalysisService)
pipeline_instance = MagicMock()
pipeline_instance.process_single_stock.return_value = object()
with patch("src.config.get_config", return_value=SimpleNamespace()), \
patch("src.core.pipeline.StockAnalysisPipeline", return_value=pipeline_instance), \
patch.object(AnalysisService, "_build_analysis_response", return_value={"stock_code": "600519"}):
result = AnalysisService.analyze_stock(service, "600519", report_type="full", query_id="q1")
self.assertEqual(result, {"stock_code": "600519"})
self.assertEqual(
pipeline_instance.process_single_stock.call_args.kwargs["report_type"],
ReportType.FULL,
)
def test_analysis_service_passes_request_skills_to_pipeline(self) -> None:
service = object.__new__(AnalysisService)
pipeline_instance = MagicMock()
pipeline_instance.process_single_stock.return_value = object()
request_skills = ["growth_quality"]
with patch("src.config.get_config", return_value=SimpleNamespace()), \
patch("src.core.pipeline.StockAnalysisPipeline", return_value=pipeline_instance) as pipeline_cls, \
patch.object(AnalysisService, "_build_analysis_response", return_value={"stock_code": "600519"}):
result = AnalysisService.analyze_stock(
service,
"600519",
report_type="full",
query_id="q1",
skills=request_skills,
)
self.assertEqual(result, {"stock_code": "600519"})
self.assertEqual(pipeline_cls.call_args.kwargs["analysis_skills"], request_skills)
def test_report_type_full_is_preserved_in_response_metadata(self) -> None:
service = AnalysisService()
pipeline_instance = MagicMock()
pipeline_instance.process_single_stock.return_value = SimpleNamespace(
code="600519",
name="贵州茅台",
current_price=1234.56,
change_pct=1.23,
model_used="test-model",
analysis_summary="summary",
operation_advice="hold",
trend_prediction="up",
sentiment_score=80,
news_summary="news",
technical_analysis="tech",
fundamental_analysis="fundamental",
risk_warning="risk",
get_sniper_points=lambda: {},
)
with patch("src.config.get_config", return_value=SimpleNamespace()), \
patch("src.core.pipeline.StockAnalysisPipeline", return_value=pipeline_instance):
result = service.analyze_stock("600519", report_type="full", query_id="q1", send_notification=False)
self.assertEqual(result["report"]["meta"]["report_type"], "full")
def test_analysis_service_returns_none_and_records_last_error_for_unsuccessful_pipeline_result(self) -> None:
service = AnalysisService()
pipeline_instance = MagicMock()
pipeline_instance.process_single_stock.return_value = SimpleNamespace(
success=False,
error_message="LLM stream interrupted",
)
with patch("src.config.get_config", return_value=SimpleNamespace()), \
patch("src.core.pipeline.StockAnalysisPipeline", return_value=pipeline_instance):
result = service.analyze_stock("600519", report_type="detailed", query_id="q1", send_notification=False)
self.assertIsNone(result)
self.assertEqual(service.last_error, "LLM stream interrupted")
def test_handle_sync_analysis_uses_service_last_error_for_failed_pipeline_result(self) -> None:
if _handle_sync_analysis is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
service_instance = MagicMock()
service_instance.analyze_stock.return_value = None
service_instance.last_error = "LLM stream interrupted"
with patch("src.services.analysis_service.AnalysisService", return_value=service_instance):
with self.assertRaises(Exception) as ctx:
_handle_sync_analysis(
"600519",
SimpleNamespace(
report_type="detailed",
force_refresh=False,
notify=True,
analysis_phase="auto",
),
)
self.assertEqual(ctx.exception.status_code, 500)
self.assertEqual(
ctx.exception.detail,
{
"error": "analysis_failed",
"message": "LLM stream interrupted",
},
)
def test_handle_sync_analysis_response_exposes_overview(self) -> None:
if _handle_sync_analysis is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
overview = _analysis_context_pack_overview()
phase_summary = _market_phase_summary()
service_instance = MagicMock()
service_instance.analyze_stock.return_value = {
"stock_code": "600519",
"stock_name": "贵州茅台",
"report": {
"meta": {"stock_code": "600519", "report_language": "zh"},
"summary": {"analysis_summary": "summary"},
"strategy": {},
"details": {"news_summary": "news"},
},
}
with patch("uuid.uuid4", return_value=SimpleNamespace(hex="q-sync-overview")), \
patch("src.services.analysis_service.AnalysisService", return_value=service_instance), \
patch(
"api.v1.endpoints.analysis._load_sync_fundamental_sources",
return_value=(
{
"enhanced_context": {"code": "600519"},
"analysis_context_pack_overview": overview,
"market_phase_summary": phase_summary,
},
None,
None,
),
):
result = _handle_sync_analysis(
"600519",
SimpleNamespace(
report_type="detailed",
force_refresh=False,
notify=True,
skills=None,
analysis_phase="intraday",
),
)
self.assertEqual(
service_instance.analyze_stock.call_args.kwargs["analysis_phase"],
"intraday",
)
details = result.report["details"]
self.assertEqual(result.report["meta"]["market_phase_summary"]["phase"], "intraday")
self.assertEqual(
details["analysis_context_pack_overview"]["metadata"]["trigger_source"],
"api",
)
self.assertEqual(
details["analysis_context_pack_overview"]["data_quality"]["overall_score"],
88,
)
self.assertNotIn("analysis_context_pack_overview", details["context_snapshot"])
self.assertNotIn("market_phase_summary", details["context_snapshot"])
def test_handle_sync_analysis_restores_market_structure_from_raw_result_snapshot(self) -> None:
if _handle_sync_analysis is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
market_structure = _market_structure_context()
service_instance = MagicMock()
service_instance.analyze_stock.return_value = {
"stock_code": "300024",
"stock_name": "机器人",
"report": {
"meta": {"stock_code": "300024", "report_language": "zh"},
"summary": {"analysis_summary": "summary"},
"strategy": {},
"details": {"news_summary": "news"},
},
}
with patch("uuid.uuid4", return_value=SimpleNamespace(hex="q-sync-market-structure")), \
patch("src.services.analysis_service.AnalysisService", return_value=service_instance), \
patch(
"api.v1.endpoints.analysis._load_sync_fundamental_sources",
return_value=(
None,
None,
{
"model_used": "test-model",
"report_language": "zh",
"market_structure_context": market_structure,
},
),
):
result = _handle_sync_analysis(
"300024",
SimpleNamespace(
report_type="detailed",
force_refresh=False,
notify=True,
skills=None,
analysis_phase="intraday",
),
)
self.assertEqual(
result.report["details"]["market_structure"]["market_theme_context"]["active_themes"][0]["name"],
"机器人概念",
)
def test_handle_sync_analysis_carries_market_structure_from_service_without_fallback(self) -> None:
if _handle_sync_analysis is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
market_structure = _market_structure_context()
service = AnalysisService()
service_result = service._build_analysis_response(
SimpleNamespace(
code="300024",
name="机器人",
current_price=999.9,
change_pct=1.1,
model_used="test-model",
analysis_summary="summary",
operation_advice="持有",
trend_prediction="震荡",
sentiment_score=80,
news_summary="news",
technical_analysis="tech",
fundamental_analysis="fundamental",
risk_warning="risk",
market_structure_context=market_structure,
to_dict=lambda: {
"analysis_summary": "summary",
"operation_advice": "持有",
"trend_prediction": "震荡",
"sentiment_score": 80,
"report_language": "zh",
"news_summary": "news",
"technical_analysis": "tech",
"fundamental_analysis": "fundamental",
"risk_warning": "risk",
"market_structure_context": market_structure,
},
),
"q-sync-no-history",
report_type="detailed",
)
service_instance = MagicMock()
service_instance.analyze_stock.return_value = service_result
with patch("uuid.uuid4", return_value=SimpleNamespace(hex="q-sync-no-history")), \
patch("src.services.analysis_service.AnalysisService", return_value=service_instance), \
patch(
"api.v1.endpoints.analysis._load_sync_fundamental_sources",
return_value=(None, None, None),
):
result = _handle_sync_analysis(
"300024",
SimpleNamespace(
report_type="detailed",
force_refresh=False,
notify=True,
skills=None,
analysis_phase="intraday",
),
)
self.assertIsNotNone(result)
self.assertEqual(
result.report["details"]["market_structure"]["market_theme_context"]["active_themes"][0]["name"],
"机器人概念",
)
self.assertEqual(
result.report["details"]["raw_result"]["market_structure_context"]["market_theme_context"]["active_themes"][0]["name"],
"机器人概念",
)
self.assertNotIn(
"raw_result",
result.report["details"]["raw_result"],
)
def test_build_analysis_response_localizes_placeholder_stock_name_for_english(self) -> None:
service = AnalysisService()
result = service._build_analysis_response(
SimpleNamespace(
code="AAPL",
name="股票AAPL",
current_price=180.35,
change_pct=1.04,
model_used="test-model",
analysis_summary="Momentum remains constructive.",
operation_advice="Buy",
trend_prediction="Bullish",
sentiment_score=78,
news_summary="news",
technical_analysis="tech",
fundamental_analysis="fundamental",
risk_warning="risk",
report_language="en",
get_sniper_points=lambda: {},
),
"q1",
report_type="full",
)
self.assertEqual(result["stock_name"], "Unnamed Stock")
self.assertEqual(result["report"]["meta"]["stock_name"], "Unnamed Stock")
def test_build_analysis_response_does_not_use_model_news_summary_as_retrieval_evidence(self) -> None:
service = AnalysisService()
result = service._build_analysis_response(
SimpleNamespace(
code="600519",
name="贵州茅台",
current_price=1234.56,
change_pct=1.23,
model_used="test-model",
analysis_summary="summary",
operation_advice="hold",
trend_prediction="up",
sentiment_score=80,
news_summary="model generated news summary",
technical_analysis="tech",
fundamental_analysis="fundamental",
risk_warning="risk",
get_sniper_points=lambda: {},
),
"q1",
report_type="full",
)
news_component = result["diagnostic_summary"]["components"]["news"]
self.assertEqual(news_component["status"], "unknown")
self.assertEqual(
result["report"]["details"]["empty_news_disclosure"],
"⚠️ 未配置搜索渠道,本次分析未纳入新闻面证据。",
)
def test_build_analysis_response_includes_market_phase_summary_from_result_snapshot(self) -> None:
service = AnalysisService()
phase_summary = _market_phase_summary()
result = service._build_analysis_response(
SimpleNamespace(
code="600519",
name="贵州茅台",
current_price=1234.56,
change_pct=1.23,
model_used="test-model",
analysis_summary="summary",
operation_advice="hold",
trend_prediction="up",
sentiment_score=80,
news_summary="news",
technical_analysis="tech",
fundamental_analysis="fundamental",
risk_warning="risk",
diagnostic_context_snapshot={"market_phase_summary": phase_summary},
get_sniper_points=lambda: {},
),
"q1",
report_type="full",
)
self.assertEqual(
result["report"]["meta"]["market_phase_summary"]["phase"],
"intraday",
)
def test_build_analysis_response_includes_market_structure_in_raw_result(self) -> None:
service = AnalysisService()
market_structure = _market_structure_context()
def _raw_result() -> dict:
return {
"analysis_summary": "summary",
"operation_advice": "持有",
"trend_prediction": "震荡",
"sentiment_score": 80,
"report_language": "zh",
"news_summary": "news",
"technical_analysis": "tech",
"fundamental_analysis": "fundamental",
"risk_warning": "risk",
"market_structure_context": market_structure,
}
result = service._build_analysis_response(
SimpleNamespace(
code="300024",
name="机器人",
current_price=999.9,
change_pct=1.01,
model_used="test-model",
analysis_summary="summary",
operation_advice="持有",
trend_prediction="震荡",
sentiment_score=80,
news_summary="news",
technical_analysis="tech",
fundamental_analysis="fundamental",
risk_warning="risk",
market_structure_context=market_structure,
to_dict=_raw_result,
),
"q-build-response",
report_type="detailed",
)
self.assertEqual(
result["report"]["details"]["raw_result"]["market_structure_context"]["market_theme_context"]["active_themes"][0]["name"],
"机器人概念",
)
def test_analysis_service_passes_analysis_phase_to_pipeline(self) -> None:
service = AnalysisService()
pipeline_instance = MagicMock()
pipeline_instance.process_single_stock.return_value = SimpleNamespace(
success=True,
code="600519",
name="贵州茅台",
current_price=1234.56,
change_pct=1.23,
model_used="test-model",
analysis_summary="summary",
operation_advice="hold",
trend_prediction="up",
sentiment_score=80,
news_summary="news",
technical_analysis="tech",
fundamental_analysis="fundamental",
risk_warning="risk",
get_sniper_points=lambda: {},
)
with patch("src.config.get_config", return_value=SimpleNamespace()), patch(
"src.core.pipeline.StockAnalysisPipeline",
return_value=pipeline_instance,
) as pipeline_cls:
result = service.analyze_stock(
"600519",
report_type="detailed",
send_notification=False,
analysis_phase="postmarket",
)
self.assertIsNotNone(result)
self.assertEqual(pipeline_cls.call_args.kwargs["analysis_phase"], "postmarket")
def test_build_analysis_report_extracts_fundamental_fields_from_snapshot(self) -> None:
if _build_analysis_report is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
report = _build_analysis_report(
report_data={
"meta": {},
"summary": {},
"strategy": {},
"details": {"news_summary": "news"},
},
query_id="q1",
stock_code="600519",
stock_name="贵州茅台",
context_snapshot={
"enhanced_context": {
"fundamental_context": {
"earnings": {
"data": {
"financial_report": {"report_date": "2025-12-31", "revenue": 1000},
"dividend": {"ttm_dividend_yield_pct": 2.5},
}
}
}
}
},
fallback_fundamental_payload=None,
)
self.assertEqual(report.details.financial_report["report_date"], "2025-12-31")
self.assertEqual(report.details.dividend_metrics["ttm_dividend_yield_pct"], 2.5)
def test_build_analysis_report_derives_decision_action_fields(self) -> None:
if _build_analysis_report is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
report = _build_analysis_report(
report_data={
"meta": {"report_type": "detailed", "report_language": "zh"},
"summary": {
"analysis_summary": "等待确认",
"operation_advice": "不建议买入",
"trend_prediction": "震荡",
"sentiment_score": 45,
},
"strategy": {},
"details": {"decision_type": "buy"},
},
query_id="q1",
stock_code="600519",
stock_name="贵州茅台",
context_snapshot=None,
fallback_fundamental_payload=None,
)
self.assertEqual(report.summary.operation_advice, "不建议买入")
self.assertEqual(report.summary.action, "avoid")
self.assertEqual(report.summary.action_label, "回避")
def test_build_analysis_report_aligns_score_and_legacy_advice(self) -> None:
if _build_analysis_report is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
report = _build_analysis_report(
report_data={
"meta": {"report_type": "detailed", "report_language": "zh"},
"summary": {
"analysis_summary": "等待确认",
"operation_advice": "持有",
"sentiment_score": 72,
},
"strategy": {},
"details": {},
},
query_id="q2",
stock_code="600519",
stock_name="贵州茅台",
context_snapshot=None,
fallback_fundamental_payload=None,
)
self.assertEqual(report.summary.action, "buy")
self.assertEqual(report.summary.action_label, "买入")
def test_build_analysis_report_reads_decision_action_from_raw_result(self) -> None:
if _build_analysis_report is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
report = _build_analysis_report(
report_data={
"meta": {"report_type": "detailed", "report_language": "zh"},
"summary": {
"analysis_summary": "等待确认",
"operation_advice": "持有观察",
"action": "buy",
"trend_prediction": "震荡",
"sentiment_score": 45,
},
"strategy": {},
"details": {
"action": "sell",
"raw_result": {
"operation_advice": "持有观察",
"action": "watch",
"report_language": "zh",
},
},
},
query_id="q1",
stock_code="600519",
stock_name="贵州茅台",
context_snapshot=None,
fallback_fundamental_payload=None,
)
self.assertEqual(report.summary.action, "watch")
self.assertEqual(report.summary.action_label, "观望")
def test_build_analysis_report_stringifies_strategy_price_fields(self) -> None:
if _build_analysis_report is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
report = _build_analysis_report(
report_data={
"meta": {},
"summary": {},
"strategy": {
"ideal_buy": 10.0,
"secondary_buy": None,
"stop_loss": 9.5,
"take_profit": 11.6,
},
"details": {},
},
query_id="q1",
stock_code="600519",
stock_name="贵州茅台",
context_snapshot=None,
fallback_fundamental_payload=None,
)
self.assertEqual(report.strategy.ideal_buy, "10.0")
self.assertIsNone(report.strategy.secondary_buy)
self.assertEqual(report.strategy.stop_loss, "9.5")
self.assertEqual(report.strategy.take_profit, "11.6")
def test_build_analysis_report_extracts_related_board_fields_from_snapshot(self) -> None:
if _build_analysis_report is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
report = _build_analysis_report(
report_data={
"meta": {},
"summary": {},
"strategy": {},
"details": {},
},
query_id="q1",
stock_code="600519",
stock_name="贵州茅台",
context_snapshot={
"enhanced_context": {
"fundamental_context": {
"belong_boards": [{"name": "白酒", "type": "行业"}],
"boards": {
"data": {
"top": [{"name": "白酒", "change_pct": 2.5}],
"bottom": [],
}
},
}
}
},
fallback_fundamental_payload=None,
)
self.assertEqual(report.details.belong_boards, [{"name": "白酒", "type": "行业"}])
self.assertEqual(report.details.sector_rankings["top"][0]["name"], "白酒")
self.assertEqual(report.details.sector_rankings["top"][0]["change_pct"], 2.5)
def test_build_analysis_report_exposes_overview_but_sanitizes_snapshot(self) -> None:
if _build_analysis_report is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
overview = _analysis_context_pack_overview()
phase_summary = _market_phase_summary()
report = _build_analysis_report(
report_data={
"meta": {},
"summary": {},
"strategy": {},
"details": {"news_summary": "news"},
},
query_id="q1",
stock_code="600519",
stock_name="贵州茅台",
context_snapshot={
"enhanced_context": {
"code": "600519",
"portfolio_context": {
"quantity": 100,
"avg_cost": 1800,
"unrealized_pnl_base": 5000,
},
},
"portfolio_context": {"total_cost": 180000},
"analysis_context_pack_overview": overview,
"market_phase_summary": {
**phase_summary,
"market_phase_context": {"raw": True},
},
},
fallback_fundamental_payload=None,
)
self.assertIsNotNone(report.meta.market_phase_summary)
self.assertEqual(report.meta.market_phase_summary.phase, "intraday")
self.assertEqual(
report.details.analysis_context_pack_overview.metadata.trigger_source,
"api",
)
self.assertEqual(
report.details.analysis_context_pack_overview.data_quality.overall_score,
88,
)
self.assertEqual(
report.details.analysis_context_pack_overview.blocks[1].missing_reasons,
["news_context_missing"],
)
self.assertNotIn(
"analysis_context_pack_overview",
report.details.context_snapshot,
)
self.assertNotIn(
"market_phase_summary",
report.details.context_snapshot,
)
self.assertNotIn(
"portfolio_context",
report.details.context_snapshot,
)
self.assertNotIn(
"portfolio_context",
report.details.context_snapshot["enhanced_context"],
)
self.assertNotIn("avg_cost", str(report.details.context_snapshot))
def test_build_analysis_report_falls_back_to_sanitized_report_meta_phase_summary(self) -> None:
if _build_analysis_report is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
phase_summary = {
**_market_phase_summary(),
"warnings": ["api_key=secret"],
"market_phase_context": {"raw": True},
}
report = _build_analysis_report(
report_data={
"meta": {"market_phase_summary": phase_summary},
"summary": {},
"strategy": {},
"details": {},
},
query_id="q-meta-phase",
stock_code="600519",
stock_name="贵州茅台",
context_snapshot=None,
fallback_fundamental_payload=None,
)
self.assertIsNotNone(report.meta.market_phase_summary)
self.assertEqual(report.meta.market_phase_summary.phase, "intraday")
self.assertEqual(report.meta.market_phase_summary.warnings, ["[REDACTED]"])
def test_build_analysis_report_prefers_snapshot_phase_summary_over_report_meta(self) -> None:
if _build_analysis_report is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
snapshot_summary = _market_phase_summary()
report = _build_analysis_report(
report_data={
"meta": {
"market_phase_summary": {
**snapshot_summary,
"phase": "postmarket",
},
},
"summary": {},
"strategy": {},
"details": {},
},
query_id="q-snapshot-phase",
stock_code="600519",
stock_name="贵州茅台",
context_snapshot={"market_phase_summary": snapshot_summary},
fallback_fundamental_payload=None,
)
self.assertIsNotNone(report.meta.market_phase_summary)
self.assertEqual(report.meta.market_phase_summary.phase, "intraday")
def test_build_analysis_report_repairs_bare_kr_code_and_phase_summary(self) -> None:
if _build_analysis_report is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
persisted_phase_summary = {
**_market_phase_summary(),
"phase": "postmarket",
"market_local_time": "2025-01-02T16:10:00+09:00",
"session_date": "2025-01-02",
"effective_daily_bar_date": "2025-01-02",
"is_market_open_now": False,
"is_partial_bar": False,
"minutes_to_open": 900,
"minutes_to_close": None,
"trigger_source": "scheduled_job",
"analysis_intent": "postmarket",
"warnings": ["legacy_snapshot"],
}
with patch("api.v1.endpoints.analysis.resolve_index_stock_code", return_value="005930.KS"):
report = _build_analysis_report(
report_data={
"meta": {"stock_code": "005930"},
"summary": {},
"strategy": {},
"details": {},
},
query_id="q-kr-phase",
stock_code="005930",
stock_name="三星电子",
context_snapshot={"market_phase_summary": persisted_phase_summary},
fallback_fundamental_payload=None,
)
self.assertEqual(report.meta.stock_code, "005930.KS")
self.assertIsNotNone(report.meta.market_phase_summary)
self.assertEqual(report.meta.market_phase_summary.market, "kr")
self.assertEqual(report.meta.market_phase_summary.phase, "postmarket")
self.assertEqual(
report.meta.market_phase_summary.market_local_time,
"2025-01-02T16:10:00+09:00",
)
self.assertEqual(report.meta.market_phase_summary.session_date, "2025-01-02")
self.assertEqual(
report.meta.market_phase_summary.effective_daily_bar_date,
"2025-01-02",
)
self.assertEqual(report.meta.market_phase_summary.trigger_source, "scheduled_job")
self.assertEqual(report.meta.market_phase_summary.analysis_intent, "postmarket")
def test_build_analysis_report_rebuilds_legacy_cn_market_summary_for_kr_code(self) -> None:
if _build_analysis_report is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
legacy_cn_summary = {
**_market_phase_summary(),
"market": "cn",
"phase": "intraday",
"market_local_time": "2026-03-27T10:00:00+08:00",
"session_date": "2026-03-27",
"effective_daily_bar_date": "2026-03-26",
"analysis_intent": "intraday",
"trigger_source": "history_snapshot",
"warnings": ["legacy_cn_snapshot"],
}
with patch("api.v1.endpoints.analysis.resolve_index_stock_code", return_value="005930.KS"):
report = _build_analysis_report(
report_data={
"meta": {"stock_code": "005930"},
"summary": {},
"strategy": {},
"details": {},
},
query_id="q-kr-legacy-cn",
stock_code="005930",
stock_name="三星电子",
context_snapshot={"market_phase_summary": legacy_cn_summary},
fallback_fundamental_payload=None,
)
self.assertIsNotNone(report.meta.market_phase_summary)
self.assertEqual(report.meta.stock_code, "005930.KS")
self.assertEqual(report.meta.market_phase_summary.market, "kr")
self.assertTrue(report.meta.market_phase_summary.market_local_time.endswith("+09:00"))
self.assertIn("legacy_cn_snapshot", report.meta.market_phase_summary.warnings)
def test_build_analysis_report_merges_partial_top_level_context_with_fallback(self) -> None:
if _build_analysis_report is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
report = _build_analysis_report(
report_data={
"meta": {},
"summary": {},
"strategy": {},
"details": {},
},
query_id="q1",
stock_code="600519",
stock_name="贵州茅台",
context_snapshot={
"fundamental_context": {
"belong_boards": [{"name": "白酒", "type": "行业"}],
"boards": {
"data": {
"top": [{"name": "白酒", "change_pct": 2.5}],
"bottom": [],
}
},
}
},
fallback_fundamental_payload={
"earnings": {
"data": {
"financial_report": {"report_date": "2025-12-31", "revenue": 1000},
"dividend": {"ttm_dividend_yield_pct": 2.6},
}
}
},
)
self.assertEqual(report.details.belong_boards, [{"name": "白酒", "type": "行业"}])
self.assertEqual(report.details.sector_rankings["top"][0]["name"], "白酒")
self.assertEqual(report.details.financial_report["report_date"], "2025-12-31")
self.assertEqual(report.details.dividend_metrics["ttm_dividend_yield_pct"], 2.6)
def test_build_analysis_report_keeps_fallback_when_snapshot_has_empty_placeholders(self) -> None:
if _build_analysis_report is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
report = _build_analysis_report(
report_data={
"meta": {},
"summary": {},
"strategy": {},
"details": {},
},
query_id="q1",
stock_code="600519",
stock_name="贵州茅台",
context_snapshot={
"fundamental_context": {
"belong_boards": [],
"boards": {},
"earnings": {},
},
"enhanced_context": {
"fundamental_context": {
"earnings": {"data": {}},
}
},
},
fallback_fundamental_payload={
"belong_boards": [{"name": "白酒", "type": "行业"}],
"boards": {
"data": {
"top": [{"name": "白酒", "change_pct": 2.5}],
"bottom": [],
}
},
"earnings": {
"data": {
"financial_report": {"report_date": "2025-12-31", "revenue": 1000},
"dividend": {"ttm_dividend_yield_pct": 2.6},
}
},
},
)
self.assertEqual(report.details.belong_boards, [{"name": "白酒", "type": "行业"}])
self.assertEqual(report.details.sector_rankings["top"][0]["name"], "白酒")
self.assertEqual(report.details.financial_report["report_date"], "2025-12-31")
self.assertEqual(report.details.dividend_metrics["ttm_dividend_yield_pct"], 2.6)
def test_build_analysis_report_normalizes_related_board_payloads(self) -> None:
if _build_analysis_report is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
report = _build_analysis_report(
report_data={
"meta": {},
"summary": {},
"strategy": {},
"details": {},
},
query_id="q1",
stock_code="600519",
stock_name="贵州茅台",
context_snapshot={
"enhanced_context": {
"fundamental_context": {
"belong_boards": [
{"name": " 白酒 ", "type": " 行业 ", "code": " BK0815 "},
{"name": " "},
"bad-item",
],
"boards": {
"data": {
"top": {"name": "坏数据"},
"bottom": [
{"name": " 消费 ", "change_pct": "-1.2%"},
{"name": None, "change_pct": 1},
"bad-item",
],
}
},
}
}
},
fallback_fundamental_payload=None,
)
self.assertEqual(
report.details.belong_boards,
[{"name": "白酒", "type": "行业", "code": "BK0815"}],
)
self.assertEqual(
report.details.sector_rankings,
{
"top": [],
"bottom": [{"name": "消费", "change_pct": -1.2}],
},
)
def test_build_analysis_report_keeps_failed_board_rankings_unavailable(self) -> None:
if _build_analysis_report is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
report = _build_analysis_report(
report_data={
"meta": {},
"summary": {},
"strategy": {},
"details": {},
},
query_id="q1",
stock_code="600519",
stock_name="贵州茅台",
context_snapshot={
"enhanced_context": {
"fundamental_context": {
"belong_boards": [{"name": "白酒"}],
"boards": {
"status": "failed",
"data": {},
},
}
}
},
fallback_fundamental_payload=None,
)
self.assertEqual(report.details.belong_boards, [{"name": "白酒"}])
self.assertIsNone(report.details.sector_rankings)
def test_build_analysis_report_preserves_report_language(self) -> None:
if _build_analysis_report is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
report = _build_analysis_report(
report_data={
"meta": {"report_language": "en"},
"summary": {"analysis_summary": "English output"},
"strategy": {},
"details": {},
},
query_id="q1",
stock_code="AAPL",
stock_name="Apple",
context_snapshot={"report_language": "zh"},
fallback_fundamental_payload=None,
)
self.assertEqual(report.meta.report_language, "en")
def test_load_sync_fundamental_sources_uses_query_and_code_for_fallback(self) -> None:
if _load_sync_fundamental_sources is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
mock_db = MagicMock()
raw_result_payload = {
"model_used": "test-model",
"report_language": "zh",
"market_structure_context": _market_structure_context(),
}
mock_db.get_analysis_history.return_value = [
SimpleNamespace(
context_snapshot=None,
raw_result=json.dumps(raw_result_payload, ensure_ascii=False),
)
]
fallback_payload = {
"earnings": {
"data": {
"financial_report": {"report_date": "2025-12-31"},
"dividend": {"ttm_dividend_yield_pct": 2.1},
}
}
}
mock_db.get_latest_fundamental_snapshot.return_value = fallback_payload
with patch("src.storage.DatabaseManager.get_instance", return_value=mock_db):
context_snapshot, fundamental_snapshot, raw_result_snapshot = _load_sync_fundamental_sources(
query_id="q_sync_001",
stock_code="600519",
)
self.assertIsNone(context_snapshot)
self.assertEqual(fundamental_snapshot, fallback_payload)
self.assertEqual(raw_result_snapshot, raw_result_payload)
mock_db.get_analysis_history.assert_called_once_with(
query_id="q_sync_001",
code="600519",
limit=1,
)
mock_db.get_latest_fundamental_snapshot.assert_called_once_with(
query_id="q_sync_001",
code="600519",
)
def test_get_analysis_status_reads_price_fields_from_context_snapshot_preserving_zero_change_pct(self) -> None:
if get_analysis_status is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
record = SimpleNamespace(
id=1,
code="600519",
name="贵州茅台",
report_type="detailed",
created_at=datetime(2026, 4, 10, 12, 0, 0),
raw_result=json.dumps({"model_used": "test-model", "report_language": "zh"}),
context_snapshot=json.dumps(
{
"enhanced_context": {
"realtime": {
"price": 1234.5,
"change_pct": 0.0,
"change_60d": 9.99,
}
},
"realtime_quote_raw": {
"price": 999.9,
"change_pct": 8.88,
"pct_chg": 7.77,
},
}
),
sentiment_score=80,
operation_advice="持有",
trend_prediction="震荡上行",
analysis_summary="summary",
ideal_buy=None,
secondary_buy=None,
stop_loss=None,
take_profit=None,
)
mock_db = MagicMock()
mock_db.get_analysis_history.return_value = [record]
with patch("api.v1.endpoints.analysis.get_task_queue") as queue_mock, \
patch("src.storage.DatabaseManager.get_instance", return_value=mock_db):
queue_mock.return_value.get_task.return_value = None
status = get_analysis_status("task_123")
self.assertEqual(status.status, "completed")
self.assertEqual(status.result.report["meta"]["current_price"], 1234.5)
self.assertEqual(status.result.report["meta"]["change_pct"], 0.0)
self.assertEqual(status.result.report["meta"]["model_used"], "test-model")
def test_get_analysis_status_restores_market_structure_from_raw_result_without_snapshot(self) -> None:
if get_analysis_status is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
market_structure = {
"schema_version": "market-structure-v1",
"status": "partial",
"market": "cn",
"market_theme_context": {
"schema_version": "market-theme-v1",
"status": "partial",
"market": "cn",
"active_themes": [{"name": "机器人概念"}],
},
"stock_market_position": {
"schema_version": "stock-market-position-v1",
"status": "partial",
"stock_code": "300024",
"market": "cn",
"primary_theme": {"name": "机器人概念"},
},
}
record = SimpleNamespace(
id=1,
code="300024",
name="机器人",
report_type="detailed",
created_at=datetime(2026, 4, 10, 12, 0, 0),
raw_result=json.dumps(
{
"model_used": "test-model",
"report_language": "zh",
"market_structure_context": market_structure,
}
),
context_snapshot=None,
sentiment_score=80,
operation_advice="持有",
trend_prediction="震荡上行",
analysis_summary="summary",
ideal_buy=None,
secondary_buy=None,
stop_loss=None,
take_profit=None,
)
mock_db = MagicMock()
mock_db.get_analysis_history.return_value = [record]
mock_db.get_latest_fundamental_snapshot.return_value = None
with patch("api.v1.endpoints.analysis.get_task_queue") as queue_mock, \
patch("src.storage.DatabaseManager.get_instance", return_value=mock_db):
queue_mock.return_value.get_task.return_value = None
status = get_analysis_status("task_market_structure_raw_1")
self.assertEqual(status.status, "completed")
self.assertEqual(
status.result.report["details"]["market_structure"]["market_theme_context"]["active_themes"][0]["name"],
"机器人概念",
)
def test_get_analysis_status_completed_db_snapshot_includes_agent_snapshot_board_details(self) -> None:
if get_analysis_status is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
overview = _analysis_context_pack_overview()
phase_summary = _market_phase_summary()
record = SimpleNamespace(
id=1,
code="600519",
name="贵州茅台",
report_type="detailed",
created_at=datetime(2026, 4, 10, 12, 0, 0),
raw_result=json.dumps({"model_used": "test-model", "report_language": "zh"}),
context_snapshot=json.dumps(
{
"fundamental_context": {
"belong_boards": [{"name": "白酒", "type": "行业"}],
"boards": {
"data": {
"top": [{"name": "白酒", "change_pct": 2.8}],
"bottom": [],
}
},
},
"realtime_quote": {
"price": 1888.0,
"change_pct": 1.56,
},
"analysis_context_pack_overview": overview,
"market_phase_summary": {
**phase_summary,
"quote_timestamp": "not-public",
},
}
),
news_content="news",
sentiment_score=80,
operation_advice="持有",
trend_prediction="震荡上行",
analysis_summary="summary",
ideal_buy=None,
secondary_buy=None,
stop_loss=None,
take_profit=None,
)
mock_db = MagicMock()
mock_db.get_analysis_history.return_value = [record]
mock_db.get_latest_fundamental_snapshot.return_value = None
with patch("api.v1.endpoints.analysis.get_task_queue") as queue_mock, \
patch("src.storage.DatabaseManager.get_instance", return_value=mock_db):
queue_mock.return_value.get_task.return_value = None
status = get_analysis_status("task_agent_snapshot_1")
self.assertEqual(status.status, "completed")
self.assertEqual(status.result.report["meta"]["current_price"], 1888.0)
self.assertEqual(status.result.report["meta"]["change_pct"], 1.56)
self.assertIsNone(status.analysis_phase)
self.assertEqual(
status.result.report["meta"]["market_phase_summary"]["phase"],
"intraday",
)
self.assertEqual(
status.result.report["details"]["belong_boards"],
[{"name": "白酒", "type": "行业"}],
)
self.assertEqual(
status.result.report["details"]["sector_rankings"]["top"][0]["name"],
"白酒",
)
self.assertEqual(
status.result.report["details"]["analysis_context_pack_overview"]["metadata"]["trigger_source"],
"api",
)
self.assertEqual(
status.result.report["details"]["analysis_context_pack_overview"]["data_quality"]["overall_score"],
88,
)
self.assertNotIn(
"analysis_context_pack_overview",
status.result.report["details"]["context_snapshot"],
)
self.assertNotIn(
"market_phase_summary",
status.result.report["details"]["context_snapshot"],
)
def test_get_analysis_status_in_memory_task_enriches_agent_snapshot_board_details(self) -> None:
if get_analysis_status is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
overview = _analysis_context_pack_overview()
phase_summary = _market_phase_summary()
context_snapshot = {
"fundamental_context": {
"belong_boards": [{"name": "白酒", "type": "行业"}],
"boards": {
"data": {
"top": [{"name": "白酒", "change_pct": 2.8}],
"bottom": [],
}
},
},
"realtime_quote": {
"price": 1888.0,
"change_pct": 1.56,
},
"analysis_context_pack_overview": overview,
"market_phase_summary": phase_summary,
}
task = QueueTaskInfo(
task_id="task_agent_snapshot_in_memory_1",
stock_code="600519",
stock_name="贵州茅台",
status=TaskStatus.COMPLETED,
progress=100,
result={
"stock_code": "600519",
"stock_name": "贵州茅台",
"report": {
"meta": {
"query_id": "task_agent_snapshot_in_memory_1",
"stock_code": "600519",
"stock_name": "贵州茅台",
"report_type": "detailed",
"report_language": "zh",
"created_at": "2026-04-10T12:00:00",
"model_used": "test-model",
},
"summary": {"analysis_summary": "summary"},
"details": {"news_summary": "news"},
},
},
error=None,
original_query=None,
selection_source=None,
analysis_phase="auto",
skills=None,
created_at=datetime(2026, 4, 10, 12, 0, 0),
completed_at=datetime(2026, 4, 10, 12, 1, 0),
)
record = SimpleNamespace(context_snapshot=json.dumps(context_snapshot))
mock_db = MagicMock()
mock_db.get_analysis_history.return_value = [record]
mock_db.get_latest_fundamental_snapshot.return_value = None
with patch("api.v1.endpoints.analysis.get_task_queue") as queue_mock, \
patch("src.storage.DatabaseManager.get_instance", return_value=mock_db):
queue_mock.return_value.get_task.return_value = task
status = get_analysis_status("task_agent_snapshot_in_memory_1")
self.assertEqual(status.status, "completed")
self.assertEqual(status.result.report["meta"]["current_price"], 1888.0)
self.assertEqual(status.result.report["meta"]["change_pct"], 1.56)
self.assertEqual(
status.result.report["meta"]["market_phase_summary"]["phase"],
"intraday",
)
self.assertEqual(
status.result.report["details"]["belong_boards"],
[{"name": "白酒", "type": "行业"}],
)
self.assertEqual(
status.result.report["details"]["sector_rankings"]["top"][0]["name"],
"白酒",
)
self.assertEqual(
status.result.report["details"]["analysis_context_pack_overview"]["metadata"]["trigger_source"],
"api",
)
self.assertEqual(
status.result.report["details"]["analysis_context_pack_overview"]["data_quality"]["overall_score"],
88,
)
self.assertNotIn(
"analysis_context_pack_overview",
status.result.report["details"]["context_snapshot"],
)
self.assertNotIn(
"market_phase_summary",
status.result.report["details"]["context_snapshot"],
)
mock_db.get_analysis_history.assert_called_once_with(
query_id="task_agent_snapshot_in_memory_1",
code="600519",
limit=1,
)
def test_get_analysis_status_in_memory_task_without_db_snapshot_preserves_service_phase_summary(self) -> None:
if get_analysis_status is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
phase_summary = _market_phase_summary()
task = QueueTaskInfo(
task_id="task_no_snapshot_in_memory_1",
stock_code="600519",
stock_name="贵州茅台",
status=TaskStatus.COMPLETED,
progress=100,
analysis_phase="intraday",
result={
"stock_code": "600519",
"stock_name": "贵州茅台",
"report": {
"meta": {
"query_id": "task_no_snapshot_in_memory_1",
"stock_code": "600519",
"stock_name": "贵州茅台",
"market_phase_summary": phase_summary,
},
"summary": {"analysis_summary": "summary"},
},
},
error=None,
original_query=None,
selection_source=None,
skills=None,
created_at=datetime(2026, 4, 10, 12, 0, 0),
completed_at=datetime(2026, 4, 10, 12, 1, 0),
)
with patch("api.v1.endpoints.analysis.get_task_queue") as queue_mock, \
patch(
"api.v1.endpoints.analysis._load_sync_fundamental_sources",
return_value=(None, None, None),
) as load_sources:
queue_mock.return_value.get_task.return_value = task
status = get_analysis_status("task_no_snapshot_in_memory_1")
self.assertEqual(status.status, "completed")
self.assertEqual(status.analysis_phase, "intraday")
self.assertIsNotNone(status.result)
self.assertEqual(
status.result.report["meta"]["market_phase_summary"]["phase"],
"intraday",
)
load_sources.assert_called_once_with(
query_id="task_no_snapshot_in_memory_1",
stock_code="600519",
)
def test_openapi_declares_single_and_batch_async_202_payloads(self) -> None:
if create_app is None:
self.skipTest("fastapi is not installed in this test environment")
with tempfile.TemporaryDirectory() as temp_dir:
app = create_app(static_dir=Path(temp_dir))
schema = app.openapi()["paths"]["/api/v1/analysis/analyze"]["post"]["responses"]["202"][
"content"
]["application/json"]["schema"]
refs = {item["$ref"] for item in schema["anyOf"]}
self.assertEqual(
refs,
{
"#/components/schemas/TaskAccepted",
"#/components/schemas/BatchTaskAcceptedResponse",
},
)
def test_openapi_declares_backtest_phase_filter_enum_and_400(self) -> None:
if create_app is None:
self.skipTest("fastapi is not installed in this test environment")
with tempfile.TemporaryDirectory() as temp_dir:
app = create_app(static_dir=Path(temp_dir))
paths = app.openapi()["paths"]
for path in (
"/api/v1/backtest/results",
"/api/v1/backtest/performance",
"/api/v1/backtest/performance/{code}",
):
operation = paths[path]["get"]
self.assertIn("400", operation["responses"])
params = {param["name"]: param for param in operation["parameters"]}
schema = params["analysis_phase"]["schema"]
enum_values = set()
stack = [schema]
while stack:
current = stack.pop()
if not isinstance(current, dict):
continue
enum_values.update(current.get("enum") or [])
stack.extend(current.get("anyOf") or [])
stack.extend(current.get("oneOf") or [])
self.assertEqual(enum_values, {"premarket", "intraday", "postmarket", "unknown"})
def test_market_review_endpoint_accepts_omitted_body(self) -> None:
if create_app is None or analysis_endpoint_module is None:
self.skipTest("fastapi is not installed in this test environment")
config = SimpleNamespace(trading_day_check_enabled=True, market_review_region="cn")
with tempfile.TemporaryDirectory() as temp_dir:
app = create_app(static_dir=Path(temp_dir))
request_body = app.openapi()["paths"]["/api/v1/analysis/market-review"]["post"][
"requestBody"
]
self.assertNotIn("required", request_body)
task_queue = MagicMock()
task_queue.submit_background_task.return_value = SimpleNamespace(task_id="market-task-omitted")
with patch.object(
analysis_endpoint_module,
"_try_acquire_market_review_lock",
return_value=object(),
), patch("api.v1.endpoints.analysis.get_task_queue", return_value=task_queue):
response = trigger_market_review(
request=None,
config=config,
)
self.assertTrue(response.send_notification)
self.assertEqual(response.task_id, "market-task-omitted")
task_queue.submit_background_task.assert_called_once()
def test_trigger_analysis_rejects_blank_only_stock_inputs(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
with self.assertRaises(Exception) as ctx:
trigger_analysis(
request=SimpleNamespace(
stock_code=" ",
stock_codes=None,
report_type="detailed",
force_refresh=False,
async_mode=False,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(ctx.exception.status_code, 400)
self.assertEqual(
ctx.exception.detail["message"],
"股票代码不能为空或仅包含空白字符",
)
def test_trigger_analysis_rejects_obviously_invalid_mixed_input_before_resolution(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
with patch("api.v1.endpoints.analysis.resolve_name_to_code") as resolve_mock:
with self.assertRaises(Exception) as ctx:
trigger_analysis(
request=SimpleNamespace(
stock_code="00AAAAA",
stock_codes=None,
report_type="detailed",
force_refresh=False,
async_mode=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(ctx.exception.status_code, 400)
self.assertEqual(ctx.exception.detail["message"], "请输入有效的股票代码或股票名称")
resolve_mock.assert_not_called()
def test_trigger_analysis_rejects_unresolvable_alpha_garbage(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
with patch("api.v1.endpoints.analysis.resolve_name_to_code", return_value=None), \
patch("api.v1.endpoints.analysis.get_task_queue") as queue_mock:
with self.assertRaises(Exception) as ctx:
trigger_analysis(
request=SimpleNamespace(
stock_code="aaaaaaa",
stock_codes=None,
report_type="detailed",
force_refresh=False,
async_mode=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(ctx.exception.status_code, 400)
self.assertEqual(ctx.exception.detail["message"], "请输入有效的股票代码或股票名称")
queue_mock.assert_not_called()
def test_trigger_analysis_accepts_us_suffix_code(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="AAPL.US",
stock_codes=None,
stock_name=None,
original_query="AAPL.US",
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
resolve_mock.assert_not_called()
queue.submit_tasks_batch.assert_called_once_with(
stock_codes=["AAPL.US"],
stock_name=None,
original_query="AAPL.US",
selection_source="manual",
report_type="detailed",
analysis_phase="auto",
force_refresh=False,
notify=True,
)
def test_trigger_analysis_resolves_bare_code_from_stock_index_before_default_market(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_index_stock_code", return_value="005930.KS"), \
patch("api.v1.endpoints.analysis.resolve_name_to_code") as resolve_mock:
response = trigger_analysis(
request=SimpleNamespace(
stock_code="005930",
stock_codes=None,
stock_name=None,
original_query="005930",
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
resolve_mock.assert_not_called()
queue.submit_tasks_batch.assert_called_once_with(
stock_codes=["005930.KS"],
stock_name=None,
original_query="005930",
selection_source="manual",
report_type="detailed",
analysis_phase="auto",
force_refresh=False,
notify=True,
)
def test_trigger_analysis_resolves_bare_4_digit_jp_code_before_name_resolution(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_index_stock_code_for_analysis", return_value="7203.T") as resolve_index_mock, \
patch("api.v1.endpoints.analysis.resolve_name_to_code") as resolve_mock:
response = trigger_analysis(
request=SimpleNamespace(
stock_code="7203",
stock_codes=None,
stock_name=None,
original_query="7203",
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
resolve_index_mock.assert_called_once_with("7203")
resolve_mock.assert_not_called()
queue.submit_tasks_batch.assert_called_once_with(
stock_codes=["7203.T"],
stock_name=None,
original_query="7203",
selection_source="manual",
report_type="detailed",
analysis_phase="auto",
force_refresh=False,
notify=True,
)
def test_trigger_analysis_accepts_camel_case_report_language_alias(self) -> None:
if trigger_analysis is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
task = SimpleNamespace(
task_id="task-report-language-1",
trace_id="trace-report-language-1",
stock_code="600519",
analysis_phase="auto",
)
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([task], [])
request = analysis_endpoint_module.AnalyzeRequest.model_validate({
"stock_code": "600519",
"async_mode": True,
"reportLanguage": "en",
})
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = trigger_analysis(request=request, config=SimpleNamespace())
self.assertEqual(response.status_code, 202)
queue.submit_tasks_batch.assert_called_once_with(
stock_codes=["600519"],
stock_name=None,
original_query=None,
selection_source=None,
report_type="detailed",
analysis_phase="auto",
force_refresh=False,
notify=True,
report_language="en",
)
def test_trigger_analysis_async_passes_and_returns_analysis_phase(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
task = SimpleNamespace(
task_id="task-phase-1",
trace_id="trace-phase-1",
stock_code="600519",
analysis_phase="intraday",
)
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([task], [])
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = trigger_analysis(
request=SimpleNamespace(
stock_code="600519",
stock_codes=None,
stock_name=None,
original_query=None,
selection_source=None,
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="intraday",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
self.assertEqual(json.loads(response.body)["analysis_phase"], "intraday")
queue.submit_tasks_batch.assert_called_once_with(
stock_codes=["600519"],
stock_name=None,
original_query=None,
selection_source=None,
report_type="detailed",
analysis_phase="intraday",
force_refresh=False,
notify=True,
)
def test_trigger_analysis_accepts_hk_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="00700.HK",
stock_codes=None,
stock_name="腾讯控股",
original_query="00700",
selection_source="autocomplete",
report_type="detailed",
force_refresh=False,
async_mode=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
resolve_mock.assert_not_called()
queue.submit_tasks_batch.assert_called_once_with(
stock_codes=["00700.HK"],
stock_name="腾讯控股",
original_query="00700",
selection_source="autocomplete",
report_type="detailed",
analysis_phase="auto",
force_refresh=False,
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,
analysis_phase="auto",
),
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",
analysis_phase="auto",
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,
analysis_phase="auto",
),
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")
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="HK00700",
stock_codes=None,
stock_name=None,
original_query="HK00700",
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
resolve_mock.assert_not_called()
queue.submit_tasks_batch.assert_called_once_with(
stock_codes=["HK00700"],
stock_name=None,
original_query="HK00700",
selection_source="manual",
report_type="detailed",
analysis_phase="auto",
force_refresh=False,
notify=True,
)
def test_trigger_analysis_allows_stock_names_with_star_and_hyphen(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.resolve_name_to_code", return_value="688783"), \
patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = trigger_analysis(
request=SimpleNamespace(
stock_code="西安奕材-U",
stock_codes=None,
stock_name=None,
original_query="西安奕材-U",
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
queue.submit_tasks_batch.assert_called_once_with(
stock_codes=["688783"],
stock_name=None,
original_query="西安奕材-U",
selection_source="manual",
report_type="detailed",
analysis_phase="auto",
force_refresh=False,
notify=True,
)
def test_trigger_analysis_accepts_resolvable_free_text_input(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.resolve_name_to_code", return_value="600519"), \
patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = trigger_analysis(
request=SimpleNamespace(
stock_code="贵州茅台",
stock_codes=None,
stock_name=None,
original_query="贵州茅台",
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
queue.submit_tasks_batch.assert_called_once_with(
stock_codes=["600519"],
stock_name=None,
original_query="贵州茅台",
selection_source="manual",
report_type="detailed",
analysis_phase="auto",
force_refresh=False,
notify=True,
)
def test_trigger_analysis_preserves_batch_metadata(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):
response = trigger_analysis(
request=SimpleNamespace(
stock_code=None,
stock_codes=["600519", "000001"],
stock_name=None,
original_query="uploaded.csv",
selection_source="import",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
queue.submit_tasks_batch.assert_called_once_with(
stock_codes=["600519", "000001"],
stock_name=None,
original_query="uploaded.csv",
selection_source="import",
report_type="detailed",
analysis_phase="auto",
force_refresh=False,
notify=True,
)
def test_trigger_analysis_rejects_cross_request_duplicate_for_equivalent_code_shapes(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
original_instance = AnalysisTaskQueue._instance
AnalysisTaskQueue._instance = None
try:
queue = AnalysisTaskQueue(max_workers=1)
queue._executor = type("ExecutorStub", (), {"submit": lambda self, *args, **kwargs: Future()})()
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
first = trigger_analysis(
request=SimpleNamespace(
stock_code="600519",
stock_codes=None,
stock_name=None,
original_query=None,
selection_source=None,
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
second = trigger_analysis(
request=SimpleNamespace(
stock_code="600519.SH",
stock_codes=None,
stock_name=None,
original_query=None,
selection_source=None,
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(first.status_code, 202)
self.assertEqual(second.status_code, 409)
self.assertEqual(json.loads(second.body)["error"], "duplicate_task")
self.assertEqual(json.loads(second.body)["stock_code"], "600519.SH")
self.assertEqual(
json.loads(second.body)["existing_task_id"],
json.loads(first.body)["task_id"],
)
finally:
queue = AnalysisTaskQueue._instance
if queue is not None and queue is not original_instance:
executor = getattr(queue, "_executor", None)
if executor is not None and hasattr(executor, "shutdown"):
executor.shutdown(wait=False, cancel_futures=True)
AnalysisTaskQueue._instance = original_instance
def test_trigger_analysis_batch_does_not_apply_single_stock_name_to_all_tasks(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):
response = trigger_analysis(
request=SimpleNamespace(
stock_code=None,
stock_codes=["600519", "000001"],
stock_name="贵州茅台",
original_query="茅台,平安银行",
selection_source="import",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
queue.submit_tasks_batch.assert_called_once_with(
stock_codes=["600519", "000001"],
stock_name=None,
original_query="茅台,平安银行",
selection_source="import",
report_type="detailed",
analysis_phase="auto",
force_refresh=False,
notify=True,
)
def test_spa_fallback_returns_json_404_for_bare_api_path(self) -> None:
if create_app is None:
self.skipTest("fastapi is not installed in this test environment")
with tempfile.TemporaryDirectory() as temp_dir:
static_dir = Path(temp_dir)
(static_dir / "index.html").write_text("<html>spa</html>", encoding="utf-8")
app = create_app(static_dir=static_dir)
serve_spa = next(
route.endpoint for route in app.routes
if getattr(route, "path", None) == "/{full_path:path}"
)
response = asyncio.run(serve_spa(None, "api"))
self.assertEqual(response.status_code, 404)
self.assertEqual(
json.loads(response.body),
{"error": "not_found", "message": "API endpoint /api not found"},
)
def test_spa_fallback_blocks_path_traversal(self) -> None:
"""SPA fallback must not serve files outside static_dir.
Starlette's :path converter does not normalize `..` segments, so
without an explicit containment check `static_dir / full_path` can
resolve to arbitrary files on disk (CVE-class path traversal).
"""
if create_app is None:
self.skipTest("fastapi is not installed in this test environment")
from fastapi.responses import FileResponse
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
static_dir = root / "static"
static_dir.mkdir()
(static_dir / "index.html").write_text("<html>spa</html>", encoding="utf-8")
secret = root / "secret.txt"
secret.write_text("TOPSECRET", encoding="utf-8")
app = create_app(static_dir=static_dir)
serve_spa = next(
route.endpoint for route in app.routes
if getattr(route, "path", None) == "/{full_path:path}"
)
for traversal in ("../secret.txt", "../../secret.txt", "foo/../../secret.txt"):
with self.subTest(traversal=traversal):
response = asyncio.run(serve_spa(None, traversal))
self.assertIsInstance(response, FileResponse)
self.assertEqual(Path(response.path).resolve(), (static_dir / "index.html").resolve())
def test_sse_generator_reraises_cancelled_error(self) -> None:
"""CancelledError must propagate (not be swallowed) from the SSE event generator."""
try:
from api.v1.endpoints.analysis import task_stream
except Exception: # pragma: no cover - optional dependency environments
self.skipTest("api.v1.endpoints.analysis not importable")
class _NeverQueue:
"""Queue that never returns from get(), used to exercise cancellation."""
async def get(self):
await asyncio.sleep(3600)
never_queue = _NeverQueue()
mock_task_queue = MagicMock()
mock_task_queue.list_pending_tasks.return_value = []
async def run():
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=mock_task_queue), \
patch("asyncio.Queue", return_value=never_queue):
response = await task_stream()
gen = response.body_iterator
async def consume():
async for _ in gen:
pass
task = asyncio.create_task(consume())
await asyncio.sleep(0) # let generator start and reach wait_for
task.cancel()
await task # should re-raise CancelledError
with self.assertRaises(asyncio.CancelledError):
asyncio.run(run())
mock_task_queue.unsubscribe.assert_called_once_with(never_queue)
def test_get_task_list_includes_analysis_phase_and_skills(self) -> None:
if get_task_list is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
task = SimpleNamespace(
task_id="task-list-phase",
trace_id="trace-list-phase",
stock_code="600519",
stock_name="贵州茅台",
status=TaskStatus.PROCESSING,
progress=42,
message="running",
report_type="detailed",
created_at=datetime(2026, 4, 10, 12, 0, 0),
started_at=datetime(2026, 4, 10, 12, 0, 1),
completed_at=None,
error=None,
original_query="茅台",
selection_source="manual",
analysis_phase="postmarket",
skills=["growth_quality"],
region=None,
)
queue = MagicMock()
queue.list_all_tasks.return_value = [task]
queue.get_task_stats.return_value = {
"total": 1,
"pending": 0,
"processing": 1,
"completed": 0,
"failed": 0,
}
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = get_task_list(status=None, limit=20)
self.assertEqual(response.tasks[0].analysis_phase, "postmarket")
self.assertEqual(response.tasks[0].skills, ["growth_quality"])
class BatchTaskQueueContractTestCase(unittest.TestCase):
def setUp(self) -> None:
self._original_instance = AnalysisTaskQueue._instance
AnalysisTaskQueue._instance = None
def tearDown(self) -> None:
queue = AnalysisTaskQueue._instance
if queue is not None and queue is not self._original_instance:
executor = getattr(queue, "_executor", None)
if executor is not None and hasattr(executor, "shutdown"):
executor.shutdown(wait=False, cancel_futures=True)
AnalysisTaskQueue._instance = self._original_instance
def test_batch_submit_rolls_back_when_executor_submit_fails(self) -> None:
class FailingExecutor:
def __init__(self) -> None:
self.submit_count = 0
def submit(self, *args, **kwargs):
self.submit_count += 1
if self.submit_count == 2:
raise RuntimeError("executor down")
return Future()
queue = AnalysisTaskQueue(max_workers=1)
queue._executor = FailingExecutor()
with self.assertRaisesRegex(RuntimeError, "executor down"):
queue.submit_tasks_batch(["600519", "000858"], report_type="detailed")
self.assertEqual(queue._tasks, {})
self.assertEqual(queue._analyzing_stocks, {})
self.assertEqual(queue._futures, {})
def test_batch_submit_ignores_blank_stock_codes(self) -> None:
queue = AnalysisTaskQueue(max_workers=1)
queue._executor = type("ExecutorStub", (), {"submit": lambda self, *args, **kwargs: Future()})()
accepted, duplicates = queue.submit_tasks_batch(["600519", " "], report_type="detailed")
self.assertEqual([task.stock_code for task in accepted], ["600519"])
self.assertEqual(duplicates, [])
self.assertEqual(sorted(task.stock_code for task in queue._tasks.values()), ["600519"])
def test_batch_submit_and_worker_use_copied_request_skills(self) -> None:
class CapturingExecutor:
def __init__(self) -> None:
self.calls = []
def submit(self, fn, *args, **kwargs):
self.calls.append((fn, args, kwargs))
return Future()
queue = AnalysisTaskQueue(max_workers=1)
executor = CapturingExecutor()
queue._executor = executor
broadcast_events = []
queue._broadcast_event = lambda event_type, data: broadcast_events.append((event_type, data))
request_skills = ["growth_quality"]
portfolio_context = {
"account_id": 7,
"account_name": "Main",
"symbol": "600519",
"quantity": 100,
}
accepted, duplicates = queue.submit_tasks_batch(
["600519"],
report_type="detailed",
analysis_phase="intraday",
query_source="portfolio",
portfolio_context=portfolio_context,
skills=request_skills,
)
request_skills.append("mutated_after_submit")
portfolio_context["quantity"] = 999
self.assertEqual(duplicates, [])
self.assertEqual(accepted[0].analysis_phase, "intraday")
self.assertEqual(accepted[0].to_dict()["analysis_phase"], "intraday")
self.assertNotIn("portfolio_context", accepted[0].to_dict())
self.assertNotIn("query_source", accepted[0].to_dict())
self.assertNotIn("portfolio_context", broadcast_events[0][1])
self.assertNotIn("query_source", broadcast_events[0][1])
self.assertEqual(accepted[0].copy().analysis_phase, "intraday")
self.assertEqual(accepted[0].query_source, "portfolio")
self.assertEqual(accepted[0].portfolio_context["quantity"], 100)
self.assertEqual(accepted[0].copy().portfolio_context["quantity"], 100)
self.assertEqual(accepted[0].skills, ["growth_quality"])
self.assertIs(executor.calls[0][1][5], accepted[0].skills)
self.assertIsNone(executor.calls[0][1][6])
service_instance = MagicMock()
service_instance.analyze_stock.return_value = {"stock_name": "贵州茅台"}
with patch("src.services.analysis_service.AnalysisService", return_value=service_instance):
executor.calls[0][0](*executor.calls[0][1])
self.assertIs(
service_instance.analyze_stock.call_args.kwargs["skills"],
accepted[0].skills,
)
self.assertEqual(service_instance.analyze_stock.call_args.kwargs["skills"], ["growth_quality"])
self.assertEqual(service_instance.analyze_stock.call_args.kwargs["analysis_phase"], "intraday")
self.assertEqual(service_instance.analyze_stock.call_args.kwargs["query_source"], "portfolio")
self.assertEqual(
service_instance.analyze_stock.call_args.kwargs["portfolio_context"]["quantity"],
100,
)
def test_batch_submit_deduplicates_equivalent_stock_code_shapes(self) -> None:
queue = AnalysisTaskQueue(max_workers=1)
queue._executor = type("ExecutorStub", (), {"submit": lambda self, *args, **kwargs: Future()})()
accepted, duplicates = queue.submit_tasks_batch(["600519"], report_type="detailed")
self.assertEqual(len(accepted), 1)
self.assertEqual(duplicates, [])
self.assertTrue(queue.is_analyzing("600519.SH"))
self.assertEqual(queue.get_analyzing_task_id("600519.SH"), accepted[0].task_id)
accepted_again, duplicates_again = queue.submit_tasks_batch(
["600519.SH"],
report_type="detailed",
analysis_phase="intraday",
)
self.assertEqual(accepted_again, [])
self.assertEqual(len(duplicates_again), 1)
self.assertEqual(duplicates_again[0].stock_code, "600519.SH")
self.assertEqual(duplicates_again[0].existing_task_id, accepted[0].task_id)
def test_submit_task_rejects_blank_stock_code(self) -> None:
queue = AnalysisTaskQueue(max_workers=1)
queue._executor = type("ExecutorStub", (), {"submit": lambda self, *args, **kwargs: Future()})()
with self.assertRaisesRegex(ValueError, "股票代码不能为空或仅包含空白字符"):
queue.submit_task(" ", report_type="detailed")
self.assertEqual(queue._tasks, {})
self.assertEqual(queue._analyzing_stocks, {})
self.assertEqual(queue._futures, {})
def test_batch_submit_broadcasts_task_created_while_queue_lock_is_held(self) -> None:
queue = AnalysisTaskQueue(max_workers=1)
queue._executor = type("ExecutorStub", (), {"submit": lambda self, *args, **kwargs: Future()})()
lock_states = []
def record_broadcast(event_type, data):
if event_type == "task_created":
lock_states.append(queue._data_lock._is_owned())
queue._broadcast_event = record_broadcast
accepted, duplicates = queue.submit_tasks_batch(["600519", "000858"], report_type="detailed")
self.assertEqual(len(accepted), 2)
self.assertEqual(duplicates, [])
self.assertEqual(lock_states, [True, True])
def test_update_task_progress_broadcasts_task_progress_event(self) -> None:
queue = AnalysisTaskQueue(max_workers=1)
queue._executor = type("ExecutorStub", (), {"submit": lambda self, *args, **kwargs: Future()})()
accepted, _ = queue.submit_tasks_batch(["600519"], report_type="detailed")
events = []
queue._broadcast_event = lambda event_type, data: events.append((event_type, data))
updated = queue.update_task_progress(
accepted[0].task_id,
62,
"LLM 正在生成分析结果",
)
self.assertIsNotNone(updated)
self.assertEqual(updated.progress, 62)
self.assertEqual(updated.message, "LLM 正在生成分析结果")
self.assertEqual(events, [("task_progress", updated.to_dict())])
class ImageStockExtractorContractTestCase(unittest.TestCase):
def test_litellm_completion_patch_target_remains_available(self) -> None:
cfg = SimpleNamespace(
vision_model="",
openai_vision_model=None,
litellm_model="",
gemini_api_keys=["sk-gemini-testkey-1234"],
gemini_model="gemini-2.0-flash",
anthropic_api_keys=[],
anthropic_model="claude-3-5-sonnet-20241022",
openai_api_keys=[],
openai_model="gpt-4o-mini",
openai_base_url=None,
)
msg = MagicMock()
msg.content = '["600519"]'
choice = MagicMock()
choice.message = msg
response = MagicMock()
response.choices = [choice]
with patch("src.services.image_stock_extractor.get_config", return_value=cfg), \
patch("src.services.image_stock_extractor.litellm.completion", return_value=response) as mock_completion:
result = _call_litellm_vision("base64data", "image/jpeg")
self.assertEqual(result, '["600519"]')
mock_completion.assert_called_once()
if __name__ == "__main__":
unittest.main()