Files
daily_stock_analysis/tests/test_analysis_context_pack_overview.py
mumu 0db0c78da8 feat: 个股分析接入当日大盘上下文 (#1623)
* feat: add daily market context for stock analysis

* fix(review-feedback-1623): Share the market context service across worker threads and Use the

* fix(review-feedback-1623): Avoid overwriting per-region context reports and Expand history lookup

* fix(review-feedback-1623): add position even though the guardrail supposedly softened the

* fix(review-feedback-1623): Avoid generating the CLI market review twice

* fix(review-feedback-1623): 修正

* fix(review-feedback-1623): 修复普通 CLI/调度运行中大盘复盘被上下文预热摘要替代的问题,并收敛 PR 描述与当前实现范围

* fix(review-feedback-1623): 修正保守护栏的误判风险,并同步收敛 PR 描述范围

* fix(review-feedback-1623): 处理并发生成大盘上下文的锁语义风险,并澄清/补充外部模型或运行时配置风险检测的依据

* fix(review-feedback-1623): 收敛大盘上下文预热对用户可见大盘复盘的行为影响,并补齐外部模型/API 与运行时配置检测风险的澄清或验证证据

* fix(review-feedback-1623): Do not skip multi-market reviews after one cached context and Do not

* fix(review-feedback-1623): Prime context with the effective trading date

* fix(review-feedback-1623): 收敛 PR 描述与实际 diff 的范围不一致,并澄清结构化检测提示的 provider/model/base

* fix(review-feedback-1623): 补一句“本 PR 不修改 LLM provider/model/base url、默认模型、配置清理或迁移逻辑”,避免后续审查继续误判

* fix(review-feedback-1623): 收敛 PR 描述与实际 diff 的范围矛盾

* fix(review-feedback-1623): 补齐这些承诺的实现

* fix(review-feedback-1623): 收敛 PR 描述与实际 diff 范围,避免以未实现能力作为验收项

* fix(review-feedback-1623): 收敛 PR 描述与实际范围,并澄清外部模型/API 与运行时配置检测风险

* fix(review-feedback-1623): Match freshly saved reviews to the target trade date

* fix(review-feedback-1623): preserve structured market-light risk signals

* fix(review-feedback-1623): Escape market-summary sentinels before prompting

* fix(review-feedback-1623): 修复同日缓存复用时把低敏摘要当完整大盘复盘输出的行为风险,并收敛 PR 描述与实际 diff

* fix(review-feedback-1623): 收敛

* fix(review-feedback-1623): 收敛 PR 描述与实际 scope

* fix(review-feedback-1623): 修正大盘复盘复用语义,并让 PR 描述与实际 scope 对齐

* fix(review-feedback-1623): 修正同日大盘上下文缓存复用语义,并让 PR 描述与实际 scope 对齐

* fix(review-feedback-1623): Disable cached market context when skipping market review

* fix(review-feedback-1623): 收敛 PR 描述与实际 scope,并补充外部模型/API 与运行时配置语义未变化的可核验证据

* fix(review-feedback-1623): 修正 PR 描述与实际 scope 的实质性不一致,并补齐或澄清 LLM/provider/model/base URL

* fix(review-feedback-1623): 修正 operation advice 契约漂移,并让 PR 描述/验收范围与当前实际 diff 对齐

* fix(review-feedback-1623): 收敛 main.py 中大盘复盘复用导致的通知/执行语义变化,并补对应回归测试

* fix(review-feedback-1623): 修复生成型大盘上下文未绑定当前 query id 的行为风险,并收敛 PR body 与实际 diff 范围

* fix(review-feedback-1623): 修复缓存语言匹配问题,并收敛 PR 描述与实际 diff/验收范围的一致性

* fix(review-feedback-1623): 收敛当前实现与描述/文档中的验收边界,并澄清 schedule/CLI 首次运行无历史大盘复盘时是否应生成并注入上下文

* fix(review-feedback-1623): 收敛 PR 描述与 docs/CHANGELOG.md 中关于验收边界的表述,避免与实际 diff 实质性矛盾

* fix(review-feedback-1623): 修正 PR 描述与实际 diff 的范围漂移

* fix(review-feedback-1623): Reuse the cached runtime market context on fallback

* fix(review-feedback-1623): PR 描述:当前完整改动文件列表没有 api/ 、apps/dsa-web/ 、日报状态表或四阶段日报持久化相关实现,但 PR

* fix(review-feedback-1623): 收敛描述范围

* fix(review-feedback-1623): 收敛并发生成复用路径的正确性风险,并修正 PR 描述的实际范围与验收项

* fix(review-feedback-1623): 收敛描述和验收范围

* fix(review-feedback-1623): PR 描述的 Background / Acceptance Criteria 仍包含独立 API、Web

* fix(review-feedback-1623): 收敛 PR 描述与实际实现边界,并补齐 LLM/provider/base url 相关兼容性说明或验证证据

* fix(review-feedback-1623): PR 描述与实际 diff 范围不一致:描述和验收项仍写有独立 API、Web

* fix(review-feedback-1623): Wait long enough for in-flight market reviews

* fix(review-feedback-1623): 修复共享 market review lock 释放后仍不生成匹配上下文的行为风险,并同步收敛 PR description 的实际范围

* fix(review-feedback-1623): 修复锁竞争 fallback 下丢失运行时服务的问题,并同步收敛 PR description 与实际 diff 范围

* fix(review-feedback-1623): 收敛 PR description 与实际改动范围

* fix(review-feedback-1623): PR 描述与实际 diff 存在实质性矛盾:描述声称提供独立 API、Web

* fix(review-feedback-1623): reuse runtime market context and handle English negations

* fix: honor daily market context config

* docs: remove unrelated changelog entries

* fix(review-feedback-1623): preserve the skipped market-review artifact

* fix(review-feedback-1623): add low position cap and are not in CONSERVATIVE TEXT MARKERS , so an
2026-06-13 10:33:04 +08:00

430 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""Tests for #1389 P4 public AnalysisContextPack overviews."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
from src.analysis_context_pack_overview import (
extract_analysis_context_pack_overview,
render_analysis_context_pack_overview,
sanitize_context_snapshot_for_api,
)
from src.analysis_context_pack_prompt import (
format_analysis_context_pack_prompt_section,
iter_analysis_context_pack_block_keys,
)
from src.schemas.analysis_context_pack import (
AnalysisContextBlock,
AnalysisContextItem,
AnalysisContextPack,
AnalysisSubject,
ContextFieldStatus,
DataQuality,
)
def _pack() -> AnalysisContextPack:
return AnalysisContextPack(
subject=AnalysisSubject(code="600519", stock_name="贵州茅台", market="cn"),
created_at=datetime(2026, 4, 10, 8, 30, tzinfo=timezone.utc),
blocks={
"news": AnalysisContextBlock(
status=ContextFieldStatus.MISSING,
items={
"content": AnalysisContextItem(
status=ContextFieldStatus.MISSING,
value="这是一段不应出现在公共 overview 的完整新闻正文",
missing_reason="news_context_missing",
),
"freshness": AnalysisContextItem(
status=ContextFieldStatus.MISSING,
value=None,
missing_reason="news_context_missing",
),
"provider": AnalysisContextItem(
status=ContextFieldStatus.MISSING,
value=None,
missing_reason="provider_timeout",
),
"backup": AnalysisContextItem(
status=ContextFieldStatus.MISSING,
value=None,
missing_reason="backup_unavailable",
),
"extra": AnalysisContextItem(
status=ContextFieldStatus.MISSING,
value=None,
missing_reason="extra_reason_not_exposed",
),
},
),
"quote": AnalysisContextBlock(
status=ContextFieldStatus.FALLBACK,
source="fallback_quote_provider",
warnings=["realtime_provider_fallback"],
items={
"price": AnalysisContextItem(
status=ContextFieldStatus.FALLBACK,
value=1880.0,
source="primary_quote_provider",
)
},
),
"technical": AnalysisContextBlock(
status=ContextFieldStatus.PARTIAL,
warnings=["intraday_realtime_overlay"],
items={
"trend_result": AnalysisContextItem(
status=ContextFieldStatus.AVAILABLE,
value={"trend_status": "多头排列", "ma5": 1800.0},
)
},
),
"fundamentals": AnalysisContextBlock(
status=ContextFieldStatus.AVAILABLE,
metadata={"api_key": "secret-key"},
items={
"fundamental_context": AnalysisContextItem(
status=ContextFieldStatus.AVAILABLE,
value={"pe": 28, "authorization": "Bearer secret"},
)
},
),
},
data_quality=DataQuality(
overall_score=76,
level="usable",
block_scores={
"quote": 65,
"daily_bars": 100,
"technical": 75,
"news": 35,
"fundamentals": 100,
"chip": 100,
},
limitations=["quote: fallback", "technical: partial"],
warnings=["intraday_realtime_overlay", "intraday_realtime_overlay"]
),
metadata={
"trigger_source": "api",
"news_result_count": 3,
"webhook_url": "https://hooks.example.test/secret",
},
)
def _walk_keys(value: Any) -> list[str]:
if isinstance(value, dict):
keys = list(value)
for child in value.values():
keys.extend(_walk_keys(child))
return keys
if isinstance(value, list):
keys: list[str] = []
for child in value:
keys.extend(_walk_keys(child))
return keys
return []
def test_renderer_outputs_only_public_schema_fields() -> None:
overview = render_analysis_context_pack_overview(_pack(), report_language="zh")
assert overview is not None
assert set(overview) == {
"pack_version",
"created_at",
"subject",
"blocks",
"counts",
"data_quality",
"warnings",
"metadata",
}
assert set(overview["subject"]) == {"code", "stock_name", "market"}
assert set(overview["metadata"]) == {"trigger_source", "news_result_count"}
assert set(overview["blocks"][0]) == {
"key",
"label",
"status",
"source",
"warnings",
"missing_reasons",
}
assert set(overview["data_quality"]) == {
"overall_score",
"level",
"block_scores",
"limitations",
}
assert overview["data_quality"] == {
"overall_score": 76,
"level": "usable",
"block_scores": {
"quote": 65,
"daily_bars": 100,
"technical": 75,
"news": 35,
"fundamentals": 100,
"chip": 100,
},
"limitations": ["quote: fallback", "technical: partial"],
}
def test_renderer_does_not_dump_items_values_payloads_or_sensitive_markers() -> None:
overview = render_analysis_context_pack_overview(_pack(), report_language="zh")
assert overview is not None
keys = set(_walk_keys(overview))
assert "items" not in keys
assert "value" not in keys
assert "metadata" in keys
rendered = json.dumps(overview, ensure_ascii=False)
assert "完整新闻正文" not in rendered
assert "多头排列" not in rendered
assert "secret-key" not in rendered
assert "hooks.example.test" not in rendered
assert "authorization" not in rendered
assert "api_key" not in rendered
assert "webhook_url" not in rendered
def test_counts_are_by_block_status_and_missing_reasons_are_deduped() -> None:
overview = render_analysis_context_pack_overview(_pack(), report_language="zh")
assert overview is not None
assert overview["counts"] == {
"available": 1,
"missing": 1,
"not_supported": 0,
"fallback": 1,
"stale": 0,
"estimated": 0,
"partial": 1,
"fetch_failed": 0,
}
news_block = next(block for block in overview["blocks"] if block["key"] == "news")
assert news_block["missing_reasons"] == [
"news_context_missing",
"provider_timeout",
"backup_unavailable",
]
def test_labels_follow_report_language_and_prompt_block_order() -> None:
pack = _pack()
overview_zh = render_analysis_context_pack_overview(pack, report_language="zh")
overview_en = render_analysis_context_pack_overview(pack, report_language="en")
assert overview_zh is not None
assert overview_en is not None
assert [block["key"] for block in overview_zh["blocks"]] == iter_analysis_context_pack_block_keys(
pack.model_dump(mode="json")["blocks"]
)
assert [block["label"] for block in overview_zh["blocks"][:3]] == [
"行情",
"技术",
"基本面",
]
assert [block["label"] for block in overview_en["blocks"][:3]] == [
"quote",
"technical",
"fundamentals",
]
prompt = format_analysis_context_pack_prompt_section(pack, report_language="zh")
assert prompt.index("行情:") < prompt.index("技术:") < prompt.index("基本面:")
def test_extract_and_sanitize_handle_json_snapshot_strings() -> None:
overview = render_analysis_context_pack_overview(_pack(), report_language="zh")
snapshot = json.dumps(
{
"enhanced_context": {
"code": "600519",
"daily_market_context_summary": "仅供Prompt历史复盘摘要",
"portfolio_context": {
"quantity": 100,
"avg_cost": 1800,
},
"daily_market_context": {"summary": "大盘偏弱,谨慎"},
},
"portfolio_context": {"total_cost": 180000},
"daily_market_context_summary": "根快照大盘摘要(应清理)",
"analysis_context_pack_overview": overview,
"market_phase_summary": {"phase": "intraday", "market": "cn"},
},
ensure_ascii=False,
)
extracted = extract_analysis_context_pack_overview(snapshot)
sanitized = sanitize_context_snapshot_for_api(snapshot)
assert extracted is not None
assert extracted["subject"]["code"] == "600519"
assert sanitized == {
"enhanced_context": {
"code": "600519",
"daily_market_context": {"summary": "大盘偏弱,谨慎"},
}
}
def test_extract_reprojects_persisted_overview_to_public_schema() -> None:
snapshot = {
"analysis_context_pack_overview": {
"pack_version": "1.0",
"created_at": "2026-04-10T08:30:00+00:00",
"subject": {
"code": "600519",
"stock_name": "贵州茅台",
"market": "cn",
"api_key": "secret-key",
},
"blocks": [
{
"key": "quote",
"label": "行情",
"status": "available",
"source": "mock",
"warnings": ["ok", "ok"],
"missing_reasons": [],
"items": {"price": {"value": 1880.0}},
},
{
"key": "news",
"label": "新闻",
"status": "missing",
"source": None,
"warnings": [],
"missing_reasons": [
"news_context_missing",
"provider_timeout",
"backup_unavailable",
"extra_reason_not_exposed",
],
"content": "完整新闻正文不应出现",
},
],
"counts": {
"available": 999,
"missing": 999,
"not_supported": 999,
"fallback": 999,
"stale": 999,
"estimated": 999,
"partial": 999,
"fetch_failed": 999,
},
"data_quality": {
"overall_score": 76,
"level": "usable",
"block_scores": {
"quote": 65,
"news": 35,
"api_key": 99,
"technical": 999,
},
"limitations": [
"quote: fallback",
"token=secret should not pass",
"technical: partial",
"technical: partial",
],
"warnings": ["not-public"],
},
"warnings": ["top_warning", "top_warning"],
"metadata": {
"trigger_source": "api",
"news_result_count": 3,
"webhook_url": "https://hooks.example.test/secret",
},
"value": "raw payload should not pass through",
}
}
extracted = extract_analysis_context_pack_overview(snapshot)
assert extracted is not None
assert extracted["counts"] == {
"available": 1,
"missing": 1,
"not_supported": 0,
"fallback": 0,
"stale": 0,
"estimated": 0,
"partial": 0,
"fetch_failed": 0,
}
assert extracted["data_quality"] == {
"overall_score": 76,
"level": "usable",
"block_scores": {"quote": 65, "news": 35},
"limitations": [
"quote: fallback",
"[REDACTED]",
"technical: partial",
],
}
assert extracted["blocks"][1]["missing_reasons"] == [
"news_context_missing",
"provider_timeout",
"backup_unavailable",
]
rendered = json.dumps(extracted, ensure_ascii=False)
assert "items" not in rendered
assert "value" not in rendered
assert "完整新闻正文" not in rendered
assert "webhook_url" not in rendered
assert "hooks.example.test" not in rendered
assert "secret-key" not in rendered
def test_extract_accepts_legacy_overview_without_data_quality() -> None:
extracted = extract_analysis_context_pack_overview(
{
"analysis_context_pack_overview": {
"pack_version": "1.0",
"subject": {"code": "600519"},
"blocks": [
{
"key": "quote",
"label": "行情",
"status": "available",
"source": "mock",
"warnings": [],
"missing_reasons": [],
}
],
"metadata": {},
}
}
)
assert extracted is not None
assert "data_quality" not in extracted
assert extracted["counts"]["fetch_failed"] == 0
def test_extract_returns_none_for_malformed_persisted_overview() -> None:
assert extract_analysis_context_pack_overview(
{
"analysis_context_pack_overview": {
"subject": {"code": "600519"},
"blocks": [{"key": "quote", "label": "行情", "status": "bad_status"}],
}
}
) is None
assert extract_analysis_context_pack_overview(
{
"analysis_context_pack_overview": {
"subject": {"code": "600519"},
"blocks": "not-a-list",
}
}
) is None