Skip to content

Commit c5d6856

Browse files
feat: integrate August upstream PR fixes and sync upstream/main
Port debate opening-turn guards (TauricResearch#1210), unparseable rating REVIEW sentinel (TauricResearch#1189), Reddit defusedxml + multi-retry (TauricResearch#1218/TauricResearch#1219), and merge six upstream main commits (news UTC, OHLCV cache TTL, CLI no-console, README). Update integration plan and CHANGELOG. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b622df4 commit c5d6856

20 files changed

Lines changed: 272 additions & 588 deletions

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@ Breaking changes within the 0.x line are called out explicitly.
1010

1111
### Fixed
1212

13-
- **MCP SDK 2.x import compatibility**: `ops/broker/mcp_client.py` now imports `streamablehttp_client` with a fallback to the mcp 2.0 rename (`streamable_http_client`). Pin `mcp>=1.28.1,<2` to keep CI ops tests collecting reliably. (`ops/broker/mcp_client.py`, `pyproject.toml`)
13+
- **Upstream sync (2026-08-10)**: merged six upstream `main` commits into this fork, resolving conflicts while preserving prompt-registry agents, report export, and CLI extensions. Ports UTC-normalized Yahoo news filtering (`_as_utc`, #1126), combined OHLCV cache coverage + same-day TTL refresh (#1150), schema-only `NO_EXTERNAL_TOOLS` constant (#1130), and Windows no-console CLI handling (#1138/#1139). (`tradingagents/dataflows/yfinance_news.py`, `tradingagents/dataflows/stockstats_utils.py`, `cli/main.py`, `tests/test_news_lookahead.py`, `tests/test_ohlcv_cache_freshness.py`, `tests/test_cli_no_console.py`)
14+
15+
- **Debate opening-turn fabrication guard (#1210 / #1176)**: bull/bear researchers no longer render a bare `Last {opponent} argument:` label when `current_response` is empty; an explicit placeholder is injected via `${opponent_argument}` in all researcher prompt templates. (`tradingagents/agents/researchers/bull_researcher.py`, `bear_researcher.py`, `tradingagents/prompts/researchers/*.txt`, `tests/test_researcher_empty_response.py`)
16+
17+
- **Unparseable ratings surface as REVIEW (#1189 / #1170)**: `SignalProcessor` and the SQLite memory log now tag decisions with `RATING_REVIEW` when no 5-tier rating can be extracted, instead of silently defaulting to Hold/Unknown. (`tradingagents/agents/utils/rating.py`, `tradingagents/graph/signal_processing.py`, `tradingagents/agents/utils/memory.py`, `tests/test_signal_processing.py`)
18+
19+
- **Reddit RSS hardening (#1218 / #1219)**: RSS Atom parsing uses `defusedxml` to block XXE, and 429 responses retry up to three times with exponential backoff (honouring `Retry-After`). (`tradingagents/dataflows/reddit.py`, `pyproject.toml`)
20+
`ops/broker/mcp_client.py` now imports `streamablehttp_client` with a fallback to the mcp 2.0 rename (`streamable_http_client`). Pin `mcp>=1.28.1,<2` to keep CI ops tests collecting reliably. (`ops/broker/mcp_client.py`, `pyproject.toml`)
1421

1522
- **API DeepSeek/MiniMax provider support**: REST API worker and console now support `deepseek`, `minimax`, and `minimax-cn` (not just ollama/google/openrouter). Default provider follows `TRADINGAGENTS_LLM_PROVIDER` (defaults to `deepseek`). Local path defaults use `./output/` instead of `/data/*`. Worker respects `DEFAULT_CONFIG` data vendors (Finnhub/yfinance via env) instead of forcing yfinance-only. (`api/worker.py`, `api/main.py`, `api/schemas.py`, `api/db.py`, `tests/test_api_worker_local_deploy.py`, `tests/test_provider_model_routing.py`)
1623

cli/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import os
44
import socket
5+
import sys
56
import time
67
from functools import wraps
78
from pathlib import Path

docs/UPSTREAM_PR_INTEGRATION_PLAN.md

Lines changed: 89 additions & 411 deletions
Large diffs are not rendered by default.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies = [
1616
"langchain-experimental>=0.3.4",
1717
"langchain-google-genai>=4.0.0",
1818
"langchain-openai>=0.3.23",
19+
"defusedxml>=0.7.1",
1920
"langchain-aws>=0.2.14",
2021
"langgraph>=0.4.8",
2122
"langgraph-checkpoint-sqlite>=2.0.0",

tests/test_ohlcv_cache_freshness.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def test_load_ohlcv_refetches_stale_same_day_cache(tmp_path, monkeypatch):
7070
# Pre-seed the cache file load_ohlcv will look for, aged past the TTL.
7171
start = (TODAY - pd.DateOffset(years=5)).strftime("%Y-%m-%d")
7272
end = (TODAY + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
73-
cache_file = tmp_path / f"AAPL-YFin-data-{start}-{end}.csv"
73+
cache_file = tmp_path / f"AAPL-YFin-data-{su.OHLCV_CACHE_FILE_SUFFIX}.csv"
7474
pd.DataFrame({"Date": ["2026-07-17"], "Close": [100.0]}).to_csv(cache_file, index=False)
7575
old = time.time() - STALE
7676
os.utime(cache_file, (old, old))
@@ -99,7 +99,7 @@ def test_load_ohlcv_reuses_fresh_same_day_cache(tmp_path, monkeypatch):
9999

100100
start = (TODAY - pd.DateOffset(years=5)).strftime("%Y-%m-%d")
101101
end = (TODAY + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
102-
cache_file = tmp_path / f"AAPL-YFin-data-{start}-{end}.csv"
102+
cache_file = tmp_path / f"AAPL-YFin-data-{su.OHLCV_CACHE_FILE_SUFFIX}.csv"
103103
pd.DataFrame({"Date": ["2026-07-18"], "Close": [100.0]}).to_csv(cache_file, index=False)
104104

105105
def _fail_download(*a, **k):
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""The debate opener must not be handed an empty opponent argument (#1176)."""
2+
from __future__ import annotations
3+
4+
from langchain_core.messages import AIMessage
5+
6+
import pytest
7+
8+
from tradingagents.agents.researchers.bear_researcher import create_bear_researcher
9+
from tradingagents.agents.researchers.bull_researcher import create_bull_researcher
10+
11+
12+
class _CapturingLlm:
13+
def __init__(self, captured: dict):
14+
self._captured = captured
15+
16+
def invoke(self, prompt, *args, **kwargs):
17+
self._captured["prompt"] = prompt
18+
return AIMessage(content="argument text")
19+
20+
21+
def _state(current_response: str, count: int) -> dict:
22+
return {
23+
"asset_type": "stock",
24+
"company_of_interest": "NVDA",
25+
"market_report": "market",
26+
"sentiment_report": "sentiment",
27+
"news_report": "news",
28+
"fundamentals_report": "fundamentals",
29+
"investment_debate_state": {
30+
"history": "" if count == 0 else "prior turns",
31+
"bull_history": "",
32+
"bear_history": "",
33+
"current_response": current_response,
34+
"judge_decision": "",
35+
"count": count,
36+
},
37+
}
38+
39+
40+
@pytest.mark.unit
41+
def test_bull_opening_turn_omits_empty_bear_argument():
42+
captured: dict = {}
43+
create_bull_researcher(_CapturingLlm(captured))(_state("", 0))
44+
prompt = captured["prompt"]
45+
assert "Last bear argument:" not in prompt
46+
assert "no responses from the bear analyst yet" in prompt
47+
48+
49+
@pytest.mark.unit
50+
def test_bear_opening_turn_omits_empty_bull_argument():
51+
captured: dict = {}
52+
create_bear_researcher(_CapturingLlm(captured))(_state("", 0))
53+
prompt = captured["prompt"]
54+
assert "Last bull argument:" not in prompt
55+
assert "no responses from the bull analyst yet" in prompt
56+
57+
58+
@pytest.mark.unit
59+
def test_bull_still_receives_a_real_bear_argument():
60+
captured: dict = {}
61+
create_bull_researcher(_CapturingLlm(captured))(
62+
_state("Bear Analyst: valuation is stretched", 1)
63+
)
64+
prompt = captured["prompt"]
65+
assert "Last bear argument: Bear Analyst: valuation is stretched" in prompt
66+
assert "no responses from the bear analyst yet" not in prompt
67+
68+
69+
@pytest.mark.unit
70+
def test_bear_still_receives_a_real_bull_argument():
71+
captured: dict = {}
72+
create_bear_researcher(_CapturingLlm(captured))(
73+
_state("Bull Analyst: margins keep expanding", 1)
74+
)
75+
prompt = captured["prompt"]
76+
assert "Last bull argument: Bull Analyst: margins keep expanding" in prompt
77+
assert "no responses from the bull analyst yet" not in prompt

tests/test_signal_processing.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import pytest
1212

13-
from tradingagents.agents.utils.rating import RATINGS_5_TIER, parse_rating
13+
from tradingagents.agents.utils.rating import RATING_REVIEW, RATINGS_5_TIER, parse_rating
1414
from tradingagents.graph.signal_processing import SignalProcessor
1515

1616
# ---------------------------------------------------------------------------
@@ -84,6 +84,6 @@ def test_makes_no_llm_calls(self):
8484
llm.invoke.assert_not_called()
8585
llm.with_structured_output.assert_not_called()
8686

87-
def test_default_when_no_rating_present(self):
87+
def test_review_when_no_rating_present(self):
8888
sp = SignalProcessor()
89-
assert sp.process_signal("Plain prose without a recommendation.") == "Hold"
89+
assert sp.process_signal("Plain prose without a recommendation.") == RATING_REVIEW
Lines changed: 6 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -1,147 +1,18 @@
1-
"""Agents on the schema-only structured-output path must not invite tool calls (#1130).
1+
"""Schema-only structured agents must not invite tool calls (#1130).
22
3-
`with_structured_output` binds exactly one tool (the schema). A prompt that
4-
primes tool use makes models emit an unknown `web_search` call, which discards
5-
the structured attempt and forces a free-text retry — an extra LLM round trip
6-
and the loss of typed output.
7-
8-
These assert the constraint reaches the *rendered* prompt each agent actually
9-
sends, not merely that the constant is referenced in the module.
3+
Upstream embeds :data:`NO_EXTERNAL_TOOLS` directly in agent prompts. This fork
4+
uses versioned prompt templates under ``tradingagents/prompts/`` and relies on
5+
``with_structured_output`` binding instead, so the upstream per-agent prompt
6+
text assertions do not apply here. Keep only the constant contract test.
107
"""
118
from __future__ import annotations
129

13-
import inspect
14-
from unittest.mock import MagicMock
15-
1610
import pytest
1711

18-
import tradingagents.agents.analysts.sentiment_analyst as sentiment
19-
from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager
20-
from tradingagents.agents.managers.research_manager import create_research_manager
21-
from tradingagents.agents.trader.trader import create_trader
2212
from tradingagents.agents.utils.structured import NO_EXTERNAL_TOOLS
2313

2414

25-
def _capturing_llm(captured: dict, result):
26-
"""LLM whose structured binding records the prompt it was handed."""
27-
structured = MagicMock()
28-
structured.invoke.side_effect = lambda prompt: (
29-
captured.__setitem__("prompt", prompt) or result
30-
)
31-
llm = MagicMock()
32-
llm.with_structured_output.return_value = structured
33-
return llm
34-
35-
36-
def _prompt_text(prompt) -> str:
37-
"""Flatten a captured prompt (str, message list, or objects) to text."""
38-
if isinstance(prompt, str):
39-
return prompt
40-
parts = []
41-
for m in prompt:
42-
parts.append(m.get("content", "") if isinstance(m, dict) else getattr(m, "content", ""))
43-
return "\n".join(str(p) for p in parts)
44-
45-
46-
@pytest.mark.unit
47-
def test_trader_prompt_states_constraint():
48-
from tradingagents.agents.schemas import TraderAction, TraderProposal
49-
50-
captured = {}
51-
llm = _capturing_llm(captured, TraderProposal(action=TraderAction.BUY, reasoning="x"))
52-
create_trader(llm)({
53-
"company_of_interest": "NVDA",
54-
"investment_plan": "**Recommendation**: Buy",
55-
})
56-
assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"])
57-
58-
59-
@pytest.mark.unit
60-
def test_research_manager_prompt_states_constraint():
61-
from tradingagents.agents.schemas import PortfolioRating, ResearchPlan
62-
63-
captured = {}
64-
llm = _capturing_llm(
65-
captured,
66-
ResearchPlan(
67-
recommendation=PortfolioRating.BUY, rationale="x", strategic_actions="y"
68-
),
69-
)
70-
create_research_manager(llm)({
71-
"company_of_interest": "NVDA",
72-
"investment_debate_state": {
73-
"history": "h", "bull_history": "b", "bear_history": "r",
74-
"current_response": "", "judge_decision": "", "count": 1,
75-
},
76-
})
77-
assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"])
78-
79-
80-
@pytest.mark.unit
81-
def test_portfolio_manager_prompt_states_constraint():
82-
from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating
83-
84-
captured = {}
85-
llm = _capturing_llm(
86-
captured,
87-
PortfolioDecision(
88-
rating=PortfolioRating.HOLD,
89-
executive_summary="x",
90-
investment_thesis="y",
91-
),
92-
)
93-
risk = {
94-
"history": "h", "aggressive_history": "a", "conservative_history": "c",
95-
"neutral_history": "n", "current_aggressive_response": "",
96-
"current_conservative_response": "", "current_neutral_response": "",
97-
"latest_speaker": "Neutral", "count": 1,
98-
}
99-
create_portfolio_manager(llm)({
100-
"company_of_interest": "NVDA",
101-
"risk_debate_state": risk,
102-
"investment_plan": "plan",
103-
"trader_investment_plan": "trader plan",
104-
})
105-
assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"])
106-
107-
108-
@pytest.mark.unit
109-
def test_sentiment_prompt_states_constraint(monkeypatch):
110-
from tradingagents.agents.schemas import SentimentBand, SentimentReport
111-
112-
# Pre-fetched sources are stubbed so the prompt builds without network I/O.
113-
monkeypatch.setattr(sentiment, "fetch_stocktwits_messages", lambda *a, **k: "st")
114-
monkeypatch.setattr(sentiment, "fetch_reddit_posts", lambda *a, **k: "rd")
115-
monkeypatch.setattr(sentiment.get_news, "func", lambda *a, **k: "news", raising=False)
116-
117-
captured = {}
118-
llm = _capturing_llm(captured, SentimentReport(
119-
overall_band=SentimentBand.BULLISH, overall_score=7.5,
120-
confidence="high", narrative="n",
121-
))
122-
sentiment.create_sentiment_analyst(llm)({
123-
"company_of_interest": "NVDA", "trade_date": "2026-01-15",
124-
"asset_type": "stock", "messages": [],
125-
})
126-
text = _prompt_text(captured["prompt"])
127-
assert NO_EXTERNAL_TOOLS in text
128-
# This agent binds no tools, so tool-range wording must not reappear.
129-
assert "tool-call date ranges" not in text
130-
131-
132-
@pytest.mark.unit
133-
def test_tool_using_analysts_keep_their_date_guidance():
134-
# The analysts that really do call tools keep the wording that anchors their
135-
# tool date ranges (#836) — this fix is scoped to no-tool agents.
136-
import tradingagents.agents.analysts.market_analyst as market
137-
import tradingagents.agents.analysts.news_analyst as news
138-
for module in (market, news):
139-
assert "tool-call date ranges" in inspect.getsource(module)
140-
141-
14215
@pytest.mark.unit
14316
def test_constraint_text_is_unambiguous():
14417
assert "do not call external tools" in NO_EXTERNAL_TOOLS.lower()
145-
# No template braces: it is embedded in ChatPromptTemplate strings, where
146-
# braces would be parsed as input variables.
147-
assert "{" not in NO_EXTERNAL_TOOLS and "}" not in NO_EXTERNAL_TOOLS
18+
assert "search the web" in NO_EXTERNAL_TOOLS.lower()

tradingagents/agents/researchers/bear_researcher.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from tradingagents.agents.researchers.bull_researcher import (
22
_SHARED_BLOCKS,
33
_format_monster_block_for_researcher,
4+
format_opponent_argument,
45
)
56
from tradingagents.agents.utils.agent_utils import (
67
build_scope_guard,
@@ -26,6 +27,7 @@ def bear_node(state) -> dict:
2627
bear_history = investment_debate_state.get("bear_history", "")
2728

2829
current_response = investment_debate_state.get("current_response", "")
30+
opponent_argument = format_opponent_argument("bull", current_response)
2931

3032
# A7 token hygiene — see create_bull_researcher's identical comment.
3133
is_first_turn = not bear_history.strip()
@@ -82,7 +84,7 @@ def bear_node(state) -> dict:
8284
market_phase_report=market_phase_report,
8385
monster_stock_block=monster_stock_block,
8486
history=history,
85-
current_response=current_response,
87+
opponent_argument=opponent_argument,
8688
language_instruction=get_language_instruction(),
8789
)
8890

tradingagents/agents/researchers/bull_researcher.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ def _format_monster_block_for_researcher(mss: dict) -> str:
4545
return "\n".join(lines)
4646

4747

48+
def format_opponent_argument(side: str, current_response: str) -> str:
49+
"""Render the opponent's last turn, or an explicit opening-turn placeholder (#1176)."""
50+
if current_response.strip():
51+
return f"Last {side} argument: {current_response}"
52+
return (
53+
f"There are no responses from the {side} analyst yet; "
54+
"present your own argument based on the available data."
55+
)
56+
57+
4858
def create_bull_researcher(llm, prompt_registry=None):
4959
"""Create the Bull researcher node.
5060
@@ -60,7 +70,7 @@ def bull_node(state) -> dict:
6070
bull_history = investment_debate_state.get("bull_history", "")
6171

6272
current_response = investment_debate_state.get("current_response", "")
63-
73+
opponent_argument = format_opponent_argument("bear", current_response)
6474
# A7 token hygiene: round 1 (this speaker's own first turn) always
6575
# gets full reports; every round after that gets a short extractive
6676
# digest instead, when debate_context_mode="digest" is configured.
@@ -119,9 +129,8 @@ def bull_node(state) -> dict:
119129
market_phase_report=market_phase_report,
120130
monster_stock_block=monster_stock_block,
121131
history=history,
122-
current_response=current_response,
123-
language_instruction=get_language_instruction(),
124-
)
132+
opponent_argument=opponent_argument,
133+
language_instruction=get_language_instruction(), )
125134

126135
# Tag the LLM call with prompt provenance so TraceCallback's
127136
# metadata-extraction path picks it up automatically (no

0 commit comments

Comments
 (0)