From 79b284014d8a90644844e8fa5bdbb7ce4273cd59 Mon Sep 17 00:00:00 2001 From: Alfred Date: Mon, 25 May 2026 20:27:24 +0800 Subject: [PATCH] feat: inject market phase prompt context (#1442) --- docs/CHANGELOG.md | 1 + docs/full-guide.md | 8 +- docs/full-guide_EN.md | 8 +- src/agent/agents/base_agent.py | 10 ++ src/agent/executor.py | 8 ++ src/analyzer.py | 7 + src/market_phase_prompt.py | 215 +++++++++++++++++++++++++++++ tests/test_agent_executor.py | 11 +- tests/test_analyzer_news_prompt.py | 43 ++++++ tests/test_market_phase_prompt.py | 129 +++++++++++++++++ tests/test_multi_agent.py | 33 +++++ 11 files changed, 470 insertions(+), 3 deletions(-) create mode 100644 src/market_phase_prompt.py create mode 100644 tests/test_market_phase_prompt.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f473010e2..a1d50458a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - [改进] 新增运行态市场阶段上下文构造与降级测试。 - [文档] 新增 AnalysisContextPack P0 上下文盘点,明确字段质量状态、现有状态映射和首版 pack 边界。 - [修复] 恢复 Agent/历史兼容快照中的关联板块与板块联动字段提取,修复新版首页报告缺少“板块联动”的回归问题。 +- [改进] P2-min:LLM Prompt 注入市场阶段上下文。 ## [3.18.0] - 2026-05-21 diff --git a/docs/full-guide.md b/docs/full-guide.md index 3edbfa4bb..e7bce4926 100644 --- a/docs/full-guide.md +++ b/docs/full-guide.md @@ -737,7 +737,13 @@ P0 不做:不接入 pipeline / Agent / API / Web / Bot,不修改报告 schem P1a 在普通个股分析 pipeline、legacy Agent context 和 multi-agent `ctx.meta` 中构造并传递内部 `market_phase_context`。该上下文包含市场、阶段、市场本地日期、最新可复用日线日期、交易日/开市/partial bar 三态标记、开收盘分钟数 best-effort 估算,以及 `unknown_market`、`calendar_unavailable`、`calendar_error` 等降级 warning code。 -P1a 仍不改变 Prompt 文案、API/Web/Bot 参数、报告结构、history/task status 稳定 metadata 或 quote freshness/data quality 语义;普通分析 history snapshot 和 Agent history snapshot 会剥离该运行态字段。后续 P1b 再定义可持久化 metadata 与任务状态展示契约。 +P1a 本身不改变 Prompt 文案、API/Web/Bot 参数、报告结构、history/task status 稳定 metadata 或 quote freshness/data quality 语义;普通分析 history snapshot 和 Agent history snapshot 会剥离该运行态字段。后续 P1b 再定义可持久化 metadata 与任务状态展示契约。 + +#### 市场阶段 Prompt 注入(Issue #1386 P2-min) + +P2-min 开始在已获得 `market_phase_context` 的分析路径中,把运行态市场阶段渲染为 LLM 可读的 Prompt 区块。普通分析、single Agent 和 multi-agent 会在 Prompt 中看到当前阶段、市场本地时间、最新可复用完整日线日期以及最小阶段约束:盘前不得描述“今日走势已经发生”,盘中 / 午间 / 临近收盘需说明最后一根日线可能未完成,盘后保留完整交易日复盘语义,非交易日或未知阶段保持保守表述。 + +P2-min 仍不新增 API/Web/Bot 参数,不写入 history/task status/report metadata,不改变报告 JSON schema,也不引入完整 quote freshness、fallback、stale 或 data_quality 契约。Bot/API 直连 Agent 若未经过 P1a pipeline 构建 `market_phase_context`,仍保持旧行为;入口透传和可见展示留给后续 P4+。 #### 使用 Crontab diff --git a/docs/full-guide_EN.md b/docs/full-guide_EN.md index dcca9bccd..fb73a3a79 100644 --- a/docs/full-guide_EN.md +++ b/docs/full-guide_EN.md @@ -625,7 +625,13 @@ P0 does not connect this baseline to pipeline / Agent / API / Web / Bot, does no P1a constructs and passes an internal `market_phase_context` through the regular stock-analysis pipeline, the legacy Agent context, and multi-agent `ctx.meta`. The context includes market, phase, market-local date, effective daily-bar date, trading-day / market-open / partial-bar tristate flags, best-effort open/close minute estimates, and degradation warning codes such as `unknown_market`, `calendar_unavailable`, and `calendar_error`. -P1a still does not change prompt wording, API/Web/Bot parameters, report schemas, stable history/task-status metadata, or quote freshness/data quality semantics. Regular history snapshots and Agent history snapshots strip this runtime-only field. P1b is left to define persistent metadata and task-status display contracts. +P1a itself does not change prompt wording, API/Web/Bot parameters, report schemas, stable history/task-status metadata, or quote freshness/data quality semantics. Regular history snapshots and Agent history snapshots strip this runtime-only field. P1b is left to define persistent metadata and task-status display contracts. + +### Market Phase Prompt Injection (Issue #1386 P2-min) + +P2-min starts rendering the runtime market phase into an LLM-readable prompt section for analysis paths that already receive `market_phase_context`. Regular analysis, single Agent, and multi-agent prompts can now see the current phase, market-local time, latest reusable complete daily-bar date, and the minimal phase constraints: pre-market runs must not describe today's price action as already happened, intraday / lunch-break / near-close runs must treat the latest daily bar as potentially unfinished, post-market runs can keep the complete-session recap style, and non-trading or unknown phases should stay conservative. + +P2-min still does not add API/Web/Bot parameters, persist phase into history/task status/report metadata, change report JSON schemas, or introduce the full quote freshness, fallback, stale, or data-quality contract. Bot/API direct Agent entrypoints that do not go through the P1a pipeline to build `market_phase_context` keep their previous behavior; entrypoint propagation and visible labels are left to later P4+ work. --- diff --git a/src/agent/agents/base_agent.py b/src/agent/agents/base_agent.py index 4719dc472..e36c0782b 100644 --- a/src/agent/agents/base_agent.py +++ b/src/agent/agents/base_agent.py @@ -21,6 +21,8 @@ from src.agent.protocols import AgentContext, AgentOpinion, StageResult, StageSt from src.agent.runner import RunLoopResult, run_agent_loop from src.agent.skills.defaults import extract_skill_id from src.agent.tools.registry import ToolRegistry +from src.market_phase_prompt import format_market_phase_prompt_section +from src.report_language import normalize_report_language logger = logging.getLogger(__name__) @@ -169,6 +171,14 @@ class BaseAgent(ABC): if role in {"user", "assistant", "system"} and isinstance(content, str) and content: messages.append({"role": role, "content": content}) + report_language = normalize_report_language(ctx.meta.get("report_language", "zh")) + market_phase_section = format_market_phase_prompt_section( + ctx.meta.get("market_phase_context"), + report_language=report_language, + ) + if market_phase_section: + messages.append({"role": "user", "content": market_phase_section}) + # Inject pre-fetched data as a synthetic assistant context cached_data = self._inject_cached_data(ctx) if cached_data: diff --git a/src/agent/executor.py b/src/agent/executor.py index b225e552a..898f44c60 100644 --- a/src/agent/executor.py +++ b/src/agent/executor.py @@ -24,6 +24,7 @@ from src.agent.runner import run_agent_loop, parse_dashboard_json from src.agent.tools.registry import ToolRegistry from src.report_language import normalize_report_language from src.market_context import get_market_role, get_market_guidelines +from src.market_phase_prompt import format_market_phase_prompt_section logger = logging.getLogger(__name__) @@ -662,6 +663,13 @@ class AgentExecutor: else: parts.append("输出语言: 中文(所有 JSON 键名保持不变,所有面向用户的文本值使用中文)") + market_phase_section = format_market_phase_prompt_section( + context.get("market_phase_context"), + report_language=report_language, + ) + if market_phase_section: + parts.append(market_phase_section) + # Inject pre-fetched context data to avoid redundant fetches if context.get("realtime_quote"): parts.append(f"\n[系统已获取的实时行情]\n{json.dumps(context['realtime_quote'], ensure_ascii=False)}") diff --git a/src/analyzer.py b/src/analyzer.py index e2347a644..670243735 100644 --- a/src/analyzer.py +++ b/src/analyzer.py @@ -56,6 +56,7 @@ from src.report_language import ( ) from src.schemas.report_schema import AnalysisReportSchema from src.market_context import get_market_role, get_market_guidelines +from src.market_phase_prompt import format_market_phase_prompt_section logger = logging.getLogger(__name__) @@ -2746,6 +2747,12 @@ class GeminiAnalyzer: | 分析日期 | {context.get('date', unknown_text)} | --- +""" + prompt += format_market_phase_prompt_section( + context.get("market_phase_context"), + report_language=report_language, + ) + prompt += """ ## 📈 技术面数据 diff --git a/src/market_phase_prompt.py b/src/market_phase_prompt.py new file mode 100644 index 000000000..d980c745b --- /dev/null +++ b/src/market_phase_prompt.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- +"""Prompt rendering for Issue #1386 runtime market phase context.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + + +_PHASE_LABELS_ZH = { + "premarket": "盘前", + "intraday": "盘中", + "lunch_break": "午间休市", + "closing_auction": "临近收盘", + "postmarket": "盘后", + "non_trading": "非交易日", + "unknown": "未知阶段", +} + +_PHASE_LABELS_EN = { + "premarket": "pre-market", + "intraday": "intraday", + "lunch_break": "lunch break", + "closing_auction": "near close", + "postmarket": "post-market", + "non_trading": "non-trading day", + "unknown": "unknown phase", +} + +_KNOWN_PHASES = set(_PHASE_LABELS_ZH) + +_WARNING_LABELS_ZH = { + "unknown_market": "未知市场", + "calendar_unavailable": "交易日历不可用", + "calendar_error": "交易日历异常", +} + +_WARNING_LABELS_EN = { + "unknown_market": "unknown market", + "calendar_unavailable": "trading calendar unavailable", + "calendar_error": "trading calendar error", +} + + +def format_market_phase_prompt_section( + market_phase_context: Optional[Dict[str, Any]], + *, + report_language: str = "zh", +) -> str: + """Return a human-readable prompt section for a P1a market phase payload. + + The helper is intentionally narrow: callers pass the runtime dict produced + by ``MarketPhaseContext.to_dict()`` when available. Missing optional fields + are omitted, unknown phases use the conservative ``unknown`` template, and + raw runtime keys such as ``market_phase_context`` are never rendered. + """ + if not isinstance(market_phase_context, dict) or not market_phase_context: + return "" + + lang = "en" if str(report_language or "").lower() == "en" else "zh" + raw_phase = market_phase_context.get("phase") + phase = raw_phase if isinstance(raw_phase, str) and raw_phase in _KNOWN_PHASES else "unknown" + + if lang == "en": + return _format_en(market_phase_context, phase) + return _format_zh(market_phase_context, phase) + + +def _format_zh(ctx: Dict[str, Any], phase: str) -> str: + label = _PHASE_LABELS_ZH[phase] + lines = ["", "## 市场阶段上下文", f"- 当前市场阶段:{label}"] + lines.extend(_metadata_lines_zh(ctx)) + lines.append(f"- 阶段约束:{_phase_rule_zh(ctx, phase)}") + + warning_text = _warning_text(ctx.get("warnings"), lang="zh") + if warning_text: + lines.append(f"- 降级说明:{warning_text},请保持保守表述。") + + return "\n".join(lines) + "\n" + + +def _format_en(ctx: Dict[str, Any], phase: str) -> str: + label = _PHASE_LABELS_EN[phase] + lines = ["", "## Market Phase Context", f"- Current market phase: {label}"] + lines.extend(_metadata_lines_en(ctx)) + lines.append(f"- Phase constraint: {_phase_rule_en(ctx, phase)}") + + warning_text = _warning_text(ctx.get("warnings"), lang="en") + if warning_text: + lines.append(f"- Degradation note: {warning_text}; keep the analysis conservative.") + + return "\n".join(lines) + "\n" + + +def _metadata_lines_zh(ctx: Dict[str, Any]) -> List[str]: + items: List[str] = [] + market = _string_value(ctx.get("market")) + market_time = _string_value(ctx.get("market_local_time")) + effective_date = _string_value(ctx.get("effective_daily_bar_date")) + minutes_to_open = _int_like(ctx.get("minutes_to_open")) + minutes_to_close = _int_like(ctx.get("minutes_to_close")) + + if market: + items.append(f"- 市场:{market}") + if market_time: + items.append(f"- 市场本地时间:{market_time}") + if effective_date: + items.append(f"- 最新可复用完整日线日期:{effective_date}") + if minutes_to_open is not None: + items.append(f"- 距常规开盘约 {minutes_to_open} 分钟。") + if minutes_to_close is not None: + items.append(f"- 距常规收盘约 {minutes_to_close} 分钟。") + return items + + +def _metadata_lines_en(ctx: Dict[str, Any]) -> List[str]: + items: List[str] = [] + market = _string_value(ctx.get("market")) + market_time = _string_value(ctx.get("market_local_time")) + effective_date = _string_value(ctx.get("effective_daily_bar_date")) + minutes_to_open = _int_like(ctx.get("minutes_to_open")) + minutes_to_close = _int_like(ctx.get("minutes_to_close")) + + if market: + items.append(f"- Market: {market}") + if market_time: + items.append(f"- Market-local time: {market_time}") + if effective_date: + items.append(f"- Latest reusable complete daily bar date: {effective_date}") + if minutes_to_open is not None: + items.append(f"- About {minutes_to_open} minutes until the regular session opens.") + if minutes_to_close is not None: + items.append(f"- About {minutes_to_close} minutes until the regular session closes.") + return items + + +def _phase_rule_zh(ctx: Dict[str, Any], phase: str) -> str: + effective_date = _string_value(ctx.get("effective_daily_bar_date")) + date_hint = f"({effective_date})" if effective_date else "" + + if phase == "premarket": + return ( + f"当前尚未开盘,不得描述“今日走势已经发生”;只能基于上一完整交易日{date_hint}" + "和盘前信息生成开盘计划、观察价位与风险预案。" + ) + if phase in {"intraday", "lunch_break", "closing_auction"}: + base = "当前不是盘后复盘,应聚焦当前盘中状态、观察条件与下一次检查点。" + if ctx.get("is_partial_bar") is True: + base += " 今日最后一根日线可能尚未完成,不得当作完整日线复盘。" + if phase == "lunch_break": + base += " 午间休市期间应说明后续复盘仍需下午交易确认。" + if phase == "closing_auction": + base += " 临近收盘时应更偏向收盘前风险控制和是否隔夜持仓。" + return base + if phase == "postmarket": + return "常规交易时段已结束,可以保留完整交易日复盘语义。" + if phase == "non_trading": + return f"当前不是交易日或属于强制运行,只能基于上一完整交易日{date_hint}和已知事件分析,不得伪造今日盘中走势。" + return "当前市场阶段不可可靠推断,不要补全不存在的盘中或盘前事实,结论需保持保守。" + + +def _phase_rule_en(ctx: Dict[str, Any], phase: str) -> str: + effective_date = _string_value(ctx.get("effective_daily_bar_date")) + date_hint = f" ({effective_date})" if effective_date else "" + + if phase == "premarket": + return ( + f"The regular session has not opened. Do not describe today's price action as already happened; " + f"use only the latest complete daily bar{date_hint} and pre-market information for the opening plan." + ) + if phase in {"intraday", "lunch_break", "closing_auction"}: + base = "This is not a post-market recap. Focus on the current intraday state, watch conditions, and next check point." + if ctx.get("is_partial_bar") is True: + base += " The latest daily bar may be unfinished; do not treat it as a complete daily candle." + if phase == "lunch_break": + base += " During the lunch break, later confirmation depends on the afternoon session." + if phase == "closing_auction": + base += " Near the close, emphasize end-of-day risk control and overnight-position decisions." + return base + if phase == "postmarket": + return "The regular session has ended, so a complete-session recap style is acceptable." + if phase == "non_trading": + return ( + f"This is a non-trading day or forced run. Use the latest complete daily bar{date_hint} and known events; " + "do not invent today's intraday movement." + ) + return "The market phase cannot be inferred reliably. Do not invent pre-market or intraday facts, and keep conclusions conservative." + + +def _warning_text(value: Any, *, lang: str) -> str: + if not isinstance(value, list): + return "" + labels = _WARNING_LABELS_EN if lang == "en" else _WARNING_LABELS_ZH + rendered = [labels[item] for item in value if isinstance(item, str) and item in labels] + if not rendered: + return "" + if lang == "en": + return ", ".join(rendered) + return "、".join(rendered) + + +def _string_value(value: Any) -> str: + if value is None: + return "" + text = str(value).strip() + return text + + +def _int_like(value: Any) -> Optional[int]: + if isinstance(value, bool) or value is None: + return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + return None diff --git a/tests/test_agent_executor.py b/tests/test_agent_executor.py index 43ca156f9..669076175 100644 --- a/tests/test_agent_executor.py +++ b/tests/test_agent_executor.py @@ -732,18 +732,27 @@ class TestBuildUserMessage(unittest.TestCase): self.assertIn("股票代码: 600519", msg) self.assertIn("报告类型: daily", msg) - def test_message_does_not_render_market_phase_context(self): + def test_message_renders_readable_market_phase_context_without_raw_keys(self): msg = self.executor._build_user_message( "Analyze", context={ "stock_code": "600519", + "report_language": "zh", "market_phase_context": { "phase": "intraday", + "market": "cn", + "market_local_time": "2026-03-27T10:00:00+08:00", + "effective_daily_bar_date": "2026-03-26", "is_partial_bar": True, }, + "realtime_quote": {"price": 1880.0}, }, ) self.assertIn("股票代码: 600519", msg) + self.assertIn("市场阶段上下文", msg) + self.assertIn("盘中", msg) + self.assertIn("不得当作完整日线复盘", msg) + self.assertLess(msg.index("市场阶段上下文"), msg.index("[系统已获取的实时行情]")) self.assertNotIn("market_phase_context", msg) self.assertNotIn("is_partial_bar", msg) self.assertNotIn("is_market_open_now", msg) diff --git a/tests/test_analyzer_news_prompt.py b/tests/test_analyzer_news_prompt.py index 30bbbd0ae..31458bf6b 100644 --- a/tests/test_analyzer_news_prompt.py +++ b/tests/test_analyzer_news_prompt.py @@ -218,6 +218,49 @@ class AnalyzerNewsPromptTestCase(unittest.TestCase): self.assertIn("近1日的新闻搜索结果", prompt) self.assertIn("超出近1日窗口的新闻一律忽略", prompt) + def test_format_prompt_injects_market_phase_before_technical_data(self) -> None: + with patch.object(GeminiAnalyzer, "_init_litellm", return_value=None): + analyzer = GeminiAnalyzer() + + context = { + "code": "600519", + "stock_name": "贵州茅台", + "date": "2026-03-27", + "today": {}, + "market_phase_context": { + "market": "cn", + "phase": "premarket", + "market_local_time": "2026-03-27T09:00:00+08:00", + "effective_daily_bar_date": "2026-03-26", + "is_partial_bar": False, + "minutes_to_open": 30, + "warnings": [], + }, + } + + prompt = analyzer._format_prompt(context, "贵州茅台", news_context=None) + + phase_index = prompt.index("市场阶段上下文") + technical_index = prompt.index("技术面数据") + self.assertLess(phase_index, technical_index) + self.assertIn("盘前", prompt) + self.assertIn("不得描述“今日走势已经发生”", prompt) + + def test_format_prompt_omits_market_phase_section_without_context(self) -> None: + with patch.object(GeminiAnalyzer, "_init_litellm", return_value=None): + analyzer = GeminiAnalyzer() + + context = { + "code": "600519", + "stock_name": "贵州茅台", + "date": "2026-03-27", + "today": {}, + } + + prompt = analyzer._format_prompt(context, "贵州茅台", news_context=None) + + self.assertNotIn("市场阶段上下文", prompt) + def test_format_prompt_omits_legacy_trend_checks_for_nondefault_skill_mode(self) -> None: with patch.object(GeminiAnalyzer, "_init_litellm", return_value=None): analyzer = GeminiAnalyzer( diff --git a/tests/test_market_phase_prompt.py b/tests/test_market_phase_prompt.py new file mode 100644 index 000000000..e7d95baeb --- /dev/null +++ b/tests/test_market_phase_prompt.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +"""Tests for Issue #1386 P2-min market phase prompt rendering.""" + +import unittest + +from src.market_phase_prompt import format_market_phase_prompt_section + + +def _ctx(**overrides): + payload = { + "market": "cn", + "phase": "intraday", + "market_local_time": "2026-03-27T10:00:00+08:00", + "effective_daily_bar_date": "2026-03-26", + "is_partial_bar": True, + "minutes_to_open": None, + "minutes_to_close": 300, + "warnings": [], + "trigger_source": "system", + "analysis_intent": "auto", + } + payload.update(overrides) + return payload + + +class MarketPhasePromptTestCase(unittest.TestCase): + def test_empty_or_invalid_context_returns_empty_section(self): + self.assertEqual(format_market_phase_prompt_section(None), "") + self.assertEqual(format_market_phase_prompt_section({}), "") + self.assertEqual(format_market_phase_prompt_section("intraday"), "") + + def test_premarket_mentions_opening_plan_and_completed_daily_bar(self): + section = format_market_phase_prompt_section( + _ctx(phase="premarket", is_partial_bar=False, minutes_to_open=30) + ) + + self.assertIn("市场阶段上下文", section) + self.assertIn("盘前", section) + self.assertIn("尚未开盘", section) + self.assertIn("不得描述“今日走势已经发生”", section) + self.assertIn("上一完整交易日", section) + self.assertIn("2026-03-26", section) + self.assertIn("距常规开盘约 30 分钟", section) + + def test_intraday_partial_bar_warns_against_full_daily_recap(self): + section = format_market_phase_prompt_section(_ctx()) + + self.assertIn("盘中", section) + self.assertIn("当前不是盘后复盘", section) + self.assertIn("最后一根日线可能尚未完成", section) + self.assertIn("不得当作完整日线复盘", section) + self.assertIn("距常规收盘约 300 分钟", section) + + def test_lunch_break_and_closing_auction_add_phase_specific_guidance(self): + lunch = format_market_phase_prompt_section(_ctx(phase="lunch_break")) + closing = format_market_phase_prompt_section(_ctx(phase="closing_auction")) + + self.assertIn("午间休市", lunch) + self.assertIn("下午交易确认", lunch) + self.assertIn("临近收盘", closing) + self.assertIn("是否隔夜持仓", closing) + + def test_postmarket_keeps_recap_semantics(self): + section = format_market_phase_prompt_section( + _ctx(phase="postmarket", is_partial_bar=False, minutes_to_close=None) + ) + + self.assertIn("盘后", section) + self.assertIn("完整交易日复盘语义", section) + + def test_non_trading_prevents_fake_intraday_movement(self): + section = format_market_phase_prompt_section( + _ctx(phase="non_trading", is_partial_bar=False, minutes_to_close=None) + ) + + self.assertIn("非交易日", section) + self.assertIn("不得伪造今日盘中走势", section) + self.assertIn("2026-03-26", section) + + def test_unknown_phase_and_warnings_are_conservative_without_raw_codes(self): + section = format_market_phase_prompt_section( + _ctx(phase="not_a_phase", warnings=["calendar_unavailable", "unknown_warning"]) + ) + + self.assertIn("未知阶段", section) + self.assertIn("不可可靠推断", section) + self.assertIn("交易日历不可用", section) + self.assertNotIn("calendar_unavailable", section) + self.assertNotIn("unknown_warning", section) + + def test_missing_phase_uses_unknown_template(self): + payload = _ctx() + payload.pop("phase") + + section = format_market_phase_prompt_section(payload) + + self.assertIn("未知阶段", section) + self.assertIn("不可可靠推断", section) + + def test_warnings_non_list_is_ignored(self): + section = format_market_phase_prompt_section(_ctx(warnings="calendar_unavailable")) + + self.assertNotIn("降级说明", section) + self.assertIn("盘中", section) + + def test_english_mode_outputs_readable_english_constraints(self): + section = format_market_phase_prompt_section( + _ctx(phase="premarket", is_partial_bar=False), + report_language="en", + ) + + self.assertIn("Market Phase Context", section) + self.assertIn("pre-market", section) + self.assertIn("has not opened", section) + self.assertIn("Do not describe today's price action as already happened", section) + self.assertNotIn("(premarket)", section) + + def test_output_does_not_leak_runtime_raw_keys(self): + section = format_market_phase_prompt_section(_ctx()) + + self.assertNotIn("market_phase_context", section) + self.assertNotIn("is_partial_bar", section) + self.assertNotIn("trigger_source", section) + self.assertNotIn("analysis_intent", section) + self.assertNotIn("intraday", section) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_multi_agent.py b/tests/test_multi_agent.py index 043801294..c0e90181a 100644 --- a/tests/test_multi_agent.py +++ b/tests/test_multi_agent.py @@ -1102,6 +1102,39 @@ class TestBaseAgentMessageAssembly(unittest.TestCase): self.assertEqual(messages[2], {"role": "assistant", "content": "old answer"}) self.assertEqual(messages[-1], {"role": "user", "content": "current turn"}) + def test_build_messages_injects_market_phase_before_cached_data(self): + agent = self._make_agent() + ctx = AgentContext(query="hello", stock_code="600519") + ctx.meta["market_phase_context"] = { + "market": "cn", + "phase": "intraday", + "market_local_time": "2026-03-27T10:00:00+08:00", + "effective_daily_bar_date": "2026-03-26", + "is_partial_bar": True, + "minutes_to_close": 300, + } + ctx.set_data("realtime_quote", {"price": 1880.0}) + + messages = agent._build_messages(ctx) + + phase_indexes = [ + idx for idx, message in enumerate(messages) + if "市场阶段上下文" in message.get("content", "") + ] + cached_indexes = [ + idx for idx, message in enumerate(messages) + if "[Pre-fetched: realtime_quote]" in message.get("content", "") + ] + self.assertEqual(len(phase_indexes), 1) + self.assertEqual(len(cached_indexes), 1) + self.assertLess(phase_indexes[0], cached_indexes[0]) + phase_message = messages[phase_indexes[0]] + self.assertEqual(phase_message["role"], "user") + self.assertIn("盘中", phase_message["content"]) + self.assertIn("不得当作完整日线复盘", phase_message["content"]) + self.assertNotIn("market_phase_context", phase_message["content"]) + self.assertNotIn("is_partial_bar", phase_message["content"]) + # ============================================================ # EventMonitor serialization