mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
feat: inject market phase prompt context (#1442)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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 += """
|
||||
|
||||
## 📈 技术面数据
|
||||
|
||||
|
||||
215
src/market_phase_prompt.py
Normal file
215
src/market_phase_prompt.py
Normal file
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
129
tests/test_market_phase_prompt.py
Normal file
129
tests/test_market_phase_prompt.py
Normal file
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user