mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* feat: rebuild market structure context * fix(review-feedback-1981): apps/dsa-web/src/components/report/MarketStructureCard.tsx 新增用户可见界面,但 * fix(review-feedback-1981): apps/dsa-web/src/components/report/MarketStructureCard.tsx 新增用户可见界面,但 * fix(review-feedback-1981): Scope in-flight ranking keys to each fetcher manager * fix(review-feedback-1981): 修正无证据情况下的个股层级判定,并按仓库规范补充可访问的 Web 页面截图、更新 PR 描述事实 * fix(review-feedback-1981): apps/dsa-web/src/components/report/MarketStructureCard.tsx 新增用户可见界面,但 * fix(review-feedback-1981): 按 AGENTS.md 为新增市场结构卡片补充可直接查看的截图,并同步 PR 描述中的 diff 与 CI 事实 * fix(review-feedback-1981): apps/dsa-web/src/components/report/MarketStructureCard.tsx 新增用户可见界面,但 * fix(review-feedback-1981): 按仓库规范补充可直接审查的市场结构卡片截图,并同步 PR 描述中的 diff 与 CI 事实 * fix(review-feedback-1981): apps/dsa-web/e2e/market-structure-card-visual.spec.ts:276 在 * fix(review-feedback-1981): apps/dsa-web/e2e/market-structure-card-visual.spec.ts 将本 PR * fix(review-feedback-1981): 同步当前 Head 的 diff/CI 结论,并按仓库规范在 PR 描述或评论中附可访问的市场结构卡片截图 * fix(review-feedback-1981): apps/dsa-web/e2e/market-structure-card-visual.spec.ts:261 新增 async 空对象解构 * fix(review-feedback-1981): 按仓库规范在 PR 描述或评论中附市场结构卡片的可访问截图,并同步当前 diff 统计和 CI 结果 * fix(review-feedback-1981): 消除当前运行结果对历史持久化成功的依赖,补充对应回归测试,并补齐可访问的 UI 截图及更新失真的 PR 描述 * fix(review-feedback-1981): src/utils/data processing.py 提取榜单时丢弃了上游 boards / concept boards 的 * fix(review-feedback-1981): 修正 PR 描述并补齐可访问的视觉证据 * fix(review-feedback-1981): 关闭市场结构前置数据获取可能阻塞主分析的问题,并同步修正 PR 描述、范围清单和视觉证据 * fix(review-feedback-1981): 将 PR 描述、完整范围、当前 CI 证据、视觉证据及回滚表述同步到最新 Head * fix(review-feedback-1981): PR 描述与最新 Head 不一致:完整 diff 包含 38 个文件及 src/services/analysis * fix(review-feedback-1981): 按最新 Head 同步 PR 描述,并补充可访问的市场结构卡片截图证据 * fix(review-feedback-1981): 统一即时响应与历史响应的 details.raw result 契约并补最终 API 回归测试,同时更新 PR 描述和可访问的视觉证据 * fix(review-feedback-1981): 将 PR 描述、完整范围、CI 结果及可访问的 UI 截图同步到最新 Head,消除描述与实际改动的实质性不一致 * fix(review-feedback-1981): 同步 PR 描述与最新 Head,并补充可访问的 Web 页面截图 * fix(review-feedback-1981): 同步 PR 描述并按仓库规范附上可访问的 Web 页面截图 * fix(review-feedback-1981): 代码结论 :不可。已按 origin/main...76b5b596fdff 的完整 38 文件 diff 重新建立基线。上次 6 个 * fix(review-feedback-1981): 代码结论 :不可。最新修复已正确收窄无成分股、无龙头证据的原生榜单路径:该路径现在返回 edge/partial,不再直接输出
2267 lines
88 KiB
Python
2267 lines
88 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
===================================
|
||
A股自选股智能分析系统 - 分析历史存储单元测试
|
||
===================================
|
||
|
||
职责:
|
||
1. 验证分析历史保存逻辑
|
||
2. 验证上下文快照保存开关
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import sys
|
||
import tempfile
|
||
import unittest
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
# Keep this test runnable when optional LLM runtime deps are not installed.
|
||
try:
|
||
import litellm # noqa: F401
|
||
except ModuleNotFoundError:
|
||
sys.modules["litellm"] = MagicMock()
|
||
|
||
try:
|
||
from fastapi.testclient import TestClient
|
||
from api.app import create_app
|
||
from api.v1.endpoints.history import get_history_detail, get_history_list, get_stock_bar
|
||
except ModuleNotFoundError:
|
||
TestClient = None
|
||
create_app = None
|
||
get_history_detail = None
|
||
get_history_list = None
|
||
get_stock_bar = None
|
||
|
||
from src.config import Config
|
||
from src.storage import (
|
||
DatabaseManager,
|
||
AnalysisHistory,
|
||
BacktestResult,
|
||
DecisionSignalFeedbackRecord,
|
||
DecisionSignalOutcomeRecord,
|
||
DecisionSignalRecord,
|
||
)
|
||
from src.analyzer import AnalysisResult
|
||
from src.daily_market_context_guardrail import apply_daily_market_context_guardrail
|
||
from src.services.history_service import HistoryService
|
||
import src.auth as auth
|
||
|
||
|
||
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": [],
|
||
}
|
||
],
|
||
"counts": {
|
||
"available": 1,
|
||
"missing": 0,
|
||
"not_supported": 0,
|
||
"fallback": 0,
|
||
"stale": 0,
|
||
"estimated": 0,
|
||
"partial": 0,
|
||
"fetch_failed": 0,
|
||
},
|
||
"data_quality": {
|
||
"overall_score": 100,
|
||
"level": "good",
|
||
"block_scores": {
|
||
"quote": 100,
|
||
"daily_bars": 100,
|
||
"technical": 100,
|
||
"news": 100,
|
||
"fundamentals": 100,
|
||
"chip": 100,
|
||
},
|
||
"limitations": [],
|
||
},
|
||
"warnings": [],
|
||
"metadata": {
|
||
"trigger_source": "api",
|
||
"news_result_count": 2,
|
||
},
|
||
}
|
||
|
||
|
||
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 AnalysisHistoryTestCase(unittest.TestCase):
|
||
"""分析历史存储测试"""
|
||
|
||
def setUp(self) -> None:
|
||
"""为每个用例初始化独立数据库"""
|
||
auth._auth_enabled = False
|
||
self._temp_dir = tempfile.TemporaryDirectory()
|
||
self._db_path = os.path.join(self._temp_dir.name, "test_analysis_history.db")
|
||
self._original_env = {
|
||
key: os.environ.get(key)
|
||
for key in (
|
||
"ENV_FILE",
|
||
"DATABASE_PATH",
|
||
)
|
||
}
|
||
self._env_path = os.path.join(self._temp_dir.name, ".env")
|
||
with open(self._env_path, "w", encoding="utf-8") as env_file:
|
||
env_file.write("STOCK_LIST=600519,000001\n")
|
||
|
||
os.environ["ENV_FILE"] = self._env_path
|
||
os.environ["DATABASE_PATH"] = self._db_path
|
||
|
||
Config._instance = None
|
||
DatabaseManager.reset_instance()
|
||
self.db = DatabaseManager.get_instance()
|
||
|
||
def tearDown(self) -> None:
|
||
"""清理资源"""
|
||
Config._instance = None
|
||
DatabaseManager.reset_instance()
|
||
for key, value in self._original_env.items():
|
||
if value is None:
|
||
os.environ.pop(key, None)
|
||
else:
|
||
os.environ[key] = value
|
||
self._temp_dir.cleanup()
|
||
|
||
def test_history_timestamps_include_server_timezone_offset(self) -> None:
|
||
serialized = HistoryService._serialize_created_at(datetime(2026, 7, 11, 0, 30))
|
||
|
||
self.assertIsNotNone(serialized)
|
||
self.assertRegex(serialized or "", r"[+-]\d{2}:\d{2}$")
|
||
|
||
def test_history_query_failure_is_not_returned_as_an_empty_success(self) -> None:
|
||
db = MagicMock()
|
||
db.get_analysis_history_paginated.side_effect = RuntimeError("database unavailable")
|
||
|
||
with self.assertRaisesRegex(RuntimeError, "database unavailable"):
|
||
HistoryService(db).get_history_list(page=1, limit=20)
|
||
|
||
if get_history_list is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
with self.assertRaises(Exception) as raised:
|
||
get_history_list(
|
||
stock_code=None,
|
||
report_type=None,
|
||
start_date=None,
|
||
end_date=None,
|
||
page=1,
|
||
limit=20,
|
||
db_manager=db,
|
||
)
|
||
|
||
self.assertEqual(getattr(raised.exception, "status_code", None), 500)
|
||
|
||
def _build_result(self) -> AnalysisResult:
|
||
"""构造分析结果"""
|
||
return AnalysisResult(
|
||
code="600519",
|
||
name="贵州茅台",
|
||
sentiment_score=78,
|
||
trend_prediction="看多",
|
||
operation_advice="持有",
|
||
analysis_summary="基本面稳健,短期震荡",
|
||
)
|
||
|
||
def _save_history(self, query_id: str) -> int:
|
||
"""保存一条测试历史记录并返回主键 ID。"""
|
||
result = self._build_result()
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id=query_id,
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == query_id).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
return row.id
|
||
|
||
def test_save_analysis_history_with_snapshot(self) -> None:
|
||
"""保存历史记录并写入上下文快照"""
|
||
result = self._build_result()
|
||
result.dashboard = {
|
||
"battle_plan": {
|
||
"sniper_points": {
|
||
"ideal_buy": "理想买入点:125.5元",
|
||
"secondary_buy": "120",
|
||
"stop_loss": "止损位:110元",
|
||
"take_profit": "目标位:150.0元",
|
||
}
|
||
}
|
||
}
|
||
context_snapshot = {"enhanced_context": {"code": "600519"}}
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_001",
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=context_snapshot,
|
||
save_snapshot=True
|
||
)
|
||
|
||
self.assertGreater(saved, 0)
|
||
|
||
history = self.db.get_analysis_history(code="600519", days=7, limit=10)
|
||
self.assertEqual(len(history), 1)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
self.assertEqual(row.query_id, "query_001")
|
||
self.assertIsNotNone(row.context_snapshot)
|
||
self.assertEqual(row.ideal_buy, 125.5)
|
||
self.assertEqual(row.secondary_buy, 120.0)
|
||
self.assertEqual(row.stop_loss, 110.0)
|
||
self.assertEqual(row.take_profit, 150.0)
|
||
|
||
def test_history_display_resolves_bare_jp_kr_code_from_stock_pool(self) -> None:
|
||
result = self._build_result()
|
||
result.code = "005930"
|
||
result.name = "Samsung Electronics"
|
||
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"],
|
||
}
|
||
expected_phase_summary = {**persisted_phase_summary, "market": "kr"}
|
||
expected_phase_summary["minutes_to_open"] = None
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_kr_bare",
|
||
report_type="simple",
|
||
news_content="news",
|
||
context_snapshot={"market_phase_summary": persisted_phase_summary},
|
||
save_snapshot=True,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
service = HistoryService(self.db)
|
||
with patch("src.services.history_service.resolve_index_stock_code", return_value="005930.KS"):
|
||
listing = service.get_history_list(page=1, limit=5)
|
||
detail = service.resolve_and_get_detail("query_kr_bare")
|
||
|
||
self.assertEqual(listing["items"][0]["stock_code"], "005930.KS")
|
||
self.assertEqual(listing["items"][0]["market_phase_summary"], expected_phase_summary)
|
||
self.assertIsNotNone(detail)
|
||
self.assertEqual(detail["stock_code"], "005930.KS")
|
||
self.assertEqual(detail["market_phase_summary"], expected_phase_summary)
|
||
|
||
def test_history_display_rebuilds_market_phase_summary_for_legacy_cn_snapshot(self) -> None:
|
||
result = self._build_result()
|
||
result.code = "005930"
|
||
result.name = "Samsung Electronics"
|
||
persisted_phase_summary = {
|
||
**_market_phase_summary(),
|
||
"phase": "postmarket",
|
||
"market_local_time": "2026-01-01T10:00:00+08:00",
|
||
"session_date": "2026-01-01",
|
||
"effective_daily_bar_date": "2025-12-31",
|
||
"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"],
|
||
}
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_kr_legacy_snapshot",
|
||
report_type="simple",
|
||
news_content="news",
|
||
context_snapshot={"market_phase_summary": persisted_phase_summary},
|
||
save_snapshot=True,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
service = HistoryService(self.db)
|
||
with patch("src.services.history_service.resolve_index_stock_code", return_value="005930.KS"):
|
||
items = service.get_history_list(page=1, limit=5)["items"]
|
||
|
||
self.assertEqual(items[0]["stock_code"], "005930.KS")
|
||
rebuilt = items[0]["market_phase_summary"]
|
||
self.assertIsNotNone(rebuilt)
|
||
self.assertEqual(rebuilt["market"], "kr")
|
||
self.assertEqual(rebuilt["market_local_time"], "2026-01-01T11:00:00+09:00")
|
||
self.assertEqual(rebuilt["effective_daily_bar_date"], "2025-12-30")
|
||
self.assertIsNone(rebuilt["minutes_to_open"])
|
||
|
||
def test_history_filter_and_stock_bar_merge_bare_and_resolved_jp_kr_codes(self) -> None:
|
||
if get_stock_bar is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
legacy = self._build_result()
|
||
legacy.code = "005930"
|
||
legacy.name = "Samsung Electronics"
|
||
current = self._build_result()
|
||
current.code = "005930.KS"
|
||
current.name = "Samsung Electronics"
|
||
|
||
self.assertGreater(
|
||
self.db.save_analysis_history(
|
||
result=legacy,
|
||
query_id="query_kr_legacy",
|
||
report_type="simple",
|
||
news_content="news",
|
||
context_snapshot={"market_phase_summary": _market_phase_summary()},
|
||
save_snapshot=True,
|
||
),
|
||
0,
|
||
)
|
||
self.assertGreater(
|
||
self.db.save_analysis_history(
|
||
result=current,
|
||
query_id="query_kr_current",
|
||
report_type="simple",
|
||
news_content="news",
|
||
context_snapshot={"market_phase_summary": _market_phase_summary()},
|
||
save_snapshot=True,
|
||
),
|
||
0,
|
||
)
|
||
|
||
with patch("src.services.history_service.resolve_index_stock_code", side_effect=lambda code: "005930.KS" if str(code).split(".", 1)[0] == "005930" else None):
|
||
listing = HistoryService(self.db).get_history_list(stock_code="005930.KS", page=1, limit=10)
|
||
stock_bar = get_stock_bar(
|
||
start_date=None,
|
||
end_date=None,
|
||
limit=10,
|
||
db_manager=self.db,
|
||
)
|
||
|
||
self.assertEqual(listing["total"], 2)
|
||
self.assertEqual({item["query_id"] for item in listing["items"]}, {"query_kr_legacy", "query_kr_current"})
|
||
self.assertEqual(len(stock_bar.items), 1)
|
||
self.assertEqual(stock_bar.items[0].stock_code, "005930.KS")
|
||
self.assertEqual(stock_bar.items[0].analysis_count, 2)
|
||
|
||
def test_save_analysis_history_persists_sniper_columns_via_shared_parser(self) -> None:
|
||
"""迁出 sniper parser 后历史狙击点位列仍按原规则保存。"""
|
||
result = self._build_result()
|
||
result.dashboard = {
|
||
"battle_plan": {
|
||
"sniper_points": {
|
||
"ideal_buy": "理想买入点:125.5元",
|
||
"secondary_buy": "1.52-1.53 (回踩MA5/10附近)",
|
||
"stop_loss": "—",
|
||
"take_profit": "目标位:150.0元",
|
||
}
|
||
}
|
||
}
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_shared_sniper_parser",
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
|
||
self.assertGreater(saved, 0)
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(
|
||
AnalysisHistory.query_id == "query_shared_sniper_parser"
|
||
).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
self.assertEqual(row.ideal_buy, 125.5)
|
||
self.assertEqual(row.secondary_buy, 1.53)
|
||
self.assertIsNone(row.stop_loss)
|
||
self.assertEqual(row.take_profit, 150.0)
|
||
|
||
def test_get_latest_analysis_history_id_filters_by_report_type_and_latest_record(self) -> None:
|
||
"""按 query/code/report_type 返回最新真实历史主键。"""
|
||
for report_type in ("simple", "full", "simple"):
|
||
saved = self.db.save_analysis_history(
|
||
result=self._build_result(),
|
||
query_id="query_latest_id",
|
||
report_type=report_type,
|
||
news_content="新闻摘要",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
simple_id = self.db.get_latest_analysis_history_id(
|
||
query_id="query_latest_id",
|
||
code="600519",
|
||
report_type="simple",
|
||
)
|
||
full_id = self.db.get_latest_analysis_history_id(
|
||
query_id="query_latest_id",
|
||
code="600519",
|
||
report_type="full",
|
||
)
|
||
|
||
self.assertIsNotNone(simple_id)
|
||
self.assertIsNotNone(full_id)
|
||
self.assertGreater(simple_id, full_id)
|
||
|
||
def test_get_latest_analysis_history_id_requires_report_type(self) -> None:
|
||
"""report_type 是必传参数,避免误取同 query/code 的其他报告。"""
|
||
with self.assertRaises(TypeError):
|
||
self.db.get_latest_analysis_history_id(query_id="query", code="600519")
|
||
|
||
def test_save_analysis_history_without_snapshot(self) -> None:
|
||
"""关闭快照保存时不写入 context_snapshot"""
|
||
result = self._build_result()
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_002",
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot={"foo": "bar"},
|
||
save_snapshot=False
|
||
)
|
||
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
self.assertIsNone(row.context_snapshot)
|
||
|
||
def test_save_analysis_history_persists_model_used(self) -> None:
|
||
"""model_used should be persisted in raw_result for history detail."""
|
||
result = self._build_result()
|
||
result.model_used = "gemini/gemini-2.0-flash"
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_003",
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=None,
|
||
save_snapshot=False
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == "query_003").first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
payload = json.loads(row.raw_result or "{}")
|
||
self.assertEqual(payload.get("model_used"), "gemini/gemini-2.0-flash")
|
||
|
||
def test_update_analysis_history_diagnostics_preserves_snapshot_fields(self) -> None:
|
||
"""通知发送后补写 diagnostics 时,不应覆盖已有上下文字段。"""
|
||
saved = self.db.save_analysis_history(
|
||
result=self._build_result(),
|
||
query_id="query_diag_patch",
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot={
|
||
"enhanced_context": {"code": "600519"},
|
||
"diagnostics": {
|
||
"trace_id": "trace-1",
|
||
"query_id": "query_diag_patch",
|
||
"stock_code": "600519",
|
||
"notification_runs": [],
|
||
},
|
||
},
|
||
save_snapshot=True,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
updated = self.db.update_analysis_history_diagnostics(
|
||
query_id="query_diag_patch",
|
||
code="600519",
|
||
notification_runs=[
|
||
{
|
||
"channel": "report",
|
||
"status": "success",
|
||
"success": True,
|
||
}
|
||
],
|
||
)
|
||
|
||
self.assertEqual(updated, 1)
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(
|
||
AnalysisHistory.query_id == "query_diag_patch"
|
||
).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
snapshot = json.loads(row.context_snapshot or "{}")
|
||
self.assertEqual(snapshot["enhanced_context"]["code"], "600519")
|
||
notification_run = snapshot["diagnostics"]["notification_runs"][-1]
|
||
self.assertEqual(notification_run["status"], "success")
|
||
self.assertEqual(notification_run["trace_id"], "trace-1")
|
||
|
||
def test_history_detail_hides_placeholder_model_used(self) -> None:
|
||
"""Placeholder model values should be normalized to None in detail response."""
|
||
result = self._build_result()
|
||
result.model_used = "unknown"
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_004",
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=None,
|
||
save_snapshot=False
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == "query_004").first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
service = HistoryService(self.db)
|
||
detail = service.get_history_detail_by_id(record_id)
|
||
self.assertIsNotNone(detail)
|
||
self.assertIsNone(detail.get("model_used"))
|
||
|
||
def test_history_list_includes_timeline_summary_fields(self) -> None:
|
||
"""History list items expose the fields needed by the same-stock timeline drawer."""
|
||
result = self._build_result()
|
||
result.model_used = "gemini/gemini-2.5-pro"
|
||
context_snapshot = {
|
||
"enhanced_context": {
|
||
"realtime": {
|
||
"price": "51.5",
|
||
"change_pct": "-4.61%",
|
||
"volume_ratio": "1.17",
|
||
"turnover_rate": "11.46",
|
||
},
|
||
},
|
||
"market_phase_summary": _market_phase_summary(),
|
||
}
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_timeline_summary",
|
||
report_type="detailed",
|
||
news_content="新闻摘要",
|
||
context_snapshot=context_snapshot,
|
||
save_snapshot=True,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
service = HistoryService(self.db)
|
||
payload = service.get_history_list(stock_code="600519.SH", page=1, limit=5)
|
||
|
||
self.assertEqual(payload["total"], 1)
|
||
item = payload["items"][0]
|
||
self.assertEqual(item["stock_code"], "600519")
|
||
self.assertEqual(item["trend_prediction"], "看多")
|
||
self.assertEqual(item["analysis_summary"], "基本面稳健,短期震荡")
|
||
self.assertEqual(item["operation_advice"], "持有")
|
||
self.assertEqual(item["action"], "buy")
|
||
self.assertEqual(item["action_label"], "买入")
|
||
self.assertEqual(item["model_used"], "gemini/gemini-2.5-pro")
|
||
self.assertEqual(item["current_price"], 51.5)
|
||
self.assertEqual(item["change_pct"], -4.61)
|
||
self.assertEqual(item["volume_ratio"], 1.17)
|
||
self.assertEqual(item["turnover_rate"], 11.46)
|
||
self.assertEqual(item["market_phase_summary"]["phase"], "intraday")
|
||
self.assertEqual(item["market_phase_summary"]["minutes_to_close"], 300)
|
||
|
||
def test_history_persistence_keeps_softened_operation_advice_from_guardrail(self) -> None:
|
||
"""Conservative-market guardrail short operation_advice is persisted and exposed to history list."""
|
||
result = self._build_result()
|
||
result.decision_type = "buy"
|
||
result.operation_advice = "立即买入并积极加仓"
|
||
|
||
apply_daily_market_context_guardrail(
|
||
result,
|
||
daily_market_context={
|
||
"region": "cn",
|
||
"trade_date": "2026-06-06",
|
||
"summary": "大盘退潮,高风险,建议观望,仓位上限30%。",
|
||
"risk_tags": ["high_risk", "low_position_cap"],
|
||
},
|
||
report_language="zh",
|
||
)
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_softened_operation_advice",
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
service = HistoryService(self.db)
|
||
payload = service.get_history_list(stock_code="600519", page=1, limit=10)
|
||
|
||
self.assertEqual(payload["total"], 1)
|
||
self.assertEqual(payload["items"][0]["operation_advice"], "观望")
|
||
self.assertLessEqual(len(payload["items"][0]["operation_advice"]), 20)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(
|
||
AnalysisHistory.query_id == "query_softened_operation_advice"
|
||
).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
self.assertEqual(row.operation_advice, "观望")
|
||
|
||
def test_market_review_history_can_be_filtered_without_stock_records(self) -> None:
|
||
"""Market review records should be queryable as a dedicated history collection."""
|
||
stock_result = self._build_result()
|
||
market_result = AnalysisResult(
|
||
code="MARKET",
|
||
name="大盘复盘",
|
||
sentiment_score=50,
|
||
trend_prediction="大盘复盘",
|
||
operation_advice="查看复盘",
|
||
analysis_summary="大盘复盘摘要",
|
||
)
|
||
|
||
self.assertGreater(
|
||
self.db.save_analysis_history(
|
||
result=stock_result,
|
||
query_id="query_stock_history",
|
||
report_type="detailed",
|
||
news_content="个股正文",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
),
|
||
0,
|
||
)
|
||
self.assertGreater(
|
||
self.db.save_analysis_history(
|
||
result=market_result,
|
||
query_id="query_market_review_history",
|
||
report_type="market_review",
|
||
news_content="大盘复盘正文",
|
||
context_snapshot={
|
||
"report_kind": "market_review",
|
||
"market_review_payload": {
|
||
"kind": "market_review",
|
||
"sections": [{"title": "复盘", "markdown": "结构化正文"}],
|
||
},
|
||
},
|
||
save_snapshot=True,
|
||
),
|
||
0,
|
||
)
|
||
|
||
service = HistoryService(self.db)
|
||
payload = service.get_history_list(
|
||
stock_code="MARKET",
|
||
report_type="market_review",
|
||
page=1,
|
||
limit=10,
|
||
)
|
||
|
||
self.assertEqual(payload["total"], 1)
|
||
self.assertEqual(payload["items"][0]["stock_code"], "MARKET")
|
||
self.assertEqual(payload["items"][0]["report_type"], "market_review")
|
||
self.assertIsNone(payload["items"][0]["action"])
|
||
self.assertIsNone(payload["items"][0]["action_label"])
|
||
|
||
def test_distinct_stock_bar_excludes_market_review_records_by_default(self) -> None:
|
||
"""The stock bar aggregation should not mix MARKET into ordinary stock entries."""
|
||
stock_result = self._build_result()
|
||
market_result = AnalysisResult(
|
||
code="MARKET",
|
||
name="大盘复盘",
|
||
sentiment_score=50,
|
||
trend_prediction="大盘复盘",
|
||
operation_advice="查看复盘",
|
||
analysis_summary="大盘复盘摘要",
|
||
)
|
||
|
||
self.assertGreater(
|
||
self.db.save_analysis_history(
|
||
result=stock_result,
|
||
query_id="query_stock_bar_stock",
|
||
report_type="detailed",
|
||
news_content="个股正文",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
),
|
||
0,
|
||
)
|
||
self.assertGreater(
|
||
self.db.save_analysis_history(
|
||
result=market_result,
|
||
query_id="query_stock_bar_market",
|
||
report_type="market_review",
|
||
news_content="大盘复盘正文",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
),
|
||
0,
|
||
)
|
||
|
||
records = self.db.get_distinct_stocks_from_history(limit=10)
|
||
|
||
self.assertEqual([record.code for record in records], ["600519"])
|
||
|
||
def test_stock_bar_item_derives_action_fields_from_legacy_advice(self) -> None:
|
||
if get_stock_bar is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
result = self._build_result()
|
||
result.operation_advice = "不建议买入"
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_stock_bar_action",
|
||
report_type="detailed",
|
||
news_content="个股正文",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
response = get_stock_bar(
|
||
start_date=None,
|
||
end_date=None,
|
||
limit=10,
|
||
db_manager=self.db,
|
||
)
|
||
|
||
self.assertEqual(len(response.items), 1)
|
||
self.assertEqual(response.items[0].operation_advice, "不建议买入")
|
||
self.assertEqual(response.items[0].action, "avoid")
|
||
self.assertEqual(response.items[0].action_label, "回避")
|
||
|
||
def test_stock_bar_item_aligns_score_and_legacy_advice(self) -> None:
|
||
if get_stock_bar is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
result = self._build_result()
|
||
result.operation_advice = "持有"
|
||
result.sentiment_score = 78
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_stock_bar_score_align",
|
||
report_type="detailed",
|
||
news_content="个股正文",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
response = get_stock_bar(
|
||
start_date=None,
|
||
end_date=None,
|
||
limit=10,
|
||
db_manager=self.db,
|
||
)
|
||
|
||
self.assertEqual(len(response.items), 1)
|
||
self.assertEqual(response.items[0].operation_advice, "持有")
|
||
self.assertEqual(response.items[0].sentiment_score, 78)
|
||
self.assertEqual(response.items[0].action, "buy")
|
||
self.assertEqual(response.items[0].action_label, "买入")
|
||
|
||
def test_stock_bar_item_falls_back_to_raw_result_summary_fields(self) -> None:
|
||
if get_stock_bar is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
result = self._build_result()
|
||
result.operation_advice = "Hold"
|
||
result.report_language = "en"
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_stock_bar_raw_fallback",
|
||
report_type="detailed",
|
||
news_content="stock report",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.session_scope() as session:
|
||
row = session.query(AnalysisHistory).filter(
|
||
AnalysisHistory.query_id == "query_stock_bar_raw_fallback"
|
||
).first()
|
||
self.assertIsNotNone(row)
|
||
row.sentiment_score = None
|
||
row.operation_advice = None
|
||
|
||
response = get_stock_bar(
|
||
start_date=None,
|
||
end_date=None,
|
||
limit=10,
|
||
db_manager=self.db,
|
||
)
|
||
|
||
self.assertEqual(len(response.items), 1)
|
||
self.assertEqual(response.items[0].sentiment_score, 78)
|
||
self.assertEqual(response.items[0].operation_advice, "Hold")
|
||
self.assertEqual(response.items[0].action, "buy")
|
||
self.assertEqual(response.items[0].action_label, "Buy")
|
||
|
||
def test_history_detail_uses_service_resolved_action_fields(self) -> None:
|
||
if get_history_detail is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
service = MagicMock()
|
||
service.resolve_and_get_detail.return_value = {
|
||
"id": 1,
|
||
"query_id": "query_action_conflict",
|
||
"stock_code": "600519",
|
||
"stock_name": "贵州茅台",
|
||
"report_type": "detailed",
|
||
"report_language": "zh",
|
||
"created_at": "2026-05-21T17:40:00",
|
||
"sentiment_score": 45,
|
||
"operation_advice": "持有观察",
|
||
"action": "watch",
|
||
"action_label": "观望",
|
||
"trend_prediction": "震荡",
|
||
"analysis_summary": "等待确认",
|
||
"raw_result": {
|
||
"operation_advice": "持有观察",
|
||
"action": "watch",
|
||
"report_language": "zh",
|
||
},
|
||
}
|
||
|
||
with patch("api.v1.endpoints.history.HistoryService", return_value=service):
|
||
response = get_history_detail("query_action_conflict", db_manager=self.db)
|
||
|
||
self.assertEqual(response.summary.operation_advice, "持有观察")
|
||
self.assertEqual(response.summary.action, "watch")
|
||
self.assertEqual(response.summary.action_label, "观望")
|
||
|
||
def test_history_list_matches_equivalent_suffixed_stock_codes(self) -> None:
|
||
"""Same-stock history should include rows saved with supported suffixed codes."""
|
||
|
||
def save_record(code: str, query_id: str) -> None:
|
||
result = self._build_result()
|
||
result.code = code
|
||
if "HK" in code:
|
||
result.name = "腾讯控股"
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id=query_id,
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
save_record("600519.SH", "query_cn_suffix")
|
||
save_record("600519", "query_cn_plain")
|
||
save_record("00700.HK", "query_hk_suffix")
|
||
save_record("HK00700", "query_hk_prefix")
|
||
|
||
service = HistoryService(self.db)
|
||
|
||
cn_from_suffix = service.get_history_list(stock_code="600519.SH", page=1, limit=10)
|
||
self.assertEqual(cn_from_suffix["total"], 2)
|
||
self.assertEqual(
|
||
{item["stock_code"] for item in cn_from_suffix["items"]},
|
||
{"600519.SH", "600519"},
|
||
)
|
||
|
||
cn_from_plain = service.get_history_list(stock_code="600519", page=1, limit=10)
|
||
self.assertEqual(cn_from_plain["total"], 2)
|
||
self.assertEqual(
|
||
{item["stock_code"] for item in cn_from_plain["items"]},
|
||
{"600519.SH", "600519"},
|
||
)
|
||
|
||
hk_from_suffix = service.get_history_list(stock_code="00700.HK", page=1, limit=10)
|
||
self.assertEqual(hk_from_suffix["total"], 2)
|
||
self.assertEqual(
|
||
{item["stock_code"] for item in hk_from_suffix["items"]},
|
||
{"00700.HK", "HK00700"},
|
||
)
|
||
|
||
hk_from_prefix = service.get_history_list(stock_code="HK00700", page=1, limit=10)
|
||
self.assertEqual(hk_from_prefix["total"], 2)
|
||
self.assertEqual(
|
||
{item["stock_code"] for item in hk_from_prefix["items"]},
|
||
{"00700.HK", "HK00700"},
|
||
)
|
||
|
||
def test_history_list_matches_unpadded_hk_suffix_variants(self) -> None:
|
||
"""HK short suffix forms (e.g. 1810.HK) should match 5-digit canonical suffix/prefix forms."""
|
||
|
||
def save_record(code: str, query_id: str) -> None:
|
||
result = self._build_result()
|
||
result.code = code
|
||
if "HK" in code:
|
||
result.name = "腾讯控股"
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id=query_id,
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
save_record("1810.HK", "query_hk_unpadded")
|
||
save_record("01810.HK", "query_hk_padded")
|
||
save_record("HK01810", "query_hk_prefix")
|
||
|
||
service = HistoryService(self.db)
|
||
|
||
hk_from_suffix = service.get_history_list(stock_code="01810.HK", page=1, limit=10)
|
||
self.assertEqual(hk_from_suffix["total"], 3)
|
||
self.assertEqual(
|
||
{item["stock_code"] for item in hk_from_suffix["items"]},
|
||
{"1810.HK", "01810.HK", "HK01810"},
|
||
)
|
||
|
||
hk_from_prefix = service.get_history_list(stock_code="HK01810", page=1, limit=10)
|
||
self.assertEqual(hk_from_prefix["total"], 3)
|
||
self.assertEqual(
|
||
{item["stock_code"] for item in hk_from_prefix["items"]},
|
||
{"1810.HK", "01810.HK", "HK01810"},
|
||
)
|
||
|
||
def test_history_list_matches_sh_and_ss_suffixed_variants(self) -> None:
|
||
"""SH suffix and legacy `.SS` variants should be treated as the same A-share stock."""
|
||
|
||
def save_record(code: str, query_id: str) -> None:
|
||
result = self._build_result()
|
||
result.code = code
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id=query_id,
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
save_record("600519.SH", "query_cn_sh")
|
||
save_record("600519.SS", "query_cn_ss")
|
||
save_record("600519", "query_cn_plain")
|
||
|
||
service = HistoryService(self.db)
|
||
expected = {"600519.SH", "600519.SS", "600519"}
|
||
|
||
from_sh = service.get_history_list(stock_code="600519.SH", page=1, limit=10)
|
||
self.assertEqual(from_sh["total"], 3)
|
||
self.assertEqual({item["stock_code"] for item in from_sh["items"]}, expected)
|
||
|
||
from_ss = service.get_history_list(stock_code="600519.SS", page=1, limit=10)
|
||
self.assertEqual(from_ss["total"], 3)
|
||
self.assertEqual({item["stock_code"] for item in from_ss["items"]}, expected)
|
||
|
||
from_plain = service.get_history_list(stock_code="600519", page=1, limit=10)
|
||
self.assertEqual(from_plain["total"], 3)
|
||
self.assertEqual({item["stock_code"] for item in from_plain["items"]}, expected)
|
||
|
||
def test_history_detail_preserves_zero_change_pct(self) -> None:
|
||
"""change_pct=0.0(平盘)应原样返回,而不是被当成缺失值丢失。
|
||
|
||
Regression for issue #1084: history endpoint used `or` chains that
|
||
treated 0.0 as falsy and silently dropped the daily change.
|
||
"""
|
||
if get_history_detail is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
context_snapshot = {
|
||
"enhanced_context": {
|
||
"realtime": {"price": 100.0, "change_pct": 0.0},
|
||
}
|
||
}
|
||
query_id = "query_change_pct_zero"
|
||
saved = self.db.save_analysis_history(
|
||
result=self._build_result(),
|
||
query_id=query_id,
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=context_snapshot,
|
||
save_snapshot=True,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == query_id).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
report = get_history_detail(str(record_id), db_manager=self.db)
|
||
self.assertEqual(report.meta.current_price, 100.0)
|
||
self.assertEqual(report.meta.change_pct, 0.0)
|
||
|
||
def test_history_detail_falls_back_to_realtime_quote_raw_change_pct(self) -> None:
|
||
"""缺少 enhanced_context.realtime.change_pct 时,应回退到 realtime_quote_raw。
|
||
|
||
Regression for issue #1084: previously the realtime_quote_raw fallback
|
||
was only consulted when current_price was missing, so reports with
|
||
price-only enhanced_context lost their change_pct entirely.
|
||
"""
|
||
if get_history_detail is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
context_snapshot = {
|
||
"enhanced_context": {
|
||
"realtime": {"price": 200.0},
|
||
},
|
||
"realtime_quote_raw": {"change_pct": 1.23},
|
||
}
|
||
query_id = "query_change_pct_fallback"
|
||
saved = self.db.save_analysis_history(
|
||
result=self._build_result(),
|
||
query_id=query_id,
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=context_snapshot,
|
||
save_snapshot=True,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == query_id).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
report = get_history_detail(str(record_id), db_manager=self.db)
|
||
self.assertEqual(report.meta.current_price, 200.0)
|
||
self.assertEqual(report.meta.change_pct, 1.23)
|
||
|
||
@patch("src.auth.is_auth_enabled", return_value=False)
|
||
def test_history_detail_ignores_non_dict_realtime_quote_raw(self, mock_auth) -> None:
|
||
"""GET /api/v1/history/{id} should tolerate truthy non-dict realtime_quote_raw."""
|
||
if TestClient is None or create_app is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
context_snapshot = {
|
||
"enhanced_context": {
|
||
"realtime": {"price": 300.0},
|
||
},
|
||
"realtime_quote_raw": "not-a-dict",
|
||
}
|
||
query_id = "query_change_pct_non_dict_raw"
|
||
saved = self.db.save_analysis_history(
|
||
result=self._build_result(),
|
||
query_id=query_id,
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=context_snapshot,
|
||
save_snapshot=True,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == query_id).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
static_dir = Path(self._temp_dir.name) / "empty-static"
|
||
static_dir.mkdir(exist_ok=True)
|
||
client = TestClient(create_app(static_dir=static_dir))
|
||
|
||
response = client.get(f"/api/v1/history/{record_id}")
|
||
|
||
self.assertEqual(response.status_code, 200)
|
||
payload = response.json()
|
||
self.assertEqual(payload["meta"]["current_price"], 300.0)
|
||
self.assertIsNone(payload["meta"]["change_pct"])
|
||
|
||
def test_history_detail_accepts_dict_raw_result(self) -> None:
|
||
"""_record_to_detail_dict should handle dict raw_result without json.loads errors."""
|
||
result = self._build_result()
|
||
result.model_used = "gemini/gemini-2.0-flash"
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_005",
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=None,
|
||
save_snapshot=False
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == "query_005").first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
row.raw_result = {"model_used": "unknown", "extra": "v"}
|
||
|
||
service = HistoryService(self.db)
|
||
detail = service._record_to_detail_dict(row)
|
||
|
||
self.assertIsNotNone(detail)
|
||
self.assertIsInstance(detail.get("raw_result"), dict)
|
||
self.assertIsNone(detail.get("model_used"))
|
||
|
||
def test_history_detail_prefers_raw_sniper_strings(self) -> None:
|
||
"""History detail should display the original sniper point strings from raw_result."""
|
||
result = self._build_result()
|
||
result.dashboard = {
|
||
"battle_plan": {
|
||
"sniper_points": {
|
||
"ideal_buy": "理想买入点:125.5元",
|
||
"secondary_buy": "120-121 元分批",
|
||
"stop_loss": "跌破 110 元止损",
|
||
"take_profit": "目标位:150.0元",
|
||
}
|
||
}
|
||
}
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_006",
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=None,
|
||
save_snapshot=False
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == "query_006").first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
service = HistoryService(self.db)
|
||
detail = service.get_history_detail_by_id(record_id)
|
||
self.assertIsNotNone(detail)
|
||
self.assertEqual(detail.get("ideal_buy"), "理想买入点:125.5元")
|
||
self.assertEqual(detail.get("secondary_buy"), "120-121 元分批")
|
||
self.assertEqual(detail.get("stop_loss"), "跌破 110 元止损")
|
||
self.assertEqual(detail.get("take_profit"), "目标位:150.0元")
|
||
|
||
def test_history_detail_falls_back_to_numeric_sniper_columns(self) -> None:
|
||
"""History detail should still fall back to stored numeric sniper columns when raw strings are unavailable."""
|
||
result = self._build_result()
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_007",
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=None,
|
||
save_snapshot=False
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == "query_007").first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
row.ideal_buy = 125.5
|
||
row.secondary_buy = 120.0
|
||
row.stop_loss = 110.0
|
||
row.take_profit = 150.0
|
||
row.raw_result = json.dumps({"model_used": "gemini/gemini-2.0-flash"})
|
||
session.commit()
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
service = HistoryService(self.db)
|
||
detail = service.get_history_detail_by_id(record_id)
|
||
self.assertIsNotNone(detail)
|
||
self.assertEqual(detail.get("ideal_buy"), "125.5")
|
||
self.assertEqual(detail.get("secondary_buy"), "120.0")
|
||
self.assertEqual(detail.get("stop_loss"), "110.0")
|
||
self.assertEqual(detail.get("take_profit"), "150.0")
|
||
|
||
def test_history_detail_uses_fundamental_snapshot_fallback_when_context_missing(self) -> None:
|
||
"""When context_snapshot is disabled, detail API should fallback to fundamental_snapshot."""
|
||
if get_history_detail is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
result = self._build_result()
|
||
query_id = "query_fundamental_fallback_001"
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id=query_id,
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
self.db.save_fundamental_snapshot(
|
||
query_id=query_id,
|
||
code="600519",
|
||
payload={
|
||
"belong_boards": [{"name": "白酒", "type": "行业"}],
|
||
"boards": {
|
||
"data": {
|
||
"top": [{"name": "白酒", "change_pct": 2.6}],
|
||
"bottom": [],
|
||
}
|
||
},
|
||
"concept_boards": {
|
||
"data": {
|
||
"top": [{"name": "机器人概念", "change_pct": 4.2}],
|
||
"bottom": [],
|
||
}
|
||
},
|
||
"earnings": {
|
||
"data": {
|
||
"financial_report": {"report_date": "2025-12-31", "revenue": 1000},
|
||
"dividend": {"ttm_dividend_yield_pct": 2.6, "ttm_cash_dividend_per_share": 1.3},
|
||
}
|
||
}
|
||
},
|
||
)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == query_id).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
report = get_history_detail(str(record_id), db_manager=self.db)
|
||
self.assertEqual(report.details.financial_report["report_date"], "2025-12-31")
|
||
self.assertEqual(report.details.dividend_metrics["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.concept_rankings["top"][0]["name"], "机器人概念")
|
||
|
||
def test_history_detail_uses_raw_code_for_legacy_jp_kr_fundamental_snapshot(self) -> None:
|
||
"""Legacy bare JP/KR history rows should display suffixes but read snapshots by stored code."""
|
||
if get_history_detail is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
result = self._build_result()
|
||
result.code = "005930"
|
||
result.name = "Samsung Electronics"
|
||
query_id = "query_kr_raw_fundamental_fallback"
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id=query_id,
|
||
report_type="simple",
|
||
news_content="news",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
self.db.save_fundamental_snapshot(
|
||
query_id=query_id,
|
||
code="005930",
|
||
payload={
|
||
"earnings": {
|
||
"data": {
|
||
"financial_report": {"report_date": "2025-12-31", "revenue": 1000},
|
||
"dividend": {"ttm_dividend_yield_pct": 2.6},
|
||
}
|
||
}
|
||
},
|
||
)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == query_id).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
with patch("src.services.history_service.resolve_index_stock_code", return_value="005930.KS"):
|
||
report = get_history_detail(str(record_id), db_manager=self.db)
|
||
|
||
self.assertEqual(report.meta.stock_code, "005930.KS")
|
||
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_history_detail_preserves_unavailable_board_rankings_state(self) -> None:
|
||
"""Failed board ranking blocks should remain unavailable in detail response."""
|
||
if get_history_detail is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
query_id = "query_fundamental_failed_boards_001"
|
||
saved = self.db.save_analysis_history(
|
||
result=self._build_result(),
|
||
query_id=query_id,
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
fallback_fundamental = {
|
||
"belong_boards": [{"name": "白酒", "type": "行业"}],
|
||
"boards": {
|
||
"status": "failed",
|
||
"data": {},
|
||
},
|
||
}
|
||
saved_snapshot = self.db.save_fundamental_snapshot(
|
||
query_id=query_id,
|
||
code="600519",
|
||
payload=fallback_fundamental,
|
||
)
|
||
self.assertGreater(saved_snapshot, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == query_id).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
report = get_history_detail(str(record_id), db_manager=self.db)
|
||
self.assertEqual(report.details.belong_boards, [{"name": "白酒", "type": "行业"}])
|
||
self.assertIsNone(report.details.sector_rankings)
|
||
|
||
def test_history_detail_returns_null_fundamental_fields_when_snapshot_absent(self) -> None:
|
||
"""Detail API should keep new fields nullable when no context/fundamental snapshot exists."""
|
||
if get_history_detail is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
query_id = "query_fundamental_fallback_002"
|
||
saved = self.db.save_analysis_history(
|
||
result=self._build_result(),
|
||
query_id=query_id,
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == query_id).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
report = get_history_detail(str(record_id), db_manager=self.db)
|
||
self.assertIsNone(report.details.financial_report)
|
||
self.assertIsNone(report.details.dividend_metrics)
|
||
self.assertEqual(report.details.belong_boards, [])
|
||
self.assertIsNone(report.details.sector_rankings)
|
||
|
||
def test_history_detail_returns_empty_related_boards_for_non_cn(self) -> None:
|
||
if get_history_detail is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
result = AnalysisResult(
|
||
code="AAPL",
|
||
name="Apple",
|
||
sentiment_score=65,
|
||
trend_prediction="Bullish",
|
||
operation_advice="Hold",
|
||
analysis_summary="US stock test",
|
||
)
|
||
query_id = "query_non_cn_board_001"
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id=query_id,
|
||
report_type="simple",
|
||
news_content="news",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == query_id).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
report = get_history_detail(str(record_id), db_manager=self.db)
|
||
self.assertEqual(report.details.belong_boards, [])
|
||
self.assertIsNone(report.details.sector_rankings)
|
||
|
||
def test_history_detail_reads_agent_snapshot_related_boards_shape(self) -> None:
|
||
"""Agent-mode snapshots store fundamental_context/realtime_quote at the top level."""
|
||
if get_history_detail is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
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,
|
||
},
|
||
}
|
||
query_id = "query_agent_snapshot_boards_001"
|
||
saved = self.db.save_analysis_history(
|
||
result=self._build_result(),
|
||
query_id=query_id,
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot=context_snapshot,
|
||
save_snapshot=True,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == query_id).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
report = get_history_detail(str(record_id), db_manager=self.db)
|
||
self.assertEqual(report.meta.current_price, 1888.0)
|
||
self.assertEqual(report.meta.change_pct, 1.56)
|
||
self.assertEqual(report.details.belong_boards, [{"name": "白酒", "type": "行业"}])
|
||
self.assertEqual(report.details.sector_rankings["top"][0]["name"], "白酒")
|
||
|
||
def test_history_detail_returns_overview_and_sanitizes_snapshot(self) -> None:
|
||
"""History detail exposes the public overview separately from raw snapshot JSON."""
|
||
if get_history_detail is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
overview = _analysis_context_pack_overview()
|
||
phase_summary = _market_phase_summary()
|
||
query_id = "query_context_pack_overview_001"
|
||
saved = self.db.save_analysis_history(
|
||
result=self._build_result(),
|
||
query_id=query_id,
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot={
|
||
"enhanced_context": {"code": "600519"},
|
||
"analysis_context_pack_overview": overview,
|
||
"market_phase_summary": {
|
||
**phase_summary,
|
||
"market_phase_context": {"raw": True},
|
||
},
|
||
},
|
||
save_snapshot=True,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == query_id).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
report = get_history_detail(str(record_id), db_manager=self.db)
|
||
self.assertEqual(
|
||
report.details.analysis_context_pack_overview.metadata.trigger_source,
|
||
"api",
|
||
)
|
||
self.assertEqual(
|
||
report.details.analysis_context_pack_overview.data_quality.overall_score,
|
||
100,
|
||
)
|
||
self.assertIsNotNone(report.meta.market_phase_summary)
|
||
self.assertEqual(report.meta.market_phase_summary.phase, "intraday")
|
||
self.assertEqual(report.meta.market_phase_summary.minutes_to_close, 300)
|
||
self.assertEqual(report.details.analysis_context_pack_overview.metadata.news_result_count, 2)
|
||
self.assertNotIn(
|
||
"analysis_context_pack_overview",
|
||
report.details.context_snapshot,
|
||
)
|
||
self.assertNotIn(
|
||
"market_phase_summary",
|
||
report.details.context_snapshot,
|
||
)
|
||
|
||
def test_history_detail_handles_missing_overview_when_snapshot_disabled(self) -> None:
|
||
"""SAVE_CONTEXT_SNAPSHOT=false style records should not require an overview."""
|
||
if get_history_detail is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
query_id = "query_context_pack_snapshot_disabled_001"
|
||
saved = self.db.save_analysis_history(
|
||
result=self._build_result(),
|
||
query_id=query_id,
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot={
|
||
"enhanced_context": {"code": "600519"},
|
||
"analysis_context_pack_overview": _analysis_context_pack_overview(),
|
||
},
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == query_id).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
self.assertIsNone(row.context_snapshot)
|
||
|
||
report = get_history_detail(str(record_id), db_manager=self.db)
|
||
self.assertIsNone(report.meta.market_phase_summary)
|
||
self.assertIsNone(report.details.analysis_context_pack_overview)
|
||
self.assertIsNone(report.details.context_snapshot)
|
||
|
||
def test_history_detail_restores_market_structure_from_raw_result_without_snapshot(self) -> None:
|
||
"""SAVE_CONTEXT_SNAPSHOT=false should still expose market_structure saved in raw_result."""
|
||
if get_history_detail is None:
|
||
self.skipTest("fastapi is not installed in this test 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": "机器人概念"},
|
||
},
|
||
}
|
||
result = self._build_result()
|
||
result.market_structure_context = market_structure
|
||
query_id = "query_market_structure_snapshot_disabled_001"
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id=query_id,
|
||
report_type="simple",
|
||
news_content="新闻摘要",
|
||
context_snapshot={"market_structure_context": {"ignored": True}},
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == query_id).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
self.assertIsNone(row.context_snapshot)
|
||
record_id = row.id
|
||
|
||
report = get_history_detail(str(record_id), db_manager=self.db)
|
||
self.assertIsNone(report.details.context_snapshot)
|
||
self.assertEqual(
|
||
report.details.market_structure["market_theme_context"]["active_themes"][0]["name"],
|
||
"机器人概念",
|
||
)
|
||
self.assertEqual(
|
||
report.details.raw_result["market_structure_context"]["market_theme_context"]["active_themes"][0]["name"],
|
||
"机器人概念",
|
||
)
|
||
self.assertNotIn(
|
||
"raw_result",
|
||
report.details.raw_result,
|
||
)
|
||
|
||
def test_history_markdown_localizes_english_report_and_placeholder_name(self) -> None:
|
||
"""History markdown should preserve report_language for English reports."""
|
||
result = AnalysisResult(
|
||
code="AAPL",
|
||
name="股票AAPL",
|
||
sentiment_score=78,
|
||
trend_prediction="Bullish",
|
||
operation_advice="Buy",
|
||
analysis_summary="Momentum remains constructive.",
|
||
report_language="en",
|
||
dashboard={
|
||
"core_conclusion": {
|
||
"one_sentence": "Favor buying on pullbacks.",
|
||
"position_advice": {
|
||
"no_position": "Open a starter position.",
|
||
"has_position": "Hold and trail the stop.",
|
||
},
|
||
},
|
||
"intelligence": {
|
||
"risk_alerts": [],
|
||
},
|
||
"battle_plan": {
|
||
"sniper_points": {
|
||
"ideal_buy": "180-182",
|
||
"stop_loss": "172",
|
||
"take_profit": "195",
|
||
}
|
||
},
|
||
},
|
||
)
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_english_markdown_001",
|
||
report_type="full",
|
||
news_content="news",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(
|
||
AnalysisHistory.query_id == "query_english_markdown_001"
|
||
).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
markdown = HistoryService(self.db).get_markdown_report(str(record_id))
|
||
|
||
self.assertIsNotNone(markdown)
|
||
self.assertIn("Stock Analysis Report", markdown)
|
||
self.assertIn("Core Conclusion", markdown)
|
||
self.assertIn("Unnamed Stock (AAPL)", markdown)
|
||
self.assertNotIn("核心结论", markdown)
|
||
|
||
def test_history_markdown_signal_metadata_uses_explicit_avoid_action(self) -> None:
|
||
result = AnalysisResult(
|
||
code="AAPL",
|
||
name="Apple",
|
||
sentiment_score=90,
|
||
trend_prediction="Bullish",
|
||
operation_advice="Hold",
|
||
analysis_summary="Risk remains elevated.",
|
||
report_language="en",
|
||
action="avoid",
|
||
action_label="Avoid",
|
||
)
|
||
|
||
markdown = HistoryService(self.db)._generate_single_stock_markdown(
|
||
result,
|
||
MagicMock(created_at=None),
|
||
)
|
||
|
||
self.assertIn("**🟡 Avoid** | Bullish", markdown)
|
||
self.assertNotIn("Strong Buy", markdown)
|
||
|
||
def test_history_markdown_returns_persisted_market_review_report(self) -> None:
|
||
"""Market review history should return the saved Markdown without rebuilding a stock report."""
|
||
result = AnalysisResult(
|
||
code="MARKET",
|
||
name="大盘复盘",
|
||
sentiment_score=50,
|
||
trend_prediction="大盘复盘",
|
||
operation_advice="查看复盘",
|
||
analysis_summary="今日大盘复盘",
|
||
raw_response="# 🎯 大盘复盘\n\n## 今日大盘\n\n复盘正文",
|
||
)
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="market_review_query_001",
|
||
report_type="market_review",
|
||
news_content="## 今日大盘\n\n复盘正文",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(
|
||
AnalysisHistory.query_id == "market_review_query_001"
|
||
).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
markdown = HistoryService(self.db).get_markdown_report(str(record_id))
|
||
|
||
self.assertEqual(markdown, "# 🎯 大盘复盘\n\n## 今日大盘\n\n复盘正文")
|
||
|
||
def test_history_markdown_collapses_unavailable_chip_structure(self) -> None:
|
||
result = AnalysisResult(
|
||
code="600519",
|
||
name="贵州茅台",
|
||
sentiment_score=72,
|
||
trend_prediction="看多",
|
||
operation_advice="持有",
|
||
analysis_summary="稳健",
|
||
dashboard={
|
||
"data_perspective": {
|
||
"chip_structure": {
|
||
"profit_ratio": "数据缺失,无法判断",
|
||
"avg_cost": "数据缺失,无法判断",
|
||
"concentration": "数据缺失,无法判断",
|
||
"chip_health": "数据缺失,无法判断",
|
||
}
|
||
}
|
||
},
|
||
)
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_chip_unavailable_001",
|
||
report_type="full",
|
||
news_content="news",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(
|
||
AnalysisHistory.query_id == "query_chip_unavailable_001"
|
||
).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
markdown = HistoryService(self.db).get_markdown_report(str(record_id))
|
||
|
||
self.assertIsNotNone(markdown)
|
||
self.assertIn("**筹码**: 筹码分布未启用或数据源暂不可用,未纳入筹码判断。", markdown)
|
||
self.assertEqual(markdown.count("数据缺失,无法判断"), 0)
|
||
|
||
def test_history_detail_returns_persisted_market_review_report(self) -> None:
|
||
"""Market review detail should surface the saved recap content for Web history clicks."""
|
||
if get_history_detail is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
report_content = "# 🎯 大盘复盘\n\n## 今日大盘\n\n复盘正文"
|
||
result = AnalysisResult(
|
||
code="MARKET",
|
||
name="大盘复盘",
|
||
sentiment_score=50,
|
||
trend_prediction="大盘复盘",
|
||
operation_advice="查看复盘",
|
||
analysis_summary="今日大盘复盘",
|
||
raw_response=report_content,
|
||
)
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="market_review_query_detail_001",
|
||
report_type="market_review",
|
||
news_content="## 今日大盘\n\n复盘正文",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(
|
||
AnalysisHistory.query_id == "market_review_query_detail_001"
|
||
).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
report = get_history_detail(str(record_id), db_manager=self.db)
|
||
|
||
self.assertEqual(report.meta.report_type, "market_review")
|
||
self.assertEqual(report.summary.analysis_summary, report_content)
|
||
self.assertIsNone(report.summary.action)
|
||
self.assertIsNone(report.summary.action_label)
|
||
self.assertEqual(report.details.news_content, report_content)
|
||
|
||
def test_history_detail_localizes_english_summary_fields(self) -> None:
|
||
"""History detail should localize summary enums for English reports."""
|
||
if get_history_detail is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
result = AnalysisResult(
|
||
code="AAPL",
|
||
name="股票AAPL",
|
||
sentiment_score=78,
|
||
trend_prediction="看多",
|
||
operation_advice="买入",
|
||
analysis_summary="Momentum remains constructive.",
|
||
report_language="en",
|
||
)
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_english_detail_001",
|
||
report_type="full",
|
||
news_content="news",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(
|
||
AnalysisHistory.query_id == "query_english_detail_001"
|
||
).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
report = get_history_detail(str(record_id), db_manager=self.db)
|
||
|
||
self.assertEqual(report.meta.report_language, "en")
|
||
self.assertEqual(report.meta.stock_name, "Unnamed Stock")
|
||
self.assertEqual(report.summary.operation_advice, "Buy")
|
||
self.assertEqual(report.summary.action, "buy")
|
||
self.assertEqual(report.summary.action_label, "Buy")
|
||
self.assertEqual(report.summary.trend_prediction, "Bullish")
|
||
self.assertEqual(report.summary.sentiment_label, "Bullish")
|
||
|
||
def test_history_markdown_uses_safe_bias_emoji_for_english_status(self) -> None:
|
||
"""English bias status should keep the correct non-risk emoji in markdown."""
|
||
result = AnalysisResult(
|
||
code="AAPL",
|
||
name="股票AAPL",
|
||
sentiment_score=80,
|
||
trend_prediction="Bullish",
|
||
operation_advice="Buy",
|
||
analysis_summary="Momentum remains constructive.",
|
||
report_language="en",
|
||
dashboard={
|
||
"data_perspective": {
|
||
"price_position": {
|
||
"current_price": 190.5,
|
||
"ma5": 188.0,
|
||
"ma10": 184.5,
|
||
"ma20": 179.2,
|
||
"bias_ma5": 1.33,
|
||
"bias_status": "Safe",
|
||
"support_level": 184.5,
|
||
"resistance_level": 195.0,
|
||
}
|
||
}
|
||
},
|
||
)
|
||
|
||
saved = self.db.save_analysis_history(
|
||
result=result,
|
||
query_id="query_english_markdown_bias_001",
|
||
report_type="full",
|
||
news_content="news",
|
||
context_snapshot=None,
|
||
save_snapshot=False,
|
||
)
|
||
self.assertGreater(saved, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
row = session.query(AnalysisHistory).filter(
|
||
AnalysisHistory.query_id == "query_english_markdown_bias_001"
|
||
).first()
|
||
if row is None:
|
||
self.fail("未找到保存的历史记录")
|
||
self.assertEqual(row.id, saved)
|
||
record_id = row.id
|
||
|
||
markdown = HistoryService(self.db).get_markdown_report(str(record_id))
|
||
|
||
self.assertIsNotNone(markdown)
|
||
self.assertIn("✅Safe", markdown)
|
||
self.assertNotIn("🚨Safe", markdown)
|
||
|
||
def test_delete_analysis_history_records_also_cleans_backtests_and_decision_signals(self) -> None:
|
||
"""删除历史记录时应一并清理关联回测结果和决策信号。"""
|
||
record_id = self._save_history("query_delete_001")
|
||
linked_signal_id = None
|
||
|
||
with self.db.session_scope() as session:
|
||
session.add(BacktestResult(
|
||
analysis_history_id=record_id,
|
||
code="600519",
|
||
analysis_date=None,
|
||
eval_window_days=10,
|
||
engine_version="v1",
|
||
eval_status="pending",
|
||
))
|
||
linked_signal = DecisionSignalRecord(
|
||
stock_code="600519",
|
||
stock_name="贵州茅台",
|
||
market="cn",
|
||
source_type="analysis",
|
||
source_report_id=record_id,
|
||
trace_id="trace-delete-linked",
|
||
market_phase="intraday",
|
||
trigger_source="api",
|
||
action="buy",
|
||
action_label="买入",
|
||
reason="linked",
|
||
plan_quality="minimal",
|
||
status="active",
|
||
)
|
||
session.add(linked_signal)
|
||
session.flush()
|
||
linked_signal_id = linked_signal.id
|
||
session.add(DecisionSignalOutcomeRecord(
|
||
signal_id=linked_signal_id,
|
||
horizon="3d",
|
||
engine_version="decision-signal-v1",
|
||
eval_status="completed",
|
||
outcome="hit",
|
||
action="buy",
|
||
market="cn",
|
||
source_type="analysis",
|
||
plan_quality="minimal",
|
||
holding_state="holding",
|
||
))
|
||
session.add(DecisionSignalFeedbackRecord(
|
||
signal_id=linked_signal_id,
|
||
feedback_value="useful",
|
||
source="api",
|
||
))
|
||
session.add(DecisionSignalRecord(
|
||
stock_code="000001",
|
||
stock_name="平安银行",
|
||
market="cn",
|
||
source_type="analysis",
|
||
source_report_id=record_id + 999,
|
||
trace_id="trace-delete-unrelated",
|
||
market_phase="intraday",
|
||
trigger_source="api",
|
||
action="watch",
|
||
action_label="观望",
|
||
reason="unrelated",
|
||
plan_quality="minimal",
|
||
status="active",
|
||
))
|
||
|
||
deleted = self.db.delete_analysis_history_records([record_id])
|
||
self.assertEqual(deleted, 1)
|
||
|
||
with self.db.get_session() as session:
|
||
self.assertIsNone(session.query(AnalysisHistory).filter(AnalysisHistory.id == record_id).first())
|
||
self.assertEqual(
|
||
session.query(BacktestResult).filter(BacktestResult.analysis_history_id == record_id).count(),
|
||
0,
|
||
)
|
||
self.assertEqual(
|
||
session.query(DecisionSignalRecord).filter(DecisionSignalRecord.source_report_id == record_id).count(),
|
||
0,
|
||
)
|
||
self.assertEqual(
|
||
session.query(DecisionSignalOutcomeRecord).filter(
|
||
DecisionSignalOutcomeRecord.signal_id == linked_signal_id
|
||
).count(),
|
||
0,
|
||
)
|
||
self.assertEqual(
|
||
session.query(DecisionSignalFeedbackRecord).filter(
|
||
DecisionSignalFeedbackRecord.signal_id == linked_signal_id
|
||
).count(),
|
||
0,
|
||
)
|
||
self.assertEqual(
|
||
session.query(DecisionSignalRecord).filter(DecisionSignalRecord.trace_id == "trace-delete-unrelated").count(),
|
||
1,
|
||
)
|
||
|
||
def test_delete_analysis_history_records_keeps_signals_for_nonexistent_history_id(self) -> None:
|
||
"""不存在的历史 ID 不应触发弱关联 DecisionSignal 清理。"""
|
||
missing_id = 987654321
|
||
|
||
with self.db.session_scope() as session:
|
||
session.add(DecisionSignalRecord(
|
||
stock_code="600519",
|
||
stock_name="贵州茅台",
|
||
market="cn",
|
||
source_type="manual",
|
||
source_report_id=missing_id,
|
||
trace_id="trace-delete-missing-history",
|
||
market_phase="intraday",
|
||
trigger_source="api",
|
||
action="watch",
|
||
action_label="观望",
|
||
reason="manual signal with unverified report id",
|
||
plan_quality="minimal",
|
||
status="active",
|
||
))
|
||
|
||
deleted = self.db.delete_analysis_history_records([missing_id])
|
||
self.assertEqual(deleted, 0)
|
||
|
||
with self.db.get_session() as session:
|
||
self.assertEqual(
|
||
session.query(DecisionSignalRecord).filter(
|
||
DecisionSignalRecord.trace_id == "trace-delete-missing-history"
|
||
).count(),
|
||
1,
|
||
)
|
||
|
||
def test_delete_analysis_history_records_keeps_manual_signal_with_same_report_id(self) -> None:
|
||
"""source_report_id 是弱引用,真实 history 删除不应误删 manual/pre-report 信号。"""
|
||
record_id = self._save_history("query_delete_manual_collision")
|
||
|
||
with self.db.session_scope() as session:
|
||
session.add(DecisionSignalRecord(
|
||
stock_code="600519",
|
||
stock_name="贵州茅台",
|
||
market="cn",
|
||
source_type="analysis",
|
||
source_report_id=record_id,
|
||
trace_id="trace-delete-analysis-bound",
|
||
market_phase="intraday",
|
||
trigger_source="api",
|
||
action="buy",
|
||
action_label="买入",
|
||
reason="history-bound signal",
|
||
plan_quality="minimal",
|
||
status="active",
|
||
))
|
||
session.add(DecisionSignalRecord(
|
||
stock_code="600519",
|
||
stock_name="贵州茅台",
|
||
market="cn",
|
||
source_type="manual",
|
||
source_report_id=record_id,
|
||
trace_id="trace-delete-manual-weak-ref",
|
||
market_phase="intraday",
|
||
trigger_source="api",
|
||
action="watch",
|
||
action_label="观望",
|
||
reason="manual signal with caller-supplied report id",
|
||
plan_quality="minimal",
|
||
status="active",
|
||
))
|
||
|
||
deleted = self.db.delete_analysis_history_records([record_id])
|
||
self.assertEqual(deleted, 1)
|
||
|
||
with self.db.get_session() as session:
|
||
self.assertEqual(
|
||
session.query(DecisionSignalRecord).filter(
|
||
DecisionSignalRecord.trace_id == "trace-delete-analysis-bound"
|
||
).count(),
|
||
0,
|
||
)
|
||
self.assertEqual(
|
||
session.query(DecisionSignalRecord).filter(
|
||
DecisionSignalRecord.trace_id == "trace-delete-manual-weak-ref"
|
||
).count(),
|
||
1,
|
||
)
|
||
|
||
def test_delete_analysis_history_records_cleans_only_existing_ids_in_mixed_batch(self) -> None:
|
||
"""混合存在/不存在 ID 时,只清理实际存在历史记录的关联数据。"""
|
||
record_id = self._save_history("query_delete_mixed")
|
||
missing_id = record_id + 987654
|
||
|
||
with self.db.session_scope() as session:
|
||
session.add(BacktestResult(
|
||
analysis_history_id=record_id,
|
||
code="600519",
|
||
analysis_date=None,
|
||
eval_window_days=10,
|
||
engine_version="v1",
|
||
eval_status="pending",
|
||
))
|
||
session.add(DecisionSignalRecord(
|
||
stock_code="600519",
|
||
stock_name="贵州茅台",
|
||
market="cn",
|
||
source_type="analysis",
|
||
source_report_id=record_id,
|
||
trace_id="trace-delete-mixed-linked",
|
||
market_phase="intraday",
|
||
trigger_source="api",
|
||
action="buy",
|
||
action_label="买入",
|
||
reason="linked",
|
||
plan_quality="minimal",
|
||
status="active",
|
||
))
|
||
session.add(DecisionSignalRecord(
|
||
stock_code="000001",
|
||
stock_name="平安银行",
|
||
market="cn",
|
||
source_type="manual",
|
||
source_report_id=missing_id,
|
||
trace_id="trace-delete-mixed-missing",
|
||
market_phase="intraday",
|
||
trigger_source="api",
|
||
action="watch",
|
||
action_label="观望",
|
||
reason="weak report id collision",
|
||
plan_quality="minimal",
|
||
status="active",
|
||
))
|
||
|
||
deleted = self.db.delete_analysis_history_records([record_id, missing_id])
|
||
self.assertEqual(deleted, 1)
|
||
|
||
with self.db.get_session() as session:
|
||
self.assertIsNone(session.query(AnalysisHistory).filter(AnalysisHistory.id == record_id).first())
|
||
self.assertEqual(
|
||
session.query(BacktestResult).filter(BacktestResult.analysis_history_id == record_id).count(),
|
||
0,
|
||
)
|
||
self.assertEqual(
|
||
session.query(DecisionSignalRecord).filter(
|
||
DecisionSignalRecord.trace_id == "trace-delete-mixed-linked"
|
||
).count(),
|
||
0,
|
||
)
|
||
self.assertEqual(
|
||
session.query(DecisionSignalRecord).filter(
|
||
DecisionSignalRecord.trace_id == "trace-delete-mixed-missing"
|
||
).count(),
|
||
1,
|
||
)
|
||
|
||
@patch("src.auth.is_auth_enabled", return_value=False)
|
||
def test_delete_history_api_deletes_selected_records(self, mock_auth) -> None:
|
||
"""DELETE /api/v1/history should remove only the requested records."""
|
||
if TestClient is None or create_app is None:
|
||
self.skipTest("fastapi is not installed in this test environment")
|
||
|
||
record_id_1 = self._save_history("query_delete_api_001")
|
||
record_id_2 = self._save_history("query_delete_api_002")
|
||
|
||
static_dir = Path(self._temp_dir.name) / "empty-static"
|
||
static_dir.mkdir(exist_ok=True)
|
||
client = TestClient(create_app(static_dir=static_dir))
|
||
|
||
response = client.request(
|
||
"DELETE",
|
||
"/api/v1/history",
|
||
json={"record_ids": [record_id_1]},
|
||
)
|
||
|
||
self.assertEqual(response.status_code, 200)
|
||
self.assertEqual(response.json().get("deleted"), 1)
|
||
|
||
with self.db.get_session() as session:
|
||
self.assertIsNone(session.query(AnalysisHistory).filter(AnalysisHistory.id == record_id_1).first())
|
||
self.assertIsNotNone(session.query(AnalysisHistory).filter(AnalysisHistory.id == record_id_2).first())
|
||
|
||
|
||
class HistoryItemSchemaNegativeSentimentTest(unittest.TestCase):
|
||
"""Regression: HistoryItem / ReportSummary must accept out-of-range sentiment_score from DB rows."""
|
||
|
||
@classmethod
|
||
def setUpClass(cls) -> None:
|
||
"""Import schema classes once for all tests, skipping gracefully when deps are missing."""
|
||
try:
|
||
from api.v1.schemas.history import HistoryItem, ReportSummary # type: ignore
|
||
except ModuleNotFoundError:
|
||
cls.HistoryItem = None
|
||
cls.ReportSummary = None
|
||
else:
|
||
cls.HistoryItem = HistoryItem
|
||
cls.ReportSummary = ReportSummary
|
||
|
||
def test_negative_sentiment_score_does_not_raise(self) -> None:
|
||
"""Bug #942: sentiment_score=-22 in DB should not cause Pydantic ValidationError."""
|
||
if self.HistoryItem is None:
|
||
self.skipTest("fastapi / pydantic not installed in this test environment")
|
||
|
||
item = self.HistoryItem(query_id="q1", stock_code="600519", sentiment_score=-22)
|
||
self.assertEqual(item.sentiment_score, -22)
|
||
|
||
def test_out_of_range_high_sentiment_score_does_not_raise(self) -> None:
|
||
"""HistoryItem should also accept scores above 100 from legacy data."""
|
||
if self.HistoryItem is None:
|
||
self.skipTest("fastapi / pydantic not installed in this test environment")
|
||
|
||
item = self.HistoryItem(query_id="q2", stock_code="600519", sentiment_score=150)
|
||
self.assertEqual(item.sentiment_score, 150)
|
||
|
||
def test_none_sentiment_score_is_allowed(self) -> None:
|
||
"""HistoryItem.sentiment_score=None should still be valid (optional field)."""
|
||
if self.HistoryItem is None:
|
||
self.skipTest("fastapi / pydantic not installed in this test environment")
|
||
|
||
item = self.HistoryItem(query_id="q3", stock_code="600519", sentiment_score=None)
|
||
self.assertIsNone(item.sentiment_score)
|
||
|
||
def test_report_summary_negative_sentiment_score_does_not_raise(self) -> None:
|
||
"""ReportSummary.sentiment_score should also accept negative values from legacy DB rows."""
|
||
if self.ReportSummary is None:
|
||
self.skipTest("fastapi / pydantic not installed in this test environment")
|
||
|
||
summary = self.ReportSummary(sentiment_score=-22)
|
||
self.assertEqual(summary.sentiment_score, -22)
|
||
|
||
def test_report_summary_out_of_range_high_sentiment_score_does_not_raise(self) -> None:
|
||
"""ReportSummary.sentiment_score should also accept scores above 100 from legacy data."""
|
||
if self.ReportSummary is None:
|
||
self.skipTest("fastapi / pydantic not installed in this test environment")
|
||
|
||
summary = self.ReportSummary(sentiment_score=150)
|
||
self.assertEqual(summary.sentiment_score, 150)
|
||
|
||
def test_report_summary_none_sentiment_score_is_allowed(self) -> None:
|
||
"""ReportSummary.sentiment_score=None should still be valid (optional field)."""
|
||
if self.ReportSummary is None:
|
||
self.skipTest("fastapi / pydantic not installed in this test environment")
|
||
|
||
summary = self.ReportSummary(sentiment_score=None)
|
||
self.assertIsNone(summary.sentiment_score)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|