refactor(auto_fin): normalize data models and selection logic across agents (#396)
Some checks failed
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
Tests ReMe / Unit Tests - py3.11 (push) Has been cancelled
Tests ReMe / Unit Tests - py3.12 (push) Has been cancelled
Tests ReMe / Unit Tests - py3.13 (push) Has been cancelled
Windows Smoke / CLI smoke - py3.11 (push) Has been cancelled

- Introduce tolerant AutoFinAgentModel base class allowing extra fields in raw Agent outputs
- Replace strict models with tolerant ones for ETF, historical event, market selection, and report outputs
- Remove redundant field validators and allow empty defaults for key string fields
- Enhance historical source path resolution to safely filter invalid or out-of-workspace paths
- Add normalization of whitespace and validation to historical event references before processing
- Implement normalization in Topic and Market Agent selections to eliminate duplicates, blanks, unknowns
- Limit Topic Agent output to top 20 ETFs and ensure sorting and deduplication of events
- Normalize final Markdown report by removing redundant headers and providing safe fallbacks
- Update agent prompts to clarify task constraints and improve instruction consistency
- Add extensive tests for normalization, filtering, and safe source resolution for historical events
This commit is contained in:
jinliyl 2026-07-27 20:15:27 +08:00 • committed by GitHub
parent 4eb2adf961
commit c937be9d94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 358 additions and 235 deletions

View file

@ -26,26 +26,23 @@ ShanghaiDateTime = Annotated[datetime, BeforeValidator(_shanghai_local_time)]
class AutoFinModel(BaseModel):
"""Strict base for Agent output."""
"""Strict base for program-owned Auto Fin data."""
model_config = ConfigDict(extra="forbid")
class AutoFinEtfEventReference(AutoFinModel):
class AutoFinAgentModel(AutoFinModel):
"""Tolerant base for raw Agent output."""
model_config = ConfigDict(extra="ignore")
class AutoFinEtfEventReference(AutoFinAgentModel):
"""One selected news item and why it is relevant to an ETF."""
reason: str
news_id: str
@model_validator(mode="after")
def non_empty_values(self) -> "AutoFinEtfEventReference":
"""Reject blank event references."""
self.reason = self.reason.strip()
self.news_id = self.news_id.strip()
if not self.reason or not self.news_id:
raise ValueError("ETF event reason and news ID must be non-empty")
return self
class AutoFinSelectedEvent(AutoFinModel):
"""A selected current event with its source news reference."""
@ -57,62 +54,33 @@ class AutoFinSelectedEvent(AutoFinModel):
event_title: str = ""
class AutoFinEtfSelection(AutoFinModel):
"""One liquid ETF selected for current news."""
class AutoFinEtfSelection(AutoFinAgentModel):
"""One ETF selection returned by the Topic Agent."""
etf_code: str
etf_name: str
events: list[AutoFinEtfEventReference] = Field(min_length=1)
@model_validator(mode="after")
def valid_news_ids(self) -> "AutoFinEtfSelection":
"""Reject blank or duplicate news references."""
news_ids = [event.news_id for event in self.events]
if len(news_ids) != len(set(news_ids)):
raise ValueError("ETF event news IDs must be unique")
return self
etf_name: str = ""
events: list[AutoFinEtfEventReference] = Field(default_factory=list)
class AutoFinEtfsOutput(AutoFinModel):
"""Liquid ETFs related to current news, deduplicated by name and theme."""
class AutoFinEtfsOutput(AutoFinAgentModel):
"""ETF selections returned by the Topic Agent before normalization."""
etfs: list[AutoFinEtfSelection] = Field(default_factory=list, max_length=20)
@model_validator(mode="after")
def unique_etfs(self) -> "AutoFinEtfsOutput":
"""Reject duplicate ETF codes or names."""
codes = [item.etf_code.strip().upper() for item in self.etfs]
names = [item.etf_name.strip().casefold() for item in self.etfs]
if any(not code for code in codes) or any(not name for name in names):
raise ValueError("ETF codes and names must be non-empty")
if len(codes) != len(set(codes)) or len(names) != len(set(names)):
raise ValueError("ETF codes and names must be unique")
return self
etfs: list[AutoFinEtfSelection] = Field(default_factory=list)
class AutoFinHistoricalEventReference(AutoFinModel):
class AutoFinHistoricalEventReference(AutoFinAgentModel):
"""One historical news item selected by the search Agent."""
reason: str
news_id: str
source_path: str
@model_validator(mode="after")
def non_empty_values(self) -> "AutoFinHistoricalEventReference":
"""Reject references that cannot be resolved deterministically."""
for field in ("reason", "news_id", "source_path"):
value = getattr(self, field).strip()
if not value:
raise ValueError(f"historical event {field} must not be empty")
setattr(self, field, value)
return self
source_path: str = ""
class AutoFinEtfHistoricalEvents(AutoFinModel):
class AutoFinEtfHistoricalEvents(AutoFinAgentModel):
"""Historical news references returned by the search Agent."""
etf_code: str
etf_name: str
etf_code: str = ""
etf_name: str = ""
historical_events: list[AutoFinHistoricalEventReference] = Field(default_factory=list)
@ -235,36 +203,19 @@ class AutoFinEtfHistoricalResearch(AutoFinModel):
return self
class AutoFinHistoricalDirectionReference(AutoFinModel):
class AutoFinHistoricalDirectionReference(AutoFinAgentModel):
"""One direction-classified historical event returned by the Market Agent."""
reason: str
news_id: str
@model_validator(mode="after")
def non_empty_values(self) -> "AutoFinHistoricalDirectionReference":
"""Reject a direction judgment without source identity or rationale."""
self.reason = self.reason.strip()
self.news_id = self.news_id.strip()
if not self.reason or not self.news_id:
raise ValueError("historical direction reason and news ID must be non-empty")
return self
class AutoFinMarketSelection(AutoFinModel):
class AutoFinMarketSelection(AutoFinAgentModel):
"""Same- and opposite-direction historical events returned by the Market Agent."""
same_direction_events: list[AutoFinHistoricalDirectionReference] = Field(default_factory=list)
opposite_direction_events: list[AutoFinHistoricalDirectionReference] = Field(default_factory=list)
@model_validator(mode="after")
def unique_historical_news(self) -> "AutoFinMarketSelection":
"""Reject news IDs repeated within or across direction groups."""
news_ids = [event.news_id for event in (*self.same_direction_events, *self.opposite_direction_events)]
if len(news_ids) != len(set(news_ids)):
raise ValueError("direction-classified historical event news IDs must be unique")
return self
class AutoFinHistoricalMatch(AutoFinModel):
"""One direction-classified historical event used by the equal-weight forecast."""
@ -341,18 +292,8 @@ class AutoFinEtfHistoryDetail(AutoFinModel):
return self
class AutoFinReportOutput(AutoFinModel):
class AutoFinReportOutput(AutoFinAgentModel):
"""Final Markdown title and body for all selected ETFs."""
title: str
body: str
@model_validator(mode="after")
def non_empty_report(self) -> "AutoFinReportOutput":
"""Require both Markdown report fields."""
for field in ("title", "body"):
value = getattr(self, field).strip()
if not value:
raise ValueError(f"{field} must not be empty")
setattr(self, field, value)
return self
title: str = ""
body: str = ""

View file

@ -45,20 +45,20 @@ class AutoFinHistorySearchStep(AutoFinStep):
return number if number > 0 else None
def _historical_source_candidates(self, source_path_value: str, news_id: str) -> list[Path]:
"""Return the declared source and a date-derived fallback within the workspace."""
"""Return safe declared and date-derived source candidates within the workspace."""
workspace = self.workspace_path.resolve()
relative_path = Path(source_path_value)
if relative_path.is_absolute() or ".." in relative_path.parts:
raise ValueError(f"Historical source path must be workspace-relative: {source_path_value}")
source_path = (workspace / relative_path).resolve()
try:
source_path.relative_to(workspace)
except ValueError as exc:
raise ValueError(f"Historical source path is outside the workspace: {source_path_value}") from exc
if source_path.name != "auto_fin_news_data.jsonl":
raise ValueError(f"Historical source must be an Auto Fin news file: {source_path_value}")
candidates = []
if not relative_path.is_absolute() and ".." not in relative_path.parts:
source_path = (workspace / relative_path).resolve()
try:
source_path.relative_to(workspace)
except ValueError:
pass
else:
if source_path.name == "auto_fin_news_data.jsonl":
candidates.append(source_path)
candidates = [source_path]
news_date = news_id.partition("_")[0][:8]
try:
parsed_date = datetime.strptime(news_date, "%Y%m%d").date()
@ -68,6 +68,8 @@ class AutoFinHistorySearchStep(AutoFinStep):
inferred_path = workspace / "daily" / parsed_date.isoformat() / "auto_fin_news_data.jsonl"
if inferred_path not in candidates:
candidates.append(inferred_path)
if not candidates:
raise ValueError(f"Historical source cannot be inferred safely: {source_path_value}")
return candidates
async def _resolve_historical_event(
@ -129,6 +131,18 @@ class AutoFinHistorySearchStep(AutoFinStep):
events_by_news_id: dict[str, AutoFinHistoricalEvent] = {}
limitations = []
for reference in references:
reference = reference.model_copy(
update={
"reason": reference.reason.strip(),
"news_id": reference.news_id.strip(),
"source_path": reference.source_path.strip(),
},
)
if not reference.reason or not reference.news_id:
limitation = "跳过缺少 reason 或 news_id 的历史新闻"
self.logger.warning(f"[{self.name}] {limitation}")
limitations.append(limitation)
continue
try:
event = await self._resolve_historical_event(
reference,
@ -287,8 +301,6 @@ class AutoFinHistorySearchStep(AutoFinStep):
contexts.pop(tool_context_id, None)
if not contexts:
self.app_context.metadata.pop(_ToolContextDedupMixin.TOOL_CONTEXTS_KEY, None)
if (history.etf_code, history.etf_name) != (item.etf_code, item.etf_name):
raise ValueError(f"History Agent changed ETF {label!r}")
resolved_events, resolution_limitations = await self._resolve_historical_events(
history.historical_events,
{event.news_id for event in events},
@ -309,8 +321,8 @@ class AutoFinHistorySearchStep(AutoFinStep):
for event, sample in zip(resolved_events, samples, strict=True)
]
enriched_history = AutoFinEtfHistoricalResearch(
etf_code=history.etf_code,
etf_name=history.etf_name,
etf_code=item.etf_code,
etf_name=item.etf_name,
historical_events=enriched_events,
limitations=list(dict.fromkeys([*resolution_limitations, *market_limitations])),
)

View file

@ -1,33 +1,19 @@
history_search_user: |
你只负责为一个已选定 ETF 选择历史相似新闻,不查询行情、不复制新闻正文、不更换或增加 ETF、
不提供预测。时间、标题、正文和行情都由程序在你返回后补充。
指定 ETF:{etf_code}({etf_name})
当前事件时间线(仅作为检索线索,不含 news_id):{events}
为 {etf_code}({etf_name})寻找与当前事件相似的历史新闻。不查询行情、不计算收益或预测。
当前事件:{events}
历史截止时间:{window_start}
ReMe workspace:{workspace_root}
必须遵守:
1. 使用 memory_search 多轮搜索 {window_start} 之前的相似新闻;系统会自动过滤本次研究中已经
返回过的结果,因此结果不足时继续尝试,并调整事件类型、关键实体、传导机制或影响方向等
查询角度。memory_search 无更多有效结果后,使用 Read 或 Python 扫描 workspace 下过去
360 天的 `daily/YYYY-MM-DD/auto_fin_news_data.jsonl`。历史 Auto Fin Markdown 只能作为
检索线索,最终选择必须能回溯到上述新闻 JSONL。
2. 根据当前事件提取事件类型、关键实体、传导机制和影响方向进行搜索,不能只搜索 ETF 名称。
3. 当前事件时间线来自最近一次收盘后至本次分析时点,只能作为检索线索,不属于历史事件。
即使搜索命中同一条当前新闻,也不得放入 historical_events。
4. 每个 historical_events 项只需从原始 JSONL 逐字复制 news_id 和 workspace 相对
source_path,并用 reason 简洁说明它与当前事件相似的原因。source_path 的 YYYY-MM-DD 必须
是实际文件所在日期,通常与 news_id 开头的日期一致。不要返回时间、标题或正文。
5. 只选择原始记录中真实存在且带有 news_id 的新闻。程序会严格使用 source_path + news_id
回查,找不到、重复命中、路径越界或不是 auto_fin_news_data.jsonl 都会失败;严禁编造。
6. etf_code 和 etf_name 必须原样返回 `{etf_code}` 和 `{etf_name}`。不要读写或下载行情文件,
不要计算收益。没有合适新闻时直接返回空 historical_events,无需说明限制。
7. 最终只生成 etf_code、etf_name 和 historical_events 三个维度;
historical_events 的每一项只包含 reason、news_id 和 source_path。JSON 示例:
工作要求:
1. 使用 memory_search 从事件类型、关键实体、传导机制和影响方向等角度搜索截止时间之前的
相似新闻;结果不足时调整角度继续搜索。必要时扫描 workspace 过去 360 天的
`daily/YYYY-MM-DD/auto_fin_news_data.jsonl`。
2. 选择机制可比的历史事件,说明相似原因,并返回原始新闻的 news_id 和 source_path。
当前事件不属于历史事件。程序会回查、过滤、去重、排序并补充行情。
3. 返回以下 JSON;没有合适新闻时 historical_events 为空:
```json
{{
"etf_code": "{etf_code}",
"etf_name": "{etf_name}",
"historical_events": [
{{
"reason": "事件类型、关键实体、传导机制和影响方向相似",
@ -37,4 +23,3 @@ history_search_user: |
]
}}
```
8. 最终只生成上述 JSON 对象,不附加解释或 Markdown 正文。无需排序或计算,程序会完成。

View file

@ -19,6 +19,35 @@ from ._base import AutoFinStep, _write
class AutoFinMarketStep(AutoFinStep):
"""Classify historical event directions and calculate one ETF forecast."""
@staticmethod
def _normalize_selection(
selection: AutoFinMarketSelection,
history: AutoFinEtfHistoricalResearch,
) -> tuple[AutoFinMarketSelection, bool]:
"""Filter unknown, blank, and duplicate direction references."""
known_news_ids = {event.news_id for event in history.historical_events}
seen_news_ids: set[str] = set()
def valid_events(events):
normalized = []
for event in events:
reason = event.reason.strip()
news_id = event.news_id.strip()
if not reason or news_id not in known_news_ids or news_id in seen_news_ids:
continue
normalized.append({"reason": reason, "news_id": news_id})
seen_news_ids.add(news_id)
return normalized
normalized_selection = AutoFinMarketSelection.model_validate(
{
"same_direction_events": valid_events(selection.same_direction_events),
"opposite_direction_events": valid_events(selection.opposite_direction_events),
},
)
changed = normalized_selection.model_dump(mode="json") != selection.model_dump(mode="json")
return normalized_selection, changed
@staticmethod
def _calculate_analysis(
item: AutoFinEtfSelection,
@ -31,9 +60,6 @@ class AutoFinMarketStep(AutoFinStep):
*((match, "same", 1.0) for match in selection.same_direction_events),
*((match, "opposite", -1.0) for match in selection.opposite_direction_events),
]
unknown_news_ids = {match.news_id for match, _, _ in selected if match.news_id not in history_by_news_id}
if unknown_news_ids:
raise ValueError(f"Market Agent referenced unknown historical news: {sorted(unknown_news_ids)}")
weight = 1.0 / len(selected) if selected else 0.0
matches = [
@ -126,6 +152,9 @@ class AutoFinMarketStep(AutoFinStep):
self.workspace_path / "resource" / str(self._required("auto_fin_date")) / f"{resource_name}_output.json"
)
self.logger.warning(f"[{self.name}] skip direction Agent for {item.etf_code}: no valid history")
selection, normalized = self._normalize_selection(selection, history)
if normalized:
self.logger.info(f"[{self.name}] normalized historical direction selections")
analysis = self._calculate_analysis(item, history, selection)
_write(
selection_path,

View file

@ -1,30 +1,21 @@
market_user: |
你只负责筛选与当前事件机制可比的历史事件,并判断影响方向相同还是相反;不计算收益、
不生成预测、不总结报告。
筛选与当前事件机制可比的历史事件,并判断其影响方向与当前事件相同还是相反。
不计算收益或生成预测。
ETF:{etf_code}({etf_name})
分析截止时间:{decision_at}
当前事件:
{events}
历史事件文件:{history_path}
已补全的历史事件文件:
{history_path}
必须遵守:
1. 读取历史文件,只从 historical_events 中选择与当前事件相似的事件。
2. 综合事件类型、关键实体、传导机制和影响方向进行判断:
工作要求:
1. 根据历史事件的时间、标题、正文和 reason,综合事件类型、关键实体、传导机制和影响方向:
- 机制可比且影响方向相同,放入 same_direction_events;
- 机制可比但影响方向相反,放入 opposite_direction_events;
- 没有有效关系,不要返回。
这里的“相同/相反”是历史事件相对当前事件的影响方向,不是简单判断新闻利好或利空。例如
当前事件是黄金涨价、历史事件是黄金降价时,应放入 opposite_direction_events。
3. 只根据历史事件的 event_time、event_title、event_content 和 reason 判断相似性;不要依据
market_entry 或 future_returns 选择事件,避免使用事后行情影响相似度判断。
4. reason 简洁说明相似之处,news_id 必须从历史文件逐字复制,严禁编造。
5. 不需要返回 ETF、事件时间、权重、预测、持有天数、代码、总结或 limitations;程序会校验
news_id,并完成所有计算。没有相似事件时两组都返回空列表。
6. 同一 news_id 只能出现一次,不能同时放入两组。每项只包含 reason、news_id。
7. 最终只生成 same_direction_events 和 opposite_direction_events。JSON 示例:
- 机制不可比则不选择。
不要依据 market_entry 或 future_returns 选择事件,避免使用事后行情。
2. 返回 news_id 和判断理由。程序会过滤、去重并完成计算。
3. 返回以下 JSON:
```json
{{
"same_direction_events": [
@ -41,4 +32,3 @@ market_user: |
]
}}
```
8. 最终只生成上述 JSON 对象,不附加解释或 Markdown 正文。

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import json
import re
from types import SimpleNamespace
from ....components import R
@ -15,6 +16,16 @@ from ._base import AutoFinStep, _write, _write_jsonl
class AutoFinMergeStep(AutoFinStep):
"""Ask a fresh Agent for the final Markdown and persist it directly."""
@staticmethod
def _normalize_report(output: AutoFinReportOutput) -> AutoFinReportOutput:
"""Normalize cosmetic report fields and provide safe empty fallbacks."""
title = re.sub(r"^#+\s*", "", output.title.strip()) or "Auto Fin ETF 结论"
body = output.body.strip() or "## 结论\n\n暂无可用结论。"
first_line, separator, remainder = body.partition("\n")
if first_line.lstrip().startswith("# "):
body = remainder.lstrip() if separator else "## 结论\n\n暂无可用结论。"
return AutoFinReportOutput(title=title, body=body)
@staticmethod
def _calculation_results(history_details: list[AutoFinEtfHistoryDetail]) -> list[dict]:
"""Return the program-calculated forecast for every analyzed ETF."""
@ -45,7 +56,7 @@ class AutoFinMergeStep(AutoFinStep):
self.logger.info(
f"[{self.name}] start etfs={len(etfs)}",
)
output, _ = await self._reply(
output, output_path = await self._reply(
"merge_user",
"auto_fin_merge",
AutoFinReportOutput,
@ -55,6 +66,13 @@ class AutoFinMergeStep(AutoFinStep):
history_path=str(self._required("auto_fin_history_resource")),
calculation_results=json.dumps(calculation_results, ensure_ascii=False),
)
normalized_output = self._normalize_report(output)
if normalized_output != output:
_write(
output_path,
json.dumps(normalized_output.model_dump(mode="json"), ensure_ascii=False, separators=(",", ":")) + "\n",
)
output = normalized_output
markdown = f"# {output.title}\n\n{output.body}\n\n"
markdown += "> 仅为事件研究和持有时间参考,不构成投资建议,不会执行交易。\n"
day_dir = self.workspace_path / str(self.config_value("daily_dir")) / str(self._required("auto_fin_date"))

View file

@ -1,39 +1,23 @@
merge_user: |
你只负责把已经完成的结构化分析写成一份中文 Markdown 报告,不重新搜索新闻、不下载行情、
不修改任何数值。
把已经完成的结构化分析写成中文 Markdown 结论,不重新搜索新闻、下载行情或修改数值。
分析截止时间:{decision_at}
新闻窗口:({window_start}, {decision_at}]
ETF 与当前事件:{etfs_path}
历史事件、行情样本及分析:{history_path}
程序计算结果:{calculation_results}
已筛选 ETF 及其当前事件时间线:
{etfs_path}
各 ETF 的完整历史事件、实际行情样本和程序计算结果:
{history_path}
全部 ETF 的程序计算结果(包含正向、负向、零值和缺失值):
{calculation_results}
必须遵守:
1. 只读取上述两个文件,严格使用其中已有的 ETF、事件、收益、权重、最佳持有天数和 limitations。
2. body 只写最终结论,以推荐 ETF 为主要内容。
3. 由你根据文件中的当前事件内容和传导关系,自行判断事件对 ETF 的影响方向。推荐 ETF 必须
同时满足:
工作要求:
1. 根据当前事件内容和传导关系判断影响方向。只推荐同时满足以下条件的 ETF:
- 经你判断,当前事件对该 ETF 所代表的资产、行业或主题影响明确为正向;
- 程序给出了最佳持有天数,且该天数对应的 expected_return 大于 0。
不得使用程序计算结果反推事件方向。事件影响为负向、中性、方向不明,或计算结果为 null、
0、负数时,一律不得推荐。
4. 若没有同时满足两项条件的 ETF,只输出观望结论。不得因为计算为正就把事件方向不明的 ETF
推荐出来。
5. 如有推荐,结论需要列 ETF code、name、最佳持有天数、对应预估收益。
6. 对事件影响为负向或计算结果没有正值的 ETF,在推荐结论后用一句话合并简述;不要逐只展开。
中性、方向不明或结果缺失的 ETF 可以不写。
7. title 不包含 Markdown 标记,body 不重复一级标题,不生成 YAML frontmatter。
8. 最终只生成 title 和 body。JSON 示例:
不使用计算结果反推事件方向;不满足条件时给出观望结论。
2. 推荐结论包含 ETF code、name、最佳持有天数和对应预估收益;负向或无正收益的情况可以合并
简述。
3. 返回以下 JSON:
```json
{{
"title": "Auto Fin ETF 结论",
"body": "## 结论\n\n推荐 518880.SH(黄金ETF),参考持有 3 个交易日,当前加权预估收益 +1.2%;......。\n\n负向提示:相关能源 ETF 事件影响偏负,不推荐。"
}}
```
9. 最终只生成上述 JSON 对象,不附加解释或代码块标记。

View file

@ -16,6 +16,7 @@ NEWS_TITLE_MAX_CHARS = 200
NEWS_CONTENT_MAX_CHARS = 1200
NEWS_TOTAL_CONTENT_MAX_CHARS = 60_000
ETF_CANDIDATE_LIMIT = 150
ETF_OUTPUT_LIMIT = 20
class _NewsTextExtractor(HTMLParser):
@ -167,7 +168,9 @@ class AutoFinTopicStep(AutoFinStep):
repairs: dict[str, str] = {}
for item in data["etfs"]:
for event in item["events"]:
news_id = event["news_id"]
event["reason"] = event["reason"].strip()
news_id = event["news_id"].strip()
event["news_id"] = news_id
if news_id in news_ids:
continue
_, separator, suffix = news_id.rpartition("_")
@ -178,26 +181,47 @@ class AutoFinTopicStep(AutoFinStep):
return AutoFinEtfsOutput.model_validate(data), repairs
@staticmethod
def _validate_selection(
def _normalize_selection(
output: AutoFinEtfsOutput,
news: list[dict[str, Any]],
etfs: list[dict[str, str]],
) -> None:
) -> tuple[AutoFinEtfsOutput, bool]:
"""Canonicalize, filter, deduplicate, sort, and limit Agent selections."""
news_order = {str(row["news_id"]): index for index, row in enumerate(news)}
news_ids = set(news_order)
candidates = {row["code"]: row["name"] for row in etfs}
candidates = {str(row["code"]).strip().upper(): str(row["name"]).strip() for row in etfs}
selected: dict[str, dict[str, Any]] = {}
for item in output.etfs:
if candidates.get(item.etf_code) != item.etf_name:
raise ValueError(f"Topic Agent returned an ETF outside filtered_etf.jsonl: {item.etf_code}")
selected_news_ids = [event.news_id for event in item.events]
unknown = set(selected_news_ids) - news_ids
if unknown:
raise ValueError(f"Topic Agent returned unknown news IDs: {sorted(unknown)}")
if len(selected_news_ids) != len(set(selected_news_ids)):
raise ValueError(f"Topic Agent returned duplicate news IDs for ETF: {item.etf_code}")
event_order = [news_order[news_id] for news_id in selected_news_ids]
if event_order != sorted(event_order):
raise ValueError(f"Topic Agent returned unsorted news IDs for ETF: {item.etf_code}")
code = item.etf_code.strip().upper()
name = candidates.get(code)
if name is None:
continue
normalized = selected.setdefault(
code,
{"etf_code": code, "etf_name": name, "events": [], "seen_news_ids": set()},
)
for event in item.events:
if not event.reason or event.news_id not in news_ids or event.news_id in normalized["seen_news_ids"]:
continue
normalized["events"].append(event.model_dump(mode="json"))
normalized["seen_news_ids"].add(event.news_id)
normalized_items = []
for item in selected.values():
events = sorted(item["events"], key=lambda event: news_order[event["news_id"]])
if events:
normalized_items.append(
{
"etf_code": item["etf_code"],
"etf_name": item["etf_name"],
"events": events,
},
)
if len(normalized_items) >= ETF_OUTPUT_LIMIT:
break
normalized_output = AutoFinEtfsOutput.model_validate({"etfs": normalized_items})
changed = normalized_output.model_dump(mode="json") != output.model_dump(mode="json")
return normalized_output, changed
async def execute(self):
assert self.context is not None
@ -227,10 +251,13 @@ class AutoFinTopicStep(AutoFinStep):
filtered_etf_path=str(etf_path),
)
output, repairs = self._repair_news_ids(output, news)
output, normalized = self._normalize_selection(output, news, etfs)
if repairs:
self.logger.warning(f"[{self.name}] repaired mistyped news IDs: {repairs}")
if normalized:
self.logger.info(f"[{self.name}] normalized ETF selections")
if repairs or normalized:
_write_jsonl(output_path, output.model_dump(mode="json")["etfs"])
self._validate_selection(output, news, etfs)
self.context["auto_fin_window_start"] = window_start.isoformat()
self.context["auto_fin_etfs"] = output.model_dump(mode="json")["etfs"]
self.context["auto_fin_etfs_resource"] = str(output_path)

View file

@ -1,27 +1,15 @@
topic_user: |
你只负责从候选文件中筛选与当前新闻直接相关的代表性 ETF,并返回对应新闻 ID。
你只负责筛选与当前新闻直接相关的代表性 ETF,并说明每条相关新闻与 ETF 的关系。
不搜索或补充新闻、不下载数据、不计算收益、不提供预测或投资建议。
时间边界:
- 新闻窗口严格为 ({window_start}, {decision_at}]。
- 分析截止时间为 {decision_at}。
输入文件:
- 当前窗口内的全部新闻:{filtered_news_path}
- 已按最近收盘成交额关联、去重并截取 Top 150 的 ETF:{filtered_etf_path}
- 新闻(时间窗口为 ({window_start}, {decision_at}]):{filtered_news_path}
- 候选 ETF:{filtered_etf_path}
输出要求:
1. 必须读取上述两个 JSONL 文件;只能从 filtered_etf.jsonl 选择 ETF,只能引用
filtered_news.jsonl 中的 news_id。
2. 只保留其 name 所代表的指数、行业、主题或资产与新闻存在明确传导关系的代表性 ETF。
3. 最多返回 20 只 ETF,按与新闻的相关性排序;不要为了凑数纳入弱相关 ETF。
4. events 只填写与该 ETF 相关的事件对象,每项包含 reason 和 news_id;reason 简洁说明该条新闻
与 ETF 所代表资产之间的直接传导关系。合并重复报道,并按新闻时间升序排列。
5. etf_code、etf_name 必须逐字复制候选文件中的 code、name;无相关 ETF 时返回空 etfs。
news_id 也必须整段逐字复制,禁止把一条新闻的时间前缀与另一条新闻的哈希后缀拼接。
6. reason 不得加入预测;同一 ETF 的不同新闻应分别说明关联理由。
7. 最终生成一个对象,只包含 etfs;每只 ETF 只包含 etf_code、etf_name、events,每个 event
只包含 reason 和 news_id。JSON 示例:
1. 从候选 ETF 中选择与新闻有明确传导关系的代表性 ETF,不要为了凑数纳入弱相关 ETF。
2. 为每只 ETF 返回相关新闻的 news_id,并用 reason 简洁说明直接传导关系。
3. 返回以下 JSON:
```json
{{
"etfs": [
@ -42,4 +30,3 @@ topic_user: |
]
}}
```
8. 最终只生成上述 JSON 对象,不附加解释或 Markdown 正文,不写入 ReMe workspace。

View file

@ -114,6 +114,76 @@ def test_topic_does_not_repair_ambiguous_content_hash():
assert not repairs
def test_topic_normalizes_duplicate_and_invalid_selections():
output = AutoFinEtfsOutput.model_validate(
{
"etfs": [
{
"etf_code": "159516.SZ",
"etf_name": "半导体设备ETF",
"events": [
{"reason": "较晚新闻", "news_id": "20260724164534_9368"},
{"reason": "较早新闻", "news_id": "20260724164043_7332"},
{"reason": "重复新闻", "news_id": "20260724164043_7332"},
{"reason": "未知新闻", "news_id": "20260724170000_ffff"},
{"reason": " ", "news_id": "20260724170000_abcd"},
],
},
{
"etf_code": "159516.SZ",
"etf_name": "模型返回的错误名称",
"events": [{"reason": "另一条新闻", "news_id": "20260724170000_abcd"}],
},
{
"etf_code": "000000.SZ",
"etf_name": "候选范围外ETF",
"events": [{"reason": "范围外", "news_id": "20260724164043_7332"}],
},
],
},
)
news = [
{"news_id": "20260724164043_7332"},
{"news_id": "20260724164534_9368"},
{"news_id": "20260724170000_abcd"},
]
etfs = [{"code": "159516.SZ", "name": "国泰中证半导体材料设备主题ETF"}]
normalized, changed = AutoFinTopicStep._normalize_selection(output, news, etfs)
assert changed
assert len(normalized.etfs) == 1
assert normalized.etfs[0].etf_name == "国泰中证半导体材料设备主题ETF"
assert [event.news_id for event in normalized.etfs[0].events] == [
"20260724164043_7332",
"20260724164534_9368",
"20260724170000_abcd",
]
def test_topic_limits_normalized_output_in_code():
output = AutoFinEtfsOutput.model_validate(
{
"etfs": [
{
"etf_code": f"{index:06d}.SZ",
"etf_name": f"ETF {index}",
"events": [{"reason": "相关事件", "news_id": "20260724164043_7332"}],
}
for index in range(21)
],
},
)
news = [{"news_id": "20260724164043_7332"}]
etfs = [{"code": f"{index:06d}.SZ", "name": f"ETF {index}"} for index in range(21)]
normalized, changed = AutoFinTopicStep._normalize_selection(output, news, etfs)
assert changed
assert len(normalized.etfs) == 20
assert [item.etf_code for item in normalized.etfs] == [f"{index:06d}.SZ" for index in range(20)]
def test_published_time_is_normalized_once_to_shanghai_local_time():
parsed = AutoFinDataStep._published_at({"published_at": "2026-07-24T01:00:00+00:00"})
@ -315,8 +385,7 @@ class _Agent(BaseAgentWrapper):
if schema is AutoFinEtfsOutput:
assert "filtered_news.jsonl" in task
assert "filtered_etf.jsonl" in task
assert "Top 150" in task
assert "最多返回 20" in task
assert "最多返回 20" not in task
value = {
"etfs": [
{
@ -349,19 +418,16 @@ class _Agent(BaseAgentWrapper):
f"20260724090000_" f"{hashlib.sha256('财联社供应恢复时间仍不确定'.encode()).hexdigest()[:4]}"
)
assert current_news_id not in task
assert "当前事件时间线(仅作为检索线索,不含 news_id)" in task
assert "时间、标题、正文和行情都由程序在你返回后补充" in task
assert "每一项只包含 reason、news_id 和 source_path" in task
assert "程序会回查、过滤、去重、排序并补充行情" in task
tool_context_id = kwargs.get("tool_context_id", "")
assert tool_context_id.startswith("auto_fin_history_01_159018.SZ_")
assert tool_context_id not in task
assert "系统会自动过滤本次研究中已经" in task
self.app_context.metadata.setdefault("tool_contexts", {})[tool_context_id] = {
"search_seen_chunk_ids": {},
}
value = {
"etf_code": "159018.SZ",
"etf_name": "油气ETF",
"etf_code": "changed",
"etf_name": "changed",
"historical_events": [
{
"reason": "供应中断的事件类型和传导机制相同",
@ -375,13 +441,16 @@ class _Agent(BaseAgentWrapper):
elif schema is AutoFinMarketSelection:
assert "ETF:159018.SZ(油气ETF)" in task
assert "[2026-07-23T16:00:00] 原油供应中断" in task
assert "判断影响方向相同还是相反" in task
assert "影响方向与当前事件相同还是相反" in task
assert "不要依据" in task
assert "程序会校验" in task
assert "每项只包含 reason、news_id" in task
assert "程序会过滤、去重并完成计算" in task
assert "$tushare-data" not in task
history_path = Path(
next(line.strip() for line in task.splitlines() if line.strip().endswith("_output.json")),
next(
line.rsplit(":", 1)[-1].strip()
for line in task.splitlines()
if line.strip().endswith("_output.json")
),
)
history = json.loads(history_path.read_text(encoding="utf-8"))
assert "historical_samples" not in history
@ -400,15 +469,12 @@ class _Agent(BaseAgentWrapper):
elif schema is AutoFinReportOutput:
assert "不重新搜索新闻" in task
assert "auto_fin_history_output.jsonl" in task
assert "不生成 YAML frontmatter" in task
assert '"etf_code": "159018.SZ"' in task
assert '"suggested_holding_days": 10' in task
assert '"horizon": 1' in task
assert '"horizon": 10' in task
assert "自行判断事件对 ETF 的影响方向" in task
assert "不得使用程序计算结果反推事件方向" in task
assert "以推荐 ETF 为主要内容" in task
assert "用一句话合并简述" in task
assert "不使用计算结果反推事件方向" in task
assert "负向或无正收益的情况可以合并" in task
value = {
"title": "Auto Fin ETF 结论",
"body": "## 结论\n\n推荐 159018.SZ(油气ETF),参考持有 10 个交易日,"
@ -570,7 +636,7 @@ async def test_four_step_pipeline_writes_plain_markdown_and_cleans_temporary_dat
assert sum("agent input prompt=" in line for line in logs) == 2
assert sum("agent output prompt=" in line for line in logs) == 2
assert all("agent start prompt=" not in line and "agent done prompt=" not in line for line in logs)
assert any('query="你只负责从候选文件中筛选' in line for line in logs)
assert any('query="你只负责筛选与当前新闻直接相关' in line for line in logs)
assert any('output={"etfs":[{"etf_code":"159018.SZ"' in line for line in logs)
resource_dir = tmp_path / "resource" / "2026-07-24"
filtered_news = AutoFinDataStep._read_jsonl_sync(resource_dir / "filtered_news.jsonl")
@ -721,16 +787,61 @@ def test_market_calculation_equal_weights_and_reverses_opposite_direction_event(
assert "相似历史样本的收益方向存在分歧" in analysis.limitations
def test_market_selection_rejects_news_repeated_across_direction_groups():
def test_market_selection_filters_duplicate_unknown_and_blank_references():
duplicate = {"reason": "方向判断", "news_id": "20260601100000_abcd"}
selection = AutoFinMarketSelection.model_validate(
{
"same_direction_events": [
duplicate,
{"reason": " ", "news_id": "20260602100000_efgh"},
{"reason": "不存在", "news_id": "20260603100000_dead"},
],
"opposite_direction_events": [duplicate],
"ignored_extra_field": True,
},
)
history = AutoFinEtfHistoricalResearch.model_validate(
{
"etf_code": "518880.SH",
"etf_name": "黄金ETF",
"historical_events": [
{
"reason": "历史事件",
"news_id": "20260601100000_abcd",
"source_path": "daily/2026-06-01/auto_fin_news_data.jsonl",
"event_time": "2026-06-01T10:00:00",
"event_title": "黄金上涨",
"event_content": "黄金价格上涨。",
},
{
"reason": "历史事件",
"news_id": "20260602100000_efgh",
"source_path": "daily/2026-06-02/auto_fin_news_data.jsonl",
"event_time": "2026-06-02T10:00:00",
"event_title": "美元变化",
"event_content": "美元发生变化。",
},
],
},
)
with pytest.raises(ValueError, match="news IDs must be unique"):
AutoFinMarketSelection.model_validate(
{
"same_direction_events": [duplicate],
"opposite_direction_events": [duplicate],
},
)
normalized, changed = AutoFinMarketStep._normalize_selection(selection, history)
assert changed
assert [event.news_id for event in normalized.same_direction_events] == ["20260601100000_abcd"]
assert not normalized.opposite_direction_events
def test_merge_normalizes_cosmetic_or_empty_report_fields():
normalized = AutoFinMergeStep._normalize_report(
AutoFinReportOutput(title="## Auto Fin ETF 结论 ", body="# 重复标题\n\n## 结论\n\n观望。"),
)
fallback = AutoFinMergeStep._normalize_report(AutoFinReportOutput.model_validate({}))
assert normalized.title == "Auto Fin ETF 结论"
assert normalized.body == "## 结论\n\n观望。"
assert fallback.title == "Auto Fin ETF 结论"
assert fallback.body == "## 结论\n\n暂无可用结论。"
@pytest.mark.asyncio
@ -878,6 +989,45 @@ async def test_history_search_recovers_source_path_from_news_id_date(tmp_path: P
assert not limitations
@pytest.mark.asyncio
async def test_history_search_ignores_unsafe_source_path_and_uses_news_id_date(tmp_path: Path):
actual_path = tmp_path / "daily" / "2026-04-26" / "auto_fin_news_data.jsonl"
actual_path.parent.mkdir(parents=True)
actual_path.write_text(
json.dumps(
{
"news_id": "20260426221449_de86",
"pub_time": "2026-04-26 22:14:49",
"title": "有效历史新闻",
"content": "有效内容",
},
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
step = AutoFinHistorySearchStep(
app_context=ApplicationContext(workspace_dir=str(tmp_path), timezone="Asia/Shanghai"),
)
references = [
AutoFinHistoricalEventReference(
reason="相似事件",
news_id="20260426221449_de86",
source_path="/tmp/not-allowed.jsonl",
),
]
events, limitations = await step._resolve_historical_events(
references,
set(),
datetime.fromisoformat("2026-07-24T15:00:00"),
)
assert [event.news_id for event in events] == ["20260426221449_de86"]
assert events[0].source_path == "daily/2026-04-26/auto_fin_news_data.jsonl"
assert not limitations
@pytest.mark.asyncio
async def test_history_search_skips_one_invalid_reference_and_continues(tmp_path: Path):
actual_path = tmp_path / "daily" / "2026-04-26" / "auto_fin_news_data.jsonl"