feat: add factor rule analyst workflow

This commit is contained in:
69049ed6x 2026-03-06 11:32:33 +08:00
parent f047f26df0
commit 27e5854503
21 changed files with 322 additions and 17 deletions

16
LOGS/DEV-2026-03-06.md Normal file
View File

@ -0,0 +1,16 @@
2026-03-06 10:00 - 初始化 TradingAgent 因子规则分析师任务
- 用户要求:跑通 OpenCode ACP然后 fork/clone TradingAgents并新增因子规则分析师能力。
- 实际情况Feishu 不支持 ACP thread binding按 acp-router 技能回退到 acpx 直连 OpenCode。
- 已完成:验证 xwang gpt-5.4 可用;本地 clone TradingAgents 到 projects/tradingagent。
- 下一步:用 acpx 驱动 OpenCode 完成代码实现、验证并提交。
2026-03-06 11:40 - 直接接管 TradingAgents 改造并落地 factor rule analyst
- 新增 `tradingagents/agents/utils/factor_rules.py`:支持从配置/环境变量/默认路径加载 JSON 因子规则,并生成结构化摘要。
- 新增 `tradingagents/agents/analysts/factor_rule_analyst.py`:基于手动规则生成 analyst 报告。
- 接入 `agent_states.py`、`propagation.py`、`agents/__init__.py`、`graph/setup.py`、`graph/conditional_logic.py`、`graph/trading_graph.py`,使 factor rule analyst 成为默认 analyst 链的一部分。
- 修改 bull/bear researcher、research manager、trader、risk manager使其使用 `factor_rules_report`
- 新增 `tradingagents/examples/factor_rules.json` 示例规则文件,并更新 README/main.py/default_config.py。
- 验证结果:`python -m compileall tradingagents` 通过;`factor_rules.py` 可独立加载并输出摘要。更深的图执行验证受本地缺少 `langchain_core` 依赖限制。
- 下一步git commit 当前实现;若需要再补装依赖做端到端运行验证。

17
PROJECT_PLAN.md Normal file
View File

@ -0,0 +1,17 @@
# TradingAgent 因子规则分析师改进计划
## 目标
为 TradingAgents 增加一个新的分析师Factor Rule Analyst因子规则分析师
## 里程碑
- [ ] 跑通 OpenCode ACP / acpx 直连链路
- [ ] 获取仓库并确认现有 analyst / graph / state 架构
- [ ] 设计手动导入的因子规则格式
- [ ] 实现因子规则加载与上下文注入
- [ ] 将新分析师接入分析流程
- [ ] 增加文档、示例与最小验证
- [ ] 本地 commit
## 已知约束
- Feishu 不支持 ACP thread binding因此改用 acpx 直连 OpenCode
- 若 GitHub fork/push 无权限,可先完成本地 clone 与本地 commit

View File

@ -181,6 +181,45 @@ print(decision)
You can also adjust the default configuration to set your own choice of LLMs, debate rounds, etc.
### Factor Rule Analyst
TradingAgents now supports an optional `factor_rules` analyst that loads manually curated factor rules from JSON and injects them into the analyst → researcher → trader workflow.
Default lookup order for factor rules:
1. `config["factor_rules_path"]`
2. `TRADINGAGENTS_FACTOR_RULES_PATH`
3. `tradingagents/examples/factor_rules.json`
4. `tradingagents/factor_rules.json`
Example rule file format:
```json
{
"rules": [
{
"name": "AI capex acceleration",
"signal": "bullish",
"weight": "high",
"thesis": "AI infrastructure demand supports growth.",
"conditions": ["Backlog rising", "Margins stable"],
"rationale": "Use when execution quality remains strong."
}
]
}
```
Example config:
```python
config = DEFAULT_CONFIG.copy()
config["factor_rules_path"] = "./tradingagents/examples/factor_rules.json"
ta = TradingAgentsGraph(
debug=True,
config=config,
selected_analysts=["market", "social", "news", "fundamentals", "factor_rules"],
)
```
```python
from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.default_config import DEFAULT_CONFIG

17
SPEC.md Normal file
View File

@ -0,0 +1,17 @@
# SPEC - Factor Rule Analyst
## 用户需求
在 TradingAgents 的分析师体系中新增一个因子规则分析师,支持手动导入固定格式的因子规则,并自动把这些规则作为分析上下文注入给分析师,参与最终分析流程。
## 功能要求
1. 提供一种明确的因子规则文件格式。
2. 支持从配置或显式路径加载规则。
3. 新分析师能读取规则、结合标的与日期输出分析报告。
4. 报告内容纳入后续研究/交易链路。
5. 提供示例规则文件与说明文档。
6. 尽量少改核心架构,优先复用已有 analyst 模式。
## 非目标
- 不要求实现完整量化回测引擎。
- 不要求自动学习因子规则。
- 不要求在线因子数据库。

11
TODO.md Normal file
View File

@ -0,0 +1,11 @@
# TODO
- [x] Clone TradingAgents 仓库到本地
- [x] 阅读 analyst / graph / state 关键代码
- [x] 设计因子规则文件格式
- [x] 实现 factor rule analyst
- [x] 接入图流程与状态管理
- [x] 添加示例规则与使用文档
- [x] 运行最小验证compileall + factor rule loader
- [ ] git commit
- [ ] 后续可选:补装项目依赖后跑端到端图执行验证

View File

@ -20,6 +20,9 @@ config["data_vendors"] = {
"news_data": "yfinance", # Options: alpha_vantage, yfinance
}
# Optional: inject manual factor rules
config["factor_rules_path"] = "./tradingagents/examples/factor_rules.json"
# Initialize with custom config
ta = TradingAgentsGraph(debug=True, config=config)

View File

@ -2,6 +2,7 @@ from .utils.agent_utils import create_msg_delete
from .utils.agent_states import AgentState, InvestDebateState, RiskDebateState
from .utils.memory import FinancialSituationMemory
from .analysts.factor_rule_analyst import create_factor_rule_analyst
from .analysts.fundamentals_analyst import create_fundamentals_analyst
from .analysts.market_analyst import create_market_analyst
from .analysts.news_analyst import create_news_analyst
@ -28,6 +29,7 @@ __all__ = [
"create_bear_researcher",
"create_bull_researcher",
"create_research_manager",
"create_factor_rule_analyst",
"create_fundamentals_analyst",
"create_market_analyst",
"create_neutral_debator",

View File

@ -0,0 +1,35 @@
from tradingagents.agents.utils.factor_rules import load_factor_rules, summarize_factor_rules
from tradingagents.dataflows.config import get_config
def create_factor_rule_analyst(llm):
def factor_rule_analyst_node(state):
current_date = state["trade_date"]
ticker = state["company_of_interest"]
config = get_config()
rules, rule_path = load_factor_rules(config)
summary = summarize_factor_rules(rules, ticker, current_date)
system_prompt = f"""You are a Factor Rule Analyst for a trading research team.
Your job is to interpret manually curated factor rules for {ticker} on {current_date}.
The rules are loaded from: {rule_path or 'no file found'}.
You must:
1. Summarize the strongest bullish and bearish factor signals.
2. Explain which rules are higher conviction based on weight and rationale.
3. Point out any rule conflicts or missing information.
4. End with a practical conclusion describing how traders and downstream researchers should use these factor rules.
5. Include a short markdown table of the highest priority rules.
Do not invent quantitative backtest results. Only reason from the provided rule context.
Rule context:
{summary}
"""
result = llm.invoke(system_prompt)
return {
"messages": [result],
"factor_rules_report": result.content,
}
return factor_rule_analyst_node

View File

@ -9,10 +9,11 @@ def create_research_manager(llm, memory):
sentiment_report = state["sentiment_report"]
news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
factor_rules_report = state.get("factor_rules_report", "")
investment_debate_state = state["investment_debate_state"]
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}"
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}\n\n{factor_rules_report}"
past_memories = memory.get_memories(curr_situation, n_matches=2)
past_memory_str = ""
@ -33,6 +34,13 @@ Take into account your past mistakes on similar situations. Use these insights t
Here are your past reflections on mistakes:
\"{past_memory_str}\"
Additional analyst context:
- Market report: {market_research_report}
- Social sentiment report: {sentiment_report}
- News report: {news_report}
- Fundamentals report: {fundamentals_report}
- Factor rule analyst report: {factor_rules_report}
Here is the debate:
Debate History:
{history}"""

View File

@ -13,9 +13,10 @@ def create_risk_manager(llm, memory):
news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
sentiment_report = state["sentiment_report"]
factor_rules_report = state.get("factor_rules_report", "")
trader_plan = state["investment_plan"]
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}"
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}\n\n{factor_rules_report}"
past_memories = memory.get_memories(curr_situation, n_matches=2)
past_memory_str = ""
@ -29,6 +30,7 @@ Guidelines for Decision-Making:
2. **Provide Rationale**: Support your recommendation with direct quotes and counterarguments from the debate.
3. **Refine the Trader's Plan**: Start with the trader's original plan, **{trader_plan}**, and adjust it based on the analysts' insights.
4. **Learn from Past Mistakes**: Use lessons from **{past_memory_str}** to address prior misjudgments and improve the decision you are making now to make sure you don't make a wrong BUY/SELL/HOLD call that loses money.
5. **Use Factor Rule Context**: Incorporate this factor-rule analyst context where relevant: **{factor_rules_report}**.
Deliverables:
- A clear and actionable recommendation: Buy, Sell, or Hold.

View File

@ -14,8 +14,9 @@ def create_bear_researcher(llm, memory):
sentiment_report = state["sentiment_report"]
news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
factor_rules_report = state.get("factor_rules_report", "")
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}"
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}\n\n{factor_rules_report}"
past_memories = memory.get_memories(curr_situation, n_matches=2)
past_memory_str = ""
@ -38,10 +39,11 @@ Market research report: {market_research_report}
Social media sentiment report: {sentiment_report}
Latest world affairs news: {news_report}
Company fundamentals report: {fundamentals_report}
Factor rule analyst report: {factor_rules_report}
Conversation history of the debate: {history}
Last bull argument: {current_response}
Reflections from similar situations and lessons learned: {past_memory_str}
Use this information to deliver a compelling bear argument, refute the bull's claims, and engage in a dynamic debate that demonstrates the risks and weaknesses of investing in the stock. You must also address reflections and learn from lessons and mistakes you made in the past.
Use this information to deliver a compelling bear argument, refute the bull's claims, and engage in a dynamic debate that demonstrates the risks and weaknesses of investing in the stock. Explicitly use any cautionary or conflicting factor rules where relevant. You must also address reflections and learn from lessons and mistakes you made in the past.
"""
response = llm.invoke(prompt)

View File

@ -14,8 +14,9 @@ def create_bull_researcher(llm, memory):
sentiment_report = state["sentiment_report"]
news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
factor_rules_report = state.get("factor_rules_report", "")
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}"
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}\n\n{factor_rules_report}"
past_memories = memory.get_memories(curr_situation, n_matches=2)
past_memory_str = ""
@ -36,10 +37,11 @@ Market research report: {market_research_report}
Social media sentiment report: {sentiment_report}
Latest world affairs news: {news_report}
Company fundamentals report: {fundamentals_report}
Factor rule analyst report: {factor_rules_report}
Conversation history of the debate: {history}
Last bear argument: {current_response}
Reflections from similar situations and lessons learned: {past_memory_str}
Use this information to deliver a compelling bull argument, refute the bear's concerns, and engage in a dynamic debate that demonstrates the strengths of the bull position. You must also address reflections and learn from lessons and mistakes you made in the past.
Use this information to deliver a compelling bull argument, refute the bear's concerns, and engage in a dynamic debate that demonstrates the strengths of the bull position. Explicitly use any supportive or contradictory factor rules where relevant. You must also address reflections and learn from lessons and mistakes you made in the past.
"""
response = llm.invoke(prompt)

View File

@ -11,8 +11,9 @@ def create_trader(llm, memory):
sentiment_report = state["sentiment_report"]
news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
factor_rules_report = state.get("factor_rules_report", "")
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}"
curr_situation = f"{market_research_report}\n\n{sentiment_report}\n\n{news_report}\n\n{fundamentals_report}\n\n{factor_rules_report}"
past_memories = memory.get_memories(curr_situation, n_matches=2)
past_memory_str = ""
@ -24,7 +25,7 @@ def create_trader(llm, memory):
context = {
"role": "user",
"content": f"Based on a comprehensive analysis by a team of analysts, here is an investment plan tailored for {company_name}. This plan incorporates insights from current technical market trends, macroeconomic indicators, and social media sentiment. Use this plan as a foundation for evaluating your next trading decision.\n\nProposed Investment Plan: {investment_plan}\n\nLeverage these insights to make an informed and strategic decision.",
"content": f"Based on a comprehensive analysis by a team of analysts, here is an investment plan tailored for {company_name}. This plan incorporates insights from current technical market trends, macroeconomic indicators, social media sentiment, and manually curated factor rules. Use this plan as a foundation for evaluating your next trading decision.\n\nProposed Investment Plan: {investment_plan}\n\nFactor Rule Analyst Report: {factor_rules_report}\n\nLeverage these insights to make an informed and strategic decision.",
}
messages = [

View File

@ -60,6 +60,7 @@ class AgentState(MessagesState):
str, "Report from the News Researcher of current world affairs"
]
fundamentals_report: Annotated[str, "Report from the Fundamentals Researcher"]
factor_rules_report: Annotated[str, "Report from the Factor Rule Analyst"]
# researcher team discussion step
investment_debate_state: Annotated[

View File

@ -0,0 +1,89 @@
import json
import os
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
def _candidate_rule_paths(config: Optional[Dict[str, Any]] = None) -> List[Path]:
config = config or {}
candidates = []
explicit = config.get("factor_rules_path")
if explicit:
candidates.append(Path(explicit))
env_path = os.getenv("TRADINGAGENTS_FACTOR_RULES_PATH")
if env_path:
candidates.append(Path(env_path))
project_dir = Path(config.get("project_dir", Path(__file__).resolve().parents[2]))
candidates.extend(
[
project_dir / "examples" / "factor_rules.json",
project_dir / "factor_rules.json",
]
)
return candidates
def load_factor_rules(config: Optional[Dict[str, Any]] = None) -> Tuple[List[Dict[str, Any]], Optional[str]]:
for path in _candidate_rule_paths(config):
if not path.exists():
continue
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
rules = data.get("rules", data if isinstance(data, list) else [])
if not isinstance(rules, list):
raise ValueError("Factor rules file must contain a list under 'rules' or be a list itself.")
return rules, str(path)
return [], None
def summarize_factor_rules(rules: List[Dict[str, Any]], ticker: str, trade_date: str) -> str:
if not rules:
return (
f"No factor rules were loaded for {ticker} on {trade_date}. "
"Treat this as missing custom factor context and do not fabricate rule-based signals."
)
lines = [
f"Factor rule context for {ticker} on {trade_date}.",
f"Loaded {len(rules)} manually curated factor rules.",
"Use these as explicit analyst guidance, not as guaranteed facts.",
"",
]
for idx, rule in enumerate(rules, 1):
name = rule.get("name", f"Rule {idx}")
thesis = rule.get("thesis", "")
signal = rule.get("signal", "neutral")
weight = rule.get("weight", "medium")
rationale = rule.get("rationale", "")
conditions = rule.get("conditions", [])
conditions_text = "; ".join(str(c) for c in conditions) if conditions else "No explicit conditions provided"
lines.extend(
[
f"Rule {idx}: {name}",
f"- Signal bias: {signal}",
f"- Weight: {weight}",
f"- Thesis: {thesis}",
f"- Conditions: {conditions_text}",
f"- Rationale: {rationale}",
"",
]
)
bullish = [r for r in rules if str(r.get("signal", "")).lower() in {"bullish", "buy", "positive"}]
bearish = [r for r in rules if str(r.get("signal", "")).lower() in {"bearish", "sell", "negative"}]
neutral = len(rules) - len(bullish) - len(bearish)
lines.extend(
[
"Portfolio-level summary:",
f"- Bullish leaning rules: {len(bullish)}",
f"- Bearish leaning rules: {len(bearish)}",
f"- Neutral / mixed rules: {neutral}",
"When these rules conflict with market/news/fundamental evidence, explicitly discuss the conflict instead of ignoring it.",
]
)
return "\n".join(lines)

View File

@ -12,6 +12,7 @@ DEFAULT_CONFIG = {
"deep_think_llm": "gpt-5.2",
"quick_think_llm": "gpt-5-mini",
"backend_url": "https://api.openai.com/v1",
"factor_rules_path": os.getenv("TRADINGAGENTS_FACTOR_RULES_PATH", ""),
# Provider-specific thinking configuration
"google_thinking_level": None, # "high", "minimal", etc.
"openai_reasoning_effort": None, # "medium", "high", "low"

View File

@ -0,0 +1,37 @@
{
"rules": [
{
"name": "AI capex acceleration",
"signal": "bullish",
"weight": "high",
"thesis": "Sustained AI infrastructure capex can support revenue growth and pricing power for leading compute and semiconductor firms.",
"conditions": [
"Management guides for rising AI-related backlog or enterprise demand.",
"Gross margin remains resilient while capex grows."
],
"rationale": "Use this rule when the company is a direct beneficiary of AI infrastructure demand and execution quality remains strong."
},
{
"name": "Valuation stretch under slowing growth",
"signal": "bearish",
"weight": "high",
"thesis": "If valuation stays elevated while growth momentum decelerates, downside risk increases materially.",
"conditions": [
"Forward growth guidance is revised down.",
"Multiple expansion outpaces earnings revision."
],
"rationale": "Use this rule as a cautionary overlay when expectations appear richer than operating reality."
},
{
"name": "Balance sheet resilience",
"signal": "bullish",
"weight": "medium",
"thesis": "Net cash, high operating cash flow, and low refinancing pressure improve resilience during drawdowns.",
"conditions": [
"Net cash or modest leverage profile.",
"Healthy free cash flow conversion."
],
"rationale": "This factor matters most when macro conditions tighten and investors reward durability."
}
]
}

View File

@ -43,6 +43,10 @@ class ConditionalLogic:
return "tools_fundamentals"
return "Msg Clear Fundamentals"
def should_continue_factor_rules(self, state: AgentState):
"""Factor rule analyst is a pure context node with no tool loop."""
return "Msg Clear Factor_rules"
def should_continue_debate(self, state: AgentState) -> str:
"""Determine if debate should continue."""

View File

@ -37,6 +37,7 @@ class Propagator:
),
"market_report": "",
"fundamentals_report": "",
"factor_rules_report": "",
"sentiment_report": "",
"news_report": "",
}

View File

@ -38,7 +38,7 @@ class GraphSetup:
self.conditional_logic = conditional_logic
def setup_graph(
self, selected_analysts=["market", "social", "news", "fundamentals"]
self, selected_analysts=["market", "social", "news", "fundamentals", "factor_rules"]
):
"""Set up and compile the agent workflow graph.
@ -48,6 +48,7 @@ class GraphSetup:
- "social": Social media analyst
- "news": News analyst
- "fundamentals": Fundamentals analyst
- "factor_rules": Factor rule analyst
"""
if len(selected_analysts) == 0:
raise ValueError("Trading Agents Graph Setup Error: no analysts selected!")
@ -85,6 +86,12 @@ class GraphSetup:
delete_nodes["fundamentals"] = create_msg_delete()
tool_nodes["fundamentals"] = self.tool_nodes["fundamentals"]
if "factor_rules" in selected_analysts:
analyst_nodes["factor_rules"] = create_factor_rule_analyst(
self.quick_thinking_llm
)
delete_nodes["factor_rules"] = create_msg_delete()
# Create researcher and manager nodes
bull_researcher_node = create_bull_researcher(
self.quick_thinking_llm, self.bull_memory
@ -114,7 +121,8 @@ class GraphSetup:
workflow.add_node(
f"Msg Clear {analyst_type.capitalize()}", delete_nodes[analyst_type]
)
workflow.add_node(f"tools_{analyst_type}", tool_nodes[analyst_type])
if analyst_type in tool_nodes:
workflow.add_node(f"tools_{analyst_type}", tool_nodes[analyst_type])
# Add other nodes
workflow.add_node("Bull Researcher", bull_researcher_node)
@ -138,12 +146,20 @@ class GraphSetup:
current_clear = f"Msg Clear {analyst_type.capitalize()}"
# Add conditional edges for current analyst
workflow.add_conditional_edges(
current_analyst,
getattr(self.conditional_logic, f"should_continue_{analyst_type}"),
[current_tools, current_clear],
)
workflow.add_edge(current_tools, current_analyst)
continue_fn = getattr(self.conditional_logic, f"should_continue_{analyst_type}")
if analyst_type in tool_nodes:
workflow.add_conditional_edges(
current_analyst,
continue_fn,
[current_tools, current_clear],
)
workflow.add_edge(current_tools, current_analyst)
else:
workflow.add_conditional_edges(
current_analyst,
continue_fn,
[current_clear],
)
# Connect to next analyst or to Bull Researcher if this is the last analyst
if i < len(selected_analysts) - 1:

View File

@ -45,7 +45,7 @@ class TradingAgentsGraph:
def __init__(
self,
selected_analysts=["market", "social", "news", "fundamentals"],
selected_analysts=["market", "social", "news", "fundamentals", "factor_rules"],
debug=False,
config: Dict[str, Any] = None,
callbacks: Optional[List] = None,
@ -227,6 +227,7 @@ class TradingAgentsGraph:
"sentiment_report": final_state["sentiment_report"],
"news_report": final_state["news_report"],
"fundamentals_report": final_state["fundamentals_report"],
"factor_rules_report": final_state.get("factor_rules_report", ""),
"investment_debate_state": {
"bull_history": final_state["investment_debate_state"]["bull_history"],
"bear_history": final_state["investment_debate_state"]["bear_history"],