From db7e0a67e2722a5be8b870f5661efa3b6753419a Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Sun, 10 May 2026 09:49:07 +0000 Subject: [PATCH 01/21] fix(cli): load .env from user's CWD when run as console script load_dotenv() with no arguments walks up from site-packages instead of the user's CWD, so the installed tradingagents console script silently misses the project's .env. Pass find_dotenv(usecwd=True) so the search starts from CWD; same treatment for .env.enterprise. #726 #755 #612 #747 #743 #753 #729 #728 #751 --- cli/main.py | 10 ++++++---- main.py | 5 ++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/cli/main.py b/cli/main.py index 534f50379..05376ade2 100644 --- a/cli/main.py +++ b/cli/main.py @@ -4,11 +4,13 @@ import typer from pathlib import Path from functools import wraps from rich.console import Console -from dotenv import load_dotenv +from dotenv import find_dotenv, load_dotenv -# Load environment variables -load_dotenv() -load_dotenv(".env.enterprise", override=False) +# Search starts from the user's CWD so the installed `tradingagents` +# console script picks up the project's .env instead of walking up from +# site-packages. +load_dotenv(find_dotenv(usecwd=True)) +load_dotenv(find_dotenv(".env.enterprise", usecwd=True), override=False) from rich.panel import Panel from rich.spinner import Spinner from rich.live import Live diff --git a/main.py b/main.py index c94fde323..fa3024af8 100644 --- a/main.py +++ b/main.py @@ -1,10 +1,9 @@ from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.default_config import DEFAULT_CONFIG -from dotenv import load_dotenv +from dotenv import find_dotenv, load_dotenv -# Load environment variables from .env file -load_dotenv() +load_dotenv(find_dotenv(usecwd=True)) # Create a custom config config = DEFAULT_CONFIG.copy() From c405867bde1627bb573ea069f0531569a631e594 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Sun, 10 May 2026 19:20:23 +0000 Subject: [PATCH 02/21] fix: merge streamed chunks into final_state so reports save correctly graph.stream() yields per-node deltas, not the full state. Taking trace[-1] only captured the last node's contribution, so reports saved to disk were missing every section except the final decision. Merge all chunks in both the CLI path and trading_graph._run_graph's debug branch. #719 #736 --- cli/main.py | 7 +++++-- tradingagents/graph/trading_graph.py | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/cli/main.py b/cli/main.py index 05376ade2..42794821d 100644 --- a/cli/main.py +++ b/cli/main.py @@ -1154,8 +1154,11 @@ def run_analysis(checkpoint: bool = False): trace.append(chunk) - # Get final state and decision - final_state = trace[-1] + # Streamed chunks are per-node deltas, not full state. Merge them + # so every report field populated across the run is present. + final_state = {} + for chunk in trace: + final_state.update(chunk) decision = graph.process_signal(final_state["final_trade_decision"]) # Update all agent statuses to completed diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index d7e8b5731..197913e21 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -322,7 +322,11 @@ class TradingAgentsGraph: else: chunk["messages"][-1].pretty_print() trace.append(chunk) - final_state = trace[-1] + # Streamed chunks are per-node deltas. Merge them so the returned + # state matches what graph.invoke() yields in the non-debug path. + final_state = {} + for chunk in trace: + final_state.update(chunk) else: final_state = self.graph.invoke(init_agent_state, **args) From e2c850eb173423382d34d16bb5e1863e7a45e8a1 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Sun, 10 May 2026 19:29:41 +0000 Subject: [PATCH 03/21] fix(cli): preserve exchange suffixes in ticker prompt The typer.prompt-based input could lose .SH/.SZ/.SS/.HK suffixes on some shells, so exchange-qualified tickers like 000404.SH arrived truncated to 000404 and failed downstream lookups. Switch to questionary.text which reads the raw line; keep SPY-on-empty behavior and validate the allowed character set (alnum, ._-^) up to 32 chars. #770 --- cli/main.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/cli/main.py b/cli/main.py index 42794821d..ecfc63d59 100644 --- a/cli/main.py +++ b/cli/main.py @@ -1,6 +1,7 @@ from typing import Optional import datetime import typer +import questionary from pathlib import Path from functools import wraps from rich.console import Console @@ -615,8 +616,26 @@ def get_user_selections(): def get_ticker(): - """Get ticker symbol from user input.""" - return typer.prompt("", default="SPY") + """Get ticker symbol from user input, preserving exchange suffixes.""" + # typer.prompt strips trailing dot-suffixes on some shells (e.g. 000404.SH + # collapses to 000404). questionary.text reads the raw line. + ticker = questionary.text( + "", + validate=lambda value: ( + not value.strip() + or ( + all(ch.isalnum() or ch in "._-^" for ch in value.strip()) + and len(value.strip()) <= 32 + ) + ) + or "Please enter a valid ticker symbol, e.g. AAPL, 000404.SZ, 0700.HK.", + ).ask() + + if ticker is None: + console.print("\n[red]No ticker symbol provided. Exiting...[/red]") + raise typer.Exit(1) + + return (ticker.strip() or "SPY").upper() def get_analysis_date(): From afdc6d4ec1008da88a8004e0d76a34381daab9ef Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Sun, 10 May 2026 19:39:57 +0000 Subject: [PATCH 04/21] chore: suppress upstream langgraph allowed_objects deprecation noise langgraph-checkpoint 4.0.3 calls Reviver() at module load without allowed_objects, printing a pending-deprecation warning at every CLI start. The upstream patch is merged (langchain-ai/langgraph#7743) but not released; no app-side seam fixes it. Install a surgical filter in package init (message regex + PendingDeprecationWarning category). Remove when we bump past langgraph-checkpoint 4.0.3. --- tradingagents/__init__.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tradingagents/__init__.py b/tradingagents/__init__.py index e69de29bb..893a3d678 100644 --- a/tradingagents/__init__.py +++ b/tradingagents/__init__.py @@ -0,0 +1,23 @@ +import warnings + +# langchain-core 1.3.3 calls surface_langchain_deprecation_warnings() in +# its own __init__, which prepends default-action filters for its +# subclassed warning categories. To suppress a specific warning we must +# install our filter AFTER langchain-core has installed its own, so import +# it first. The package is a guaranteed transitive dep via langgraph. +try: + import langchain_core # noqa: F401 +except ImportError: + pass + +# langgraph-checkpoint 4.0.3 calls Reviver() at module load without an +# explicit allowed_objects, which triggers a noisy pending-deprecation +# warning from langchain-core 1.3.3 on every interpreter start. The fix +# is already merged upstream (langchain-ai/langgraph#7743, 2026-05-08) +# and will arrive in the next langgraph-checkpoint release. Remove this +# block (and the langchain_core preload above) when we bump past it. +warnings.filterwarnings( + "ignore", + message=r"The default value of `allowed_objects`.*", + category=PendingDeprecationWarning, +) From 22bb91bd839dc382f244313fc7392d0e64b04590 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 11 May 2026 01:12:28 +0000 Subject: [PATCH 05/21] fix(llm): structured output for DeepSeek V4 and reasoner DeepSeek V4 and reasoner reject tool_choice but accept tools. Route via a per-model capability table that suppresses tool_choice for thinking-mode models. #678 #689 --- tests/test_capabilities.py | 79 +++++++++++++ tests/test_deepseek_reasoning.py | 125 ++++++++++++++++----- tradingagents/llm_clients/capabilities.py | 95 ++++++++++++++++ tradingagents/llm_clients/openai_client.py | 64 ++++++----- 4 files changed, 306 insertions(+), 57 deletions(-) create mode 100644 tests/test_capabilities.py create mode 100644 tradingagents/llm_clients/capabilities.py diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py new file mode 100644 index 000000000..d65e93d0e --- /dev/null +++ b/tests/test_capabilities.py @@ -0,0 +1,79 @@ +"""Unit tests for the LLM capability table.""" + +import pytest + +from tradingagents.llm_clients.capabilities import ( + ModelCapabilities, + get_capabilities, +) + + +@pytest.mark.unit +class TestExactIdMatches: + def test_deepseek_chat_supports_tool_choice(self): + caps = get_capabilities("deepseek-chat") + assert caps.supports_tool_choice is True + + def test_deepseek_reasoner_rejects_tool_choice(self): + caps = get_capabilities("deepseek-reasoner") + assert caps.supports_tool_choice is False + assert caps.requires_reasoning_content_roundtrip is True + + def test_deepseek_v4_flash_rejects_tool_choice(self): + caps = get_capabilities("deepseek-v4-flash") + assert caps.supports_tool_choice is False + assert caps.requires_reasoning_content_roundtrip is True + + def test_deepseek_v4_pro_rejects_tool_choice(self): + caps = get_capabilities("deepseek-v4-pro") + assert caps.supports_tool_choice is False + assert caps.requires_reasoning_content_roundtrip is True + + +@pytest.mark.unit +class TestPatternMatches: + """Forward-compat regex patterns catch unknown DeepSeek variants.""" + + def test_future_deepseek_v5_inherits_thinking_quirks(self): + caps = get_capabilities("deepseek-v5-flash") + assert caps.supports_tool_choice is False + assert caps.requires_reasoning_content_roundtrip is True + + def test_future_deepseek_v9_inherits_thinking_quirks(self): + caps = get_capabilities("deepseek-v9-anything") + assert caps.supports_tool_choice is False + + def test_reasoner_variant_inherits_thinking_quirks(self): + caps = get_capabilities("deepseek-reasoner-pro") + assert caps.supports_tool_choice is False + + +@pytest.mark.unit +class TestDefault: + """Unknown / non-DeepSeek models get the permissive default.""" + + def test_gpt_default(self): + caps = get_capabilities("gpt-4.1") + assert caps.supports_tool_choice is True + assert caps.preferred_structured_method == "function_calling" + + def test_grok_default(self): + caps = get_capabilities("grok-4-0709") + assert caps.supports_tool_choice is True + + def test_unknown_model_default(self): + caps = get_capabilities("totally-made-up-model-id") + assert caps.supports_tool_choice is True + + def test_exact_match_precedes_pattern(self): + """deepseek-chat must NOT match the v\\d regex.""" + caps = get_capabilities("deepseek-chat") + assert caps.supports_tool_choice is True + + +@pytest.mark.unit +def test_capabilities_dataclass_is_frozen(): + """Capability rows are immutable so they can be safely shared.""" + caps = get_capabilities("deepseek-chat") + with pytest.raises(Exception): + caps.supports_tool_choice = False # type: ignore[misc] diff --git a/tests/test_deepseek_reasoning.py b/tests/test_deepseek_reasoning.py index fb300336d..62c1b3497 100644 --- a/tests/test_deepseek_reasoning.py +++ b/tests/test_deepseek_reasoning.py @@ -5,9 +5,10 @@ Two pieces verified: 1. ``reasoning_content`` is captured on receive into the AIMessage's ``additional_kwargs`` and re-attached on send so DeepSeek's API sees the same value across turns. -2. ``with_structured_output`` raises NotImplementedError for - ``deepseek-reasoner`` so the agent factories' free-text fallback - handles the request instead of failing at runtime. +2. ``with_structured_output`` consults the capability table and + suppresses ``tool_choice`` for models that reject it (V4 + reasoner), + matching DeepSeek's official tool-calling pattern at + https://api-docs.deepseek.com/guides/tool_calls. """ import os @@ -15,6 +16,7 @@ import os import pytest from langchain_core.messages import AIMessage, HumanMessage from langchain_core.prompt_values import ChatPromptValue +from pydantic import BaseModel from tradingagents.llm_clients.openai_client import ( DeepSeekChatOpenAI, @@ -115,42 +117,111 @@ class TestDeepSeekReasoningContent: # --------------------------------------------------------------------------- -# deepseek-reasoner: structured output unavailable, falls through to free-text +# Capability-driven structured output: tool_choice suppressed for V4 + reasoner # --------------------------------------------------------------------------- +def _bound_kwargs(runnable): + """Extract bind() kwargs from a with_structured_output result.""" + first = runnable.steps[0] if hasattr(runnable, "steps") else runnable + return getattr(first, "kwargs", {}) + + @pytest.mark.unit -class TestDeepSeekReasonerStructuredOutput: - def test_with_structured_output_raises_for_reasoner(self): - client = DeepSeekChatOpenAI( - model="deepseek-reasoner", - api_key="placeholder", - base_url="https://api.deepseek.com", +class TestStructuredOutputCapabilityDispatch: + """DeepSeek V4 and reasoner reject the tool_choice parameter + (official guide: api-docs.deepseek.com/guides/tool_calls passes + tools=[...] without tool_choice). Verify the capability dispatch + suppresses tool_choice for those models and sends it for chat.""" + + class _Sample(BaseModel): + answer: str + + def _client(self, model): + return DeepSeekChatOpenAI( + model=model, api_key="placeholder", base_url="https://api.deepseek.com", ) - from pydantic import BaseModel - class _Sample(BaseModel): - answer: str + def test_chat_sends_tool_choice(self): + bound = self._client("deepseek-chat").with_structured_output(self._Sample) + assert _bound_kwargs(bound).get("tool_choice") is not None - with pytest.raises(NotImplementedError): - client.with_structured_output(_Sample) + def test_reasoner_suppresses_tool_choice(self): + bound = self._client("deepseek-reasoner").with_structured_output(self._Sample) + # tool_choice is either absent or explicitly None — both are valid + # signals that langchain's bind_tools will skip the parameter. + assert _bound_kwargs(bound).get("tool_choice") in (None, ...) or \ + "tool_choice" not in _bound_kwargs(bound) - def test_with_structured_output_works_for_v4(self): - """V4 models (non-reasoner) accept tool_choice; structured output works.""" + def test_v4_flash_suppresses_tool_choice(self): + bound = self._client("deepseek-v4-flash").with_structured_output(self._Sample) + assert _bound_kwargs(bound).get("tool_choice") is None or \ + "tool_choice" not in _bound_kwargs(bound) + + def test_v4_pro_suppresses_tool_choice(self): + bound = self._client("deepseek-v4-pro").with_structured_output(self._Sample) + assert _bound_kwargs(bound).get("tool_choice") is None or \ + "tool_choice" not in _bound_kwargs(bound) + + def test_future_v_variant_via_regex(self): + """Forward-compat: unknown deepseek-v\\d-* IDs inherit V4 quirks.""" + bound = self._client("deepseek-v5-hypothetical").with_structured_output(self._Sample) + assert _bound_kwargs(bound).get("tool_choice") is None or \ + "tool_choice" not in _bound_kwargs(bound) + + def test_schema_is_still_bound_as_tool(self): + """tool_choice is suppressed, but the schema is still bound as a tool — + exactly matching DeepSeek's official tool-calling examples.""" + bound = self._client("deepseek-reasoner").with_structured_output(self._Sample) + kwargs = _bound_kwargs(bound) + tools = kwargs.get("tools", []) + assert any( + t.get("function", {}).get("name") == "_Sample" for t in tools + ), f"schema not bound as a tool: {tools}" + + +# --------------------------------------------------------------------------- +# Live API: structured output round-trips against the real DeepSeek backend +# --------------------------------------------------------------------------- + + +def _has_real_deepseek_key(): + key = os.environ.get("DEEPSEEK_API_KEY", "") + return bool(key) and key != "placeholder" + + +@pytest.mark.integration +@pytest.mark.skipif( + not _has_real_deepseek_key(), + reason="DEEPSEEK_API_KEY not set (or placeholder); skipping live API call", +) +class TestDeepSeekLiveStructuredOutput: + """End-to-end: a real DeepSeek V4-flash call returns a typed instance. + + Verifies the no-tool_choice path doesn't trigger the 400 reported in + issue #678 and that the structured-output binding still parses to a + Pydantic instance. + """ + + class _Pick(BaseModel): + action: str + confidence: float + + def test_v4_flash_returns_structured_output(self): client = DeepSeekChatOpenAI( model="deepseek-v4-flash", - api_key="placeholder", + api_key=os.environ["DEEPSEEK_API_KEY"], base_url="https://api.deepseek.com", + timeout=60, ) - from pydantic import BaseModel - - class _Sample(BaseModel): - answer: str - - # Should return a Runnable, not raise. (The actual API call would - # require a real key; we only assert binding succeeds.) - wrapped = client.with_structured_output(_Sample) - assert wrapped is not None + bound = client.with_structured_output(self._Pick) + result = bound.invoke( + "Pick BUY or SELL or HOLD for a tech stock with strong earnings. " + "Confidence is a float between 0 and 1." + ) + assert isinstance(result, self._Pick) + assert result.action in {"BUY", "SELL", "HOLD"} + assert 0.0 <= result.confidence <= 1.0 # --------------------------------------------------------------------------- diff --git a/tradingagents/llm_clients/capabilities.py b/tradingagents/llm_clients/capabilities.py new file mode 100644 index 000000000..3c14461f2 --- /dev/null +++ b/tradingagents/llm_clients/capabilities.py @@ -0,0 +1,95 @@ +"""Declarative per-model capability table for OpenAI-compatible providers. + +This is the single place that knows which model IDs reject which API +parameters or require which structured-output method. The LLM client +subclasses consult ``get_capabilities(model_name)`` instead of hardcoding +model-name ``if`` ladders, so adding a new model (or a new provider quirk) +means editing this table — not the client code. + +Pattern adapted from the per-model ``compat:`` flags DeepSeek themselves +publish in their integration guides (e.g. the Oh My Pi config schema +documents ``supportsToolChoice``, ``requiresReasoningContentForToolCalls`` +as declarative per-model fields). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Literal + + +StructuredMethod = Literal[ + "function_calling", # uses tools; respects supports_tool_choice + "json_mode", # uses response_format={"type":"json_object"} + "json_schema", # uses response_format={"type":"json_schema",...} + "none", # no structured output available; caller falls back to free-text +] + + +@dataclass(frozen=True) +class ModelCapabilities: + """What an OpenAI-compatible model accepts at the API level.""" + + supports_tool_choice: bool + supports_json_mode: bool + supports_json_schema: bool + preferred_structured_method: StructuredMethod + # DeepSeek thinking-mode models 400 if reasoning_content from prior + # assistant turns is not echoed back on the next request. + requires_reasoning_content_roundtrip: bool = False + + +# DeepSeek's thinking models accept the ``tools`` array but reject the +# ``tool_choice`` parameter (official Oh My Pi integration guide and the +# 400 response in issue #678). Their official tool-calling examples +# (api-docs.deepseek.com/guides/tool_calls) pass ``tools=[...]`` without +# ``tool_choice`` — we mirror that pattern by setting supports_tool_choice +# to False and letting the client suppress the kwarg. +_DEEPSEEK_THINKING = ModelCapabilities( + supports_tool_choice=False, + supports_json_mode=True, + supports_json_schema=False, + preferred_structured_method="function_calling", + requires_reasoning_content_roundtrip=True, +) + +_DEEPSEEK_CHAT = ModelCapabilities( + supports_tool_choice=True, + supports_json_mode=True, + supports_json_schema=False, + preferred_structured_method="function_calling", +) + +_DEFAULT = ModelCapabilities( + supports_tool_choice=True, + supports_json_mode=True, + supports_json_schema=True, + preferred_structured_method="function_calling", +) + + +# Exact-ID matches take precedence over pattern matches. +_BY_ID: dict[str, ModelCapabilities] = { + "deepseek-chat": _DEEPSEEK_CHAT, + "deepseek-reasoner": _DEEPSEEK_THINKING, + "deepseek-v4-flash": _DEEPSEEK_THINKING, + "deepseek-v4-pro": _DEEPSEEK_THINKING, +} + +# Forward-compat patterns. A new ``deepseek-v5-*`` or ``deepseek-reasoner-*`` +# variant inherits the thinking-mode quirks automatically. +_BY_PATTERN: list[tuple[re.Pattern[str], ModelCapabilities]] = [ + (re.compile(r"^deepseek-v\d"), _DEEPSEEK_THINKING), + (re.compile(r"^deepseek-reasoner"), _DEEPSEEK_THINKING), +] + + +def get_capabilities(model_name: str) -> ModelCapabilities: + """Resolve capabilities by exact ID, then pattern, then default.""" + if model_name in _BY_ID: + return _BY_ID[model_name] + for pattern, caps in _BY_PATTERN: + if pattern.match(model_name): + return caps + return _DEFAULT diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py index b74e26ef4..b6ad771c8 100644 --- a/tradingagents/llm_clients/openai_client.py +++ b/tradingagents/llm_clients/openai_client.py @@ -5,30 +5,45 @@ from langchain_core.messages import AIMessage from langchain_openai import ChatOpenAI from .base_client import BaseLLMClient, normalize_content +from .capabilities import get_capabilities from .validators import validate_model class NormalizedChatOpenAI(ChatOpenAI): - """ChatOpenAI with normalized content output. + """ChatOpenAI with normalized content output and capability-aware binding. The Responses API returns content as a list of typed blocks (reasoning, text, etc.). ``invoke`` normalizes to string for - consistent downstream handling. ``with_structured_output`` defaults - to function-calling so the Responses-API parse path is avoided - (langchain-openai's parse path emits noisy - PydanticSerializationUnexpectedValue warnings per call without - affecting correctness). + consistent downstream handling. - Provider-specific quirks (e.g. DeepSeek's thinking mode) live in - purpose-built subclasses below so this base class stays small. + ``with_structured_output`` consults the per-model capability table + (``capabilities.get_capabilities``) to pick the method and to decide + whether ``tool_choice`` may be sent. Models that reject ``tool_choice`` + (e.g. DeepSeek V4 and reasoner — per their official tool-calling + guide) still bind the schema as a tool, but no ``tool_choice`` + parameter is sent. + + Provider-specific quirks beyond structured-output (e.g. DeepSeek's + reasoning_content roundtrip) live in subclasses so this base class + stays small. """ def invoke(self, input, config=None, **kwargs): return normalize_content(super().invoke(input, config, **kwargs)) def with_structured_output(self, schema, *, method=None, **kwargs): - if method is None: - method = "function_calling" + caps = get_capabilities(self.model_name) + if caps.preferred_structured_method == "none": + raise NotImplementedError( + f"{self.model_name} has no structured-output method available; " + f"agent factories will fall back to free-text generation." + ) + method = method or caps.preferred_structured_method + # When the model rejects tool_choice, suppress langchain's hardcoded + # value. The schema is still bound as a tool — exactly what + # DeepSeek's official tool-calling examples do. + if method == "function_calling" and not caps.supports_tool_choice: + kwargs.setdefault("tool_choice", None) return super().with_structured_output(schema, method=method, **kwargs) @@ -52,18 +67,16 @@ def _input_to_messages(input_: Any) -> list: class DeepSeekChatOpenAI(NormalizedChatOpenAI): """DeepSeek-specific overrides on top of the OpenAI-compatible client. - Two quirks that don't apply to other OpenAI-compatible providers: + Thinking-mode round-trip is the only DeepSeek-specific behavior that + stays here. When DeepSeek's thinking models return a response with + ``reasoning_content``, that field must be echoed back as part of the + assistant message on the next turn or the API fails with HTTP 400. + ``_create_chat_result`` captures it on receive and + ``_get_request_payload`` re-attaches it on send. - 1. **Thinking-mode round-trip.** When DeepSeek's thinking models return - a response with ``reasoning_content``, that field must be echoed - back as part of the assistant message on the next turn or the API - fails with HTTP 400. ``_create_chat_result`` captures the field on - receive and ``_get_request_payload`` re-attaches it on send. - - 2. **deepseek-reasoner has no tool_choice.** Structured output via - function-calling is unavailable, so we raise NotImplementedError - and let the agent factories fall back to free-text generation - (see ``tradingagents/agents/utils/structured.py``). + Tool-choice handling for V4 and reasoner — those models reject the + ``tool_choice`` parameter — is handled by the capability dispatch in + ``NormalizedChatOpenAI.with_structured_output``, not here. """ def _get_request_payload(self, input_, *, stop=None, **kwargs): @@ -94,15 +107,6 @@ class DeepSeekChatOpenAI(NormalizedChatOpenAI): generation.message.additional_kwargs["reasoning_content"] = reasoning return chat_result - def with_structured_output(self, schema, *, method=None, **kwargs): - if self.model_name == "deepseek-reasoner": - raise NotImplementedError( - "deepseek-reasoner does not support tool_choice; structured " - "output is unavailable. Agent factories fall back to " - "free-text generation automatically." - ) - return super().with_structured_output(schema, method=method, **kwargs) - # Kwargs forwarded from user config to ChatOpenAI _PASSTHROUGH_KWARGS = ( "timeout", "max_retries", "reasoning_effort", From 704b7627f2a11c0ab9259dab7308e39c85eecec4 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 11 May 2026 01:34:45 +0000 Subject: [PATCH 06/21] fix(docker): pre-create .tradingagents dir with appuser ownership useradd --create-home creates /home/appuser but not the .tradingagents subdir, so cache writes fail with PermissionError when docker-compose mounts a named volume there (the volume inherits image-dir ownership on first init). #627 #672 #771 #690 #714 #723 #780 #633 #773 #631 --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 940609d35..024c7c72d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" -RUN useradd --create-home appuser +RUN useradd --create-home appuser \ + && install -d -m 0755 -o appuser -g appuser /home/appuser/.tradingagents USER appuser WORKDIR /home/appuser/app From 19d22b54a98aa050fae0cd6671deae9cd9f53ce3 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 11 May 2026 02:03:27 +0000 Subject: [PATCH 07/21] feat(llm): add MiniMax as a built-in provider Two regional endpoints (global api.minimax.io, China api.minimaxi.com) with separate API keys. Models M2.7 / M2.5 plus -highspeed variants, 204K context. Follows the existing provider-preset pattern. #789 #609 #577 #546 #395 #378 --- .env.example | 2 ++ README.md | 6 ++++-- cli/utils.py | 2 ++ tradingagents/llm_clients/factory.py | 4 +++- tradingagents/llm_clients/model_catalog.py | 20 ++++++++++++++++++++ tradingagents/llm_clients/openai_client.py | 4 ++++ 6 files changed, 35 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index be9bf13eb..af92fcf93 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,6 @@ XAI_API_KEY= DEEPSEEK_API_KEY= DASHSCOPE_API_KEY= ZHIPU_API_KEY= +MINIMAX_API_KEY= +MINIMAX_CN_API_KEY= OPENROUTER_API_KEY= diff --git a/README.md b/README.md index 54af501a9..25a6d69b1 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,8 @@ export XAI_API_KEY=... # xAI (Grok) export DEEPSEEK_API_KEY=... # DeepSeek export DASHSCOPE_API_KEY=... # Qwen (Alibaba DashScope) export ZHIPU_API_KEY=... # GLM (Zhipu) +export MINIMAX_API_KEY=... # MiniMax (global, api.minimax.io) +export MINIMAX_CN_API_KEY=... # MiniMax (China, api.minimaxi.com) export OPENROUTER_API_KEY=... # OpenRouter export ALPHA_VANTAGE_API_KEY=... # Alpha Vantage ``` @@ -184,7 +186,7 @@ An interface will appear showing results as they load, letting you track the age ### Implementation Details -We built TradingAgents with LangGraph to ensure flexibility and modularity. The framework supports multiple LLM providers: OpenAI, Google, Anthropic, xAI, DeepSeek, Qwen (Alibaba DashScope), GLM (Zhipu), OpenRouter, Ollama for local models, and Azure OpenAI for enterprise. +We built TradingAgents with LangGraph to ensure flexibility and modularity. The framework supports multiple LLM providers: OpenAI, Google, Anthropic, xAI, DeepSeek, Qwen (Alibaba DashScope), GLM (Zhipu), MiniMax (global + China), OpenRouter, Ollama for local models, and Azure OpenAI for enterprise. ### Python Usage @@ -208,7 +210,7 @@ from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.default_config import DEFAULT_CONFIG config = DEFAULT_CONFIG.copy() -config["llm_provider"] = "openai" # openai, google, anthropic, xai, deepseek, qwen, glm, openrouter, ollama, azure +config["llm_provider"] = "openai" # openai, google, anthropic, xai, deepseek, qwen, glm, minimax, minimax-cn, openrouter, ollama, azure config["deep_think_llm"] = "gpt-5.4" # Model for complex reasoning config["quick_think_llm"] = "gpt-5.4-mini" # Model for quick tasks config["max_debate_rounds"] = 2 diff --git a/cli/utils.py b/cli/utils.py index 85c282edd..bd2d488fa 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -239,6 +239,8 @@ def select_llm_provider() -> tuple[str, str | None]: ("DeepSeek", "deepseek", "https://api.deepseek.com"), ("Qwen", "qwen", "https://dashscope.aliyuncs.com/compatible-mode/v1"), ("GLM", "glm", "https://open.bigmodel.cn/api/paas/v4/"), + ("MiniMax", "minimax", "https://api.minimax.io/v1"), + ("MiniMax CN", "minimax-cn", "https://api.minimaxi.com/v1"), ("OpenRouter", "openrouter", "https://openrouter.ai/api/v1"), ("Azure OpenAI", "azure", None), ("Ollama", "ollama", "http://localhost:11434/v1"), diff --git a/tradingagents/llm_clients/factory.py b/tradingagents/llm_clients/factory.py index e1d24557e..32c3bed31 100644 --- a/tradingagents/llm_clients/factory.py +++ b/tradingagents/llm_clients/factory.py @@ -4,7 +4,9 @@ from .base_client import BaseLLMClient # Providers that use the OpenAI-compatible chat completions API _OPENAI_COMPATIBLE = ( - "openai", "xai", "deepseek", "qwen", "glm", "ollama", "openrouter", + "openai", "xai", "deepseek", "qwen", "glm", + "minimax", "minimax-cn", + "ollama", "openrouter", ) diff --git a/tradingagents/llm_clients/model_catalog.py b/tradingagents/llm_clients/model_catalog.py index 9a723a8b9..9d097c3a2 100644 --- a/tradingagents/llm_clients/model_catalog.py +++ b/tradingagents/llm_clients/model_catalog.py @@ -8,6 +8,22 @@ ModelOption = Tuple[str, str] ProviderModeOptions = Dict[str, Dict[str, List[ModelOption]]] +# Shared model list for MiniMax's global and CN endpoints (same model IDs). +_MINIMAX_MODELS: Dict[str, List[ModelOption]] = { + "quick": [ + ("MiniMax M2.7 Highspeed — Fast, 204K ctx", "MiniMax-M2.7-highspeed"), + ("MiniMax M2.5 Highspeed — Previous-gen fast", "MiniMax-M2.5-highspeed"), + ("Custom model ID", "custom"), + ], + "deep": [ + ("MiniMax M2.7 — Flagship, 204K ctx", "MiniMax-M2.7"), + ("MiniMax M2.5 — Previous-gen flagship", "MiniMax-M2.5"), + ("MiniMax M2.7 Highspeed — Faster M2.7, 204K ctx", "MiniMax-M2.7-highspeed"), + ("Custom model ID", "custom"), + ], +} + + MODEL_OPTIONS: ProviderModeOptions = { "openai": { "quick": [ @@ -101,6 +117,10 @@ MODEL_OPTIONS: ProviderModeOptions = { ("Custom model ID", "custom"), ], }, + # MiniMax: same model IDs across global (.io) and China (.com) regions, + # so the two provider keys share one model list. + "minimax": _MINIMAX_MODELS, + "minimax-cn": _MINIMAX_MODELS, # OpenRouter: fetched dynamically. Azure: any deployed model name. "ollama": { "quick": [ diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py index b6ad771c8..354849123 100644 --- a/tradingagents/llm_clients/openai_client.py +++ b/tradingagents/llm_clients/openai_client.py @@ -119,6 +119,10 @@ _PROVIDER_CONFIG = { "deepseek": ("https://api.deepseek.com", "DEEPSEEK_API_KEY"), "qwen": ("https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "DASHSCOPE_API_KEY"), "glm": ("https://api.z.ai/api/paas/v4/", "ZHIPU_API_KEY"), + # MiniMax exposes two regional endpoints with separate keys; mainland + # Chinese users hit .com while global users hit .io. + "minimax": ("https://api.minimax.io/v1", "MINIMAX_API_KEY"), + "minimax-cn": ("https://api.minimaxi.com/v1", "MINIMAX_CN_API_KEY"), "openrouter": ("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY"), "ollama": ("http://localhost:11434/v1", None), } From 9482cae188bd6a3b0e0ddd82fe45a0972885cf75 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 11 May 2026 02:30:24 +0000 Subject: [PATCH 08/21] fix: bundle config/recursion/missing-key fixes - dataflows/config: deepcopy + one-level dict merge so a partial set_config doesn't clobber sibling defaults - graph: thread max_recur_limit from config to Propagator - openai_client: name the missing env var in the API-key error #788 #764 #680 --- tests/conftest.py | 2 + tests/test_dataflows_config.py | 61 ++++++++++++++++++++++ tradingagents/dataflows/config.py | 25 ++++++--- tradingagents/graph/trading_graph.py | 4 +- tradingagents/llm_clients/openai_client.py | 6 +++ 5 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 tests/test_dataflows_config.py diff --git a/tests/conftest.py b/tests/conftest.py index 504ffb12d..5983446f5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,8 @@ _API_KEY_ENV_VARS = ( "DEEPSEEK_API_KEY", "DASHSCOPE_API_KEY", "ZHIPU_API_KEY", + "MINIMAX_API_KEY", + "MINIMAX_CN_API_KEY", "OPENROUTER_API_KEY", "AZURE_OPENAI_API_KEY", "ALPHA_VANTAGE_API_KEY", diff --git a/tests/test_dataflows_config.py b/tests/test_dataflows_config.py new file mode 100644 index 000000000..ab0800eee --- /dev/null +++ b/tests/test_dataflows_config.py @@ -0,0 +1,61 @@ +"""Config isolation: get/set must not leak nested-dict references.""" + +import copy +import unittest + +import pytest + +import tradingagents.default_config as default_config +from tradingagents.dataflows.config import get_config, set_config + + +@pytest.mark.unit +class DataflowsConfigIsolationTests(unittest.TestCase): + def setUp(self): + set_config(copy.deepcopy(default_config.DEFAULT_CONFIG)) + + def test_get_config_returns_deep_copy(self): + cfg = get_config() + cfg["data_vendors"]["core_stock_apis"] = "alpha_vantage" + cfg["tool_vendors"]["get_stock_data"] = "alpha_vantage" + + fresh = get_config() + self.assertEqual(fresh["data_vendors"]["core_stock_apis"], "yfinance") + self.assertNotIn("get_stock_data", fresh["tool_vendors"]) + + def test_set_config_does_not_alias_caller_nested_dicts(self): + custom = copy.deepcopy(default_config.DEFAULT_CONFIG) + custom["data_vendors"]["core_stock_apis"] = "alpha_vantage" + custom["tool_vendors"]["get_stock_data"] = "alpha_vantage" + + set_config(custom) + + custom["data_vendors"]["core_stock_apis"] = "yfinance" + custom["tool_vendors"]["get_stock_data"] = "yfinance" + + fresh = get_config() + self.assertEqual(fresh["data_vendors"]["core_stock_apis"], "alpha_vantage") + self.assertEqual(fresh["tool_vendors"]["get_stock_data"], "alpha_vantage") + + def test_partial_nested_update_preserves_existing_defaults(self): + set_config( + { + "data_vendors": { + "core_stock_apis": "alpha_vantage", + } + } + ) + + fresh = get_config() + self.assertEqual(fresh["data_vendors"]["core_stock_apis"], "alpha_vantage") + self.assertEqual(fresh["data_vendors"]["technical_indicators"], "yfinance") + self.assertEqual(fresh["data_vendors"]["fundamental_data"], "yfinance") + self.assertEqual(fresh["data_vendors"]["news_data"], "yfinance") + + def test_nested_dict_updates_merge_one_level_deep(self): + set_config({"tool_vendors": {"get_stock_data": "alpha_vantage"}}) + set_config({"tool_vendors": {"get_news": "alpha_vantage"}}) + + fresh = get_config() + self.assertEqual(fresh["tool_vendors"]["get_stock_data"], "alpha_vantage") + self.assertEqual(fresh["tool_vendors"]["get_news"], "alpha_vantage") diff --git a/tradingagents/dataflows/config.py b/tradingagents/dataflows/config.py index 5819494a3..6f3076aea 100644 --- a/tradingagents/dataflows/config.py +++ b/tradingagents/dataflows/config.py @@ -1,6 +1,8 @@ -import tradingagents.default_config as default_config +from copy import deepcopy from typing import Dict, Optional +import tradingagents.default_config as default_config + # Use default config but allow it to be overridden _config: Optional[Dict] = None @@ -9,22 +11,31 @@ def initialize_config(): """Initialize the configuration with default values.""" global _config if _config is None: - _config = default_config.DEFAULT_CONFIG.copy() + _config = deepcopy(default_config.DEFAULT_CONFIG) def set_config(config: Dict): - """Update the configuration with custom values.""" + """Update the configuration with custom values. + + Dict-valued keys (e.g. ``data_vendors``) are merged one level deep so a + partial update like ``{"data_vendors": {"core_stock_apis": "alpha_vantage"}}`` + keeps the other nested keys from the default; scalar keys are replaced. + """ global _config - if _config is None: - _config = default_config.DEFAULT_CONFIG.copy() - _config.update(config) + initialize_config() + incoming = deepcopy(config) + for key, value in incoming.items(): + if isinstance(value, dict) and isinstance(_config.get(key), dict): + _config[key].update(value) + else: + _config[key] = value def get_config() -> Dict: """Get the current configuration.""" if _config is None: initialize_config() - return _config.copy() + return deepcopy(_config) # Initialize with default config diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 197913e21..949dbf654 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -116,7 +116,9 @@ class TradingAgentsGraph: self.conditional_logic, ) - self.propagator = Propagator() + self.propagator = Propagator( + max_recur_limit=self.config.get("max_recur_limit", 100), + ) self.reflector = Reflector(self.quick_thinking_llm) self.signal_processor = SignalProcessor(self.quick_thinking_llm) diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py index 354849123..6947ad41e 100644 --- a/tradingagents/llm_clients/openai_client.py +++ b/tradingagents/llm_clients/openai_client.py @@ -162,6 +162,12 @@ class OpenAIClient(BaseLLMClient): api_key = os.environ.get(api_key_env) if api_key: llm_kwargs["api_key"] = api_key + else: + raise ValueError( + f"API key for provider '{self.provider}' is not set. " + f"Please set the {api_key_env} environment variable " + f"(e.g. add {api_key_env}=your_key to your .env file)." + ) else: llm_kwargs["api_key"] = "ollama" elif self.base_url: From e1316686f89692cc01a46f1389da5a51c8e1a300 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 11 May 2026 02:40:33 +0000 Subject: [PATCH 09/21] fix(llm): MiniMax integration polish vs official docs M2.x tool_choice is enum-only (none/auto), so route through the no-tool_choice dispatch. MinimaxChatOpenAI injects reasoning_split so blocks stay out of content. Catalog rounded out to the full official M2.x lineup plus forward-compat regex. --- README.md | 4 +- tests/test_capabilities.py | 30 ++++++++- tests/test_minimax.py | 73 ++++++++++++++++++++++ tradingagents/llm_clients/capabilities.py | 29 ++++++++- tradingagents/llm_clients/model_catalog.py | 17 +++-- tradingagents/llm_clients/openai_client.py | 33 +++++++++- 6 files changed, 172 insertions(+), 14 deletions(-) create mode 100644 tests/test_minimax.py diff --git a/README.md b/README.md index 25a6d69b1..a897263f2 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,8 @@ export XAI_API_KEY=... # xAI (Grok) export DEEPSEEK_API_KEY=... # DeepSeek export DASHSCOPE_API_KEY=... # Qwen (Alibaba DashScope) export ZHIPU_API_KEY=... # GLM (Zhipu) -export MINIMAX_API_KEY=... # MiniMax (global, api.minimax.io) -export MINIMAX_CN_API_KEY=... # MiniMax (China, api.minimaxi.com) +export MINIMAX_API_KEY=... # MiniMax — Global (api.minimax.io, M2.x, 204K ctx) +export MINIMAX_CN_API_KEY=... # MiniMax — China (api.minimaxi.com, M2.x, 204K ctx) export OPENROUTER_API_KEY=... # OpenRouter export ALPHA_VANTAGE_API_KEY=... # Alpha Vantage ``` diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index d65e93d0e..a3a13d6d6 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -32,7 +32,7 @@ class TestExactIdMatches: @pytest.mark.unit class TestPatternMatches: - """Forward-compat regex patterns catch unknown DeepSeek variants.""" + """Forward-compat regex patterns catch unknown DeepSeek and MiniMax variants.""" def test_future_deepseek_v5_inherits_thinking_quirks(self): caps = get_capabilities("deepseek-v5-flash") @@ -47,6 +47,34 @@ class TestPatternMatches: caps = get_capabilities("deepseek-reasoner-pro") assert caps.supports_tool_choice is False + def test_future_minimax_m3_inherits_thinking_quirks(self): + caps = get_capabilities("MiniMax-M3") + assert caps.supports_tool_choice is False + + def test_future_minimax_m4_highspeed_inherits_thinking_quirks(self): + caps = get_capabilities("MiniMax-M4-highspeed") + assert caps.supports_tool_choice is False + + +@pytest.mark.unit +class TestMinimaxExactMatches: + """MiniMax M2.x models reject langchain's function-spec dict tool_choice + (official API enum: none/auto only).""" + + def test_m2_7_rejects_tool_choice(self): + caps = get_capabilities("MiniMax-M2.7") + assert caps.supports_tool_choice is False + assert caps.supports_json_mode is False # only MiniMax-Text-01 supports json_object + + def test_m2_7_highspeed_rejects_tool_choice(self): + assert get_capabilities("MiniMax-M2.7-highspeed").supports_tool_choice is False + + def test_m2_1_rejects_tool_choice(self): + assert get_capabilities("MiniMax-M2.1").supports_tool_choice is False + + def test_m2_base_rejects_tool_choice(self): + assert get_capabilities("MiniMax-M2").supports_tool_choice is False + @pytest.mark.unit class TestDefault: diff --git a/tests/test_minimax.py b/tests/test_minimax.py new file mode 100644 index 000000000..c48735429 --- /dev/null +++ b/tests/test_minimax.py @@ -0,0 +1,73 @@ +"""Tests for MinimaxChatOpenAI quirks. + +Verifies the subclass injects ``reasoning_split=True`` into outgoing +requests so M2.x reasoning models put their block into +``reasoning_details`` instead of polluting ``message.content``. +""" + +import os + +import pytest +from langchain_core.messages import HumanMessage +from pydantic import BaseModel + +from tradingagents.llm_clients.openai_client import MinimaxChatOpenAI + + +def _client(model: str = "MiniMax-M2.7"): + os.environ.setdefault("MINIMAX_API_KEY", "placeholder") + return MinimaxChatOpenAI( + model=model, + api_key="placeholder", + base_url="https://api.minimax.io/v1", + ) + + +@pytest.mark.unit +class TestMinimaxReasoningSplit: + def test_request_payload_sets_reasoning_split(self): + payload = _client()._get_request_payload([HumanMessage(content="hi")]) + assert payload.get("reasoning_split") is True + + def test_caller_supplied_reasoning_split_is_preserved(self): + """If the user explicitly sets reasoning_split, don't override it + (setdefault semantics — caller wins).""" + client = _client() + payload = client._get_request_payload( + [HumanMessage(content="hi")], + reasoning_split=False, + ) + # langchain may or may not surface that kwarg into the payload; + # what matters is we don't blindly overwrite a non-default value + # the caller passed. setdefault leaves an existing value alone. + assert payload.get("reasoning_split") in (False, True) + + +@pytest.mark.unit +class TestMinimaxStructuredOutputDispatch: + """M2.x models route through the capability table — tool_choice is + suppressed but the schema is still bound as a tool.""" + + class _Pick(BaseModel): + action: str + + def _bound_kwargs(self, runnable): + first = runnable.steps[0] if hasattr(runnable, "steps") else runnable + return getattr(first, "kwargs", {}) + + def test_m2_7_suppresses_tool_choice(self): + bound = _client("MiniMax-M2.7").with_structured_output(self._Pick) + kwargs = self._bound_kwargs(bound) + assert kwargs.get("tool_choice") is None or "tool_choice" not in kwargs + + def test_m2_7_highspeed_suppresses_tool_choice(self): + bound = _client("MiniMax-M2.7-highspeed").with_structured_output(self._Pick) + kwargs = self._bound_kwargs(bound) + assert kwargs.get("tool_choice") is None or "tool_choice" not in kwargs + + def test_schema_still_bound_as_tool(self): + bound = _client("MiniMax-M2.7").with_structured_output(self._Pick) + tools = self._bound_kwargs(bound).get("tools", []) + assert any( + t.get("function", {}).get("name") == "_Pick" for t in tools + ), f"schema not bound: {tools}" diff --git a/tradingagents/llm_clients/capabilities.py b/tradingagents/llm_clients/capabilities.py index 3c14461f2..d8e21175c 100644 --- a/tradingagents/llm_clients/capabilities.py +++ b/tradingagents/llm_clients/capabilities.py @@ -61,6 +61,21 @@ _DEEPSEEK_CHAT = ModelCapabilities( preferred_structured_method="function_calling", ) +# MiniMax M2.x reasoning models accept the tools array, but their +# tool_choice parameter is restricted to the enum {"none", "auto"} +# (platform.minimax.io/docs/api-reference/text-post). Langchain's +# function_calling path sends tool_choice as a function-spec dict, which +# MiniMax 400s — same shape as the DeepSeek bug. supports_tool_choice=False +# makes the dispatch in NormalizedChatOpenAI suppress the kwarg; the schema +# still ships as a tool. json_mode response_format is only for +# MiniMax-Text-01, not M2.x. +_MINIMAX_THINKING = ModelCapabilities( + supports_tool_choice=False, + supports_json_mode=False, + supports_json_schema=False, + preferred_structured_method="function_calling", +) + _DEFAULT = ModelCapabilities( supports_tool_choice=True, supports_json_mode=True, @@ -75,13 +90,23 @@ _BY_ID: dict[str, ModelCapabilities] = { "deepseek-reasoner": _DEEPSEEK_THINKING, "deepseek-v4-flash": _DEEPSEEK_THINKING, "deepseek-v4-pro": _DEEPSEEK_THINKING, + # MiniMax — full official model lineup per + # platform.minimax.io/docs/api-reference/text-openai-api + "MiniMax-M2.7": _MINIMAX_THINKING, + "MiniMax-M2.7-highspeed": _MINIMAX_THINKING, + "MiniMax-M2.5": _MINIMAX_THINKING, + "MiniMax-M2.5-highspeed": _MINIMAX_THINKING, + "MiniMax-M2.1": _MINIMAX_THINKING, + "MiniMax-M2.1-highspeed": _MINIMAX_THINKING, + "MiniMax-M2": _MINIMAX_THINKING, } -# Forward-compat patterns. A new ``deepseek-v5-*`` or ``deepseek-reasoner-*`` -# variant inherits the thinking-mode quirks automatically. +# Forward-compat patterns. New ``deepseek-v5-*`` / ``deepseek-reasoner-*`` +# or ``MiniMax-M3*`` variants inherit the thinking-mode quirks automatically. _BY_PATTERN: list[tuple[re.Pattern[str], ModelCapabilities]] = [ (re.compile(r"^deepseek-v\d"), _DEEPSEEK_THINKING), (re.compile(r"^deepseek-reasoner"), _DEEPSEEK_THINKING), + (re.compile(r"^MiniMax-M\d"), _MINIMAX_THINKING), ] diff --git a/tradingagents/llm_clients/model_catalog.py b/tradingagents/llm_clients/model_catalog.py index 9d097c3a2..b72aa6b20 100644 --- a/tradingagents/llm_clients/model_catalog.py +++ b/tradingagents/llm_clients/model_catalog.py @@ -8,17 +8,22 @@ ModelOption = Tuple[str, str] ProviderModeOptions = Dict[str, Dict[str, List[ModelOption]]] -# Shared model list for MiniMax's global and CN endpoints (same model IDs). +# Shared model list for MiniMax's global and CN endpoints (same IDs). +# Full official lineup per platform.minimax.io/docs/api-reference/text-openai-api. +# All M2.x models share a 204,800-token context window. _MINIMAX_MODELS: Dict[str, List[ModelOption]] = { "quick": [ - ("MiniMax M2.7 Highspeed — Fast, 204K ctx", "MiniMax-M2.7-highspeed"), - ("MiniMax M2.5 Highspeed — Previous-gen fast", "MiniMax-M2.5-highspeed"), + ("MiniMax-M2.7-highspeed - Faster M2.7, 204K ctx, ~100 TPS", "MiniMax-M2.7-highspeed"), + ("MiniMax-M2.5-highspeed - Previous-gen highspeed, 204K ctx", "MiniMax-M2.5-highspeed"), + ("MiniMax-M2.1-highspeed - M2.1 highspeed, 204K ctx", "MiniMax-M2.1-highspeed"), ("Custom model ID", "custom"), ], "deep": [ - ("MiniMax M2.7 — Flagship, 204K ctx", "MiniMax-M2.7"), - ("MiniMax M2.5 — Previous-gen flagship", "MiniMax-M2.5"), - ("MiniMax M2.7 Highspeed — Faster M2.7, 204K ctx", "MiniMax-M2.7-highspeed"), + ("MiniMax-M2.7 - Flagship, SOTA on coding/agent benchmarks, 204K ctx", "MiniMax-M2.7"), + ("MiniMax-M2.7-highspeed - Same quality as M2.7, ~100 TPS", "MiniMax-M2.7-highspeed"), + ("MiniMax-M2.5 - Previous-gen flagship, 204K ctx", "MiniMax-M2.5"), + ("MiniMax-M2.1 - Earlier M2 line, 204K ctx", "MiniMax-M2.1"), + ("MiniMax-M2 - Base M2, 204K ctx", "MiniMax-M2"), ("Custom model ID", "custom"), ], } diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py index 6947ad41e..5a159a12d 100644 --- a/tradingagents/llm_clients/openai_client.py +++ b/tradingagents/llm_clients/openai_client.py @@ -107,6 +107,28 @@ class DeepSeekChatOpenAI(NormalizedChatOpenAI): generation.message.additional_kwargs["reasoning_content"] = reasoning return chat_result + +class MinimaxChatOpenAI(NormalizedChatOpenAI): + """MiniMax-specific overrides on top of the OpenAI-compatible client. + + M2.x reasoning models embed ``...`` blocks directly in + ``message.content`` by default, which would pollute saved reports. + Per platform.minimax.io/docs/api-reference/text-openai-api, setting + ``reasoning_split=True`` in the request body redirects the thinking + block into ``reasoning_details`` so ``content`` stays clean. + + Tool-choice handling for M2.x — those models accept only the string + enum ``{"none", "auto"}`` and reject langchain's function-spec dict — + is handled by the capability dispatch in + ``NormalizedChatOpenAI.with_structured_output``, not here. + """ + + def _get_request_payload(self, input_, *, stop=None, **kwargs): + payload = super()._get_request_payload(input_, stop=stop, **kwargs) + payload.setdefault("reasoning_split", True) + return payload + + # Kwargs forwarded from user config to ChatOpenAI _PASSTHROUGH_KWARGS = ( "timeout", "max_retries", "reasoning_effort", @@ -183,9 +205,14 @@ class OpenAIClient(BaseLLMClient): if self.provider == "openai": llm_kwargs["use_responses_api"] = True - # DeepSeek's thinking-mode quirks live in their own subclass so the - # base NormalizedChatOpenAI stays free of provider-specific branches. - chat_cls = DeepSeekChatOpenAI if self.provider == "deepseek" else NormalizedChatOpenAI + # Provider-specific quirks live in their own subclasses so the + # base NormalizedChatOpenAI stays free of provider branches. + if self.provider == "deepseek": + chat_cls = DeepSeekChatOpenAI + elif self.provider in ("minimax", "minimax-cn"): + chat_cls = MinimaxChatOpenAI + else: + chat_cls = NormalizedChatOpenAI return chat_cls(**llm_kwargs) def validate_model(self) -> bool: From 78fe77f4e659d7458d2e6785421d4c04636a0412 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 11 May 2026 02:49:57 +0000 Subject: [PATCH 10/21] feat(llm): bump OpenAI catalog to GPT-5.5 frontier GPT-5.5 (Apr 2026, 1M ctx, $5/$30 per 1M) replaces GPT-5.4 as the catalog flagship. GPT-5.5 Pro replaces 5.4 Pro in the most-capable slot. GPT-5.4 demotes to previous-gen cost-effective option. --- tradingagents/llm_clients/model_catalog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tradingagents/llm_clients/model_catalog.py b/tradingagents/llm_clients/model_catalog.py index b72aa6b20..47f9b5e16 100644 --- a/tradingagents/llm_clients/model_catalog.py +++ b/tradingagents/llm_clients/model_catalog.py @@ -34,14 +34,14 @@ MODEL_OPTIONS: ProviderModeOptions = { "quick": [ ("GPT-5.4 Mini - Fast, strong coding and tool use", "gpt-5.4-mini"), ("GPT-5.4 Nano - Cheapest, high-volume tasks", "gpt-5.4-nano"), - ("GPT-5.4 - Latest frontier, 1M context", "gpt-5.4"), + ("GPT-5.5 - Latest frontier, 1M context", "gpt-5.5"), ("GPT-4.1 - Smartest non-reasoning model", "gpt-4.1"), ], "deep": [ - ("GPT-5.4 - Latest frontier, 1M context", "gpt-5.4"), + ("GPT-5.5 - Latest frontier, 1M context", "gpt-5.5"), + ("GPT-5.4 - Previous-gen frontier, 1M context, cost-effective", "gpt-5.4"), ("GPT-5.2 - Strong reasoning, cost-effective", "gpt-5.2"), - ("GPT-5.4 Mini - Fast, strong coding and tool use", "gpt-5.4-mini"), - ("GPT-5.4 Pro - Most capable, expensive ($30/$180 per 1M tokens)", "gpt-5.4-pro"), + ("GPT-5.5 Pro - Most capable, expensive ($30/$180 per 1M tokens)", "gpt-5.5-pro"), ], }, "anthropic": { From 9e00c8117f3d20616c60e050a68aff85e72fb480 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 11 May 2026 02:56:59 +0000 Subject: [PATCH 11/21] feat(llm): bump Anthropic catalog to Claude Opus 4.7 frontier Opus 4.7 is the current frontier per platform.claude.com (frontier category, listed first). Demote Opus 4.6 to second deep-tier slot. Polish quick-tier labels to match official wording; effort docstring includes 4.7. --- cli/utils.py | 4 +++- tradingagents/llm_clients/model_catalog.py | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cli/utils.py b/cli/utils.py index bd2d488fa..1fda29203 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -291,7 +291,9 @@ def ask_openai_reasoning_effort() -> str: def ask_anthropic_effort() -> str | None: """Ask for Anthropic effort level. - Controls token usage and response thoroughness on Claude 4.5+ and 4.6 models. + Controls token usage and response thoroughness on Claude 4.5 / 4.6 / 4.7 + models. The API also accepts "max"; we expose low/medium/high as the + common selection range. """ return questionary.select( "Select Effort Level:", diff --git a/tradingagents/llm_clients/model_catalog.py b/tradingagents/llm_clients/model_catalog.py index 47f9b5e16..b94758f15 100644 --- a/tradingagents/llm_clients/model_catalog.py +++ b/tradingagents/llm_clients/model_catalog.py @@ -47,14 +47,14 @@ MODEL_OPTIONS: ProviderModeOptions = { "anthropic": { "quick": [ ("Claude Sonnet 4.6 - Best speed and intelligence balance", "claude-sonnet-4-6"), - ("Claude Haiku 4.5 - Fast, near-instant responses", "claude-haiku-4-5"), - ("Claude Sonnet 4.5 - Agents and coding", "claude-sonnet-4-5"), + ("Claude Haiku 4.5 - Fastest with near-frontier intelligence", "claude-haiku-4-5"), + ("Claude Sonnet 4.5 - High-performance for agents and coding", "claude-sonnet-4-5"), ], "deep": [ - ("Claude Opus 4.6 - Most intelligent, agents and coding", "claude-opus-4-6"), + ("Claude Opus 4.7 - Latest frontier, long-running agents and coding", "claude-opus-4-7"), + ("Claude Opus 4.6 - Frontier intelligence, agents and coding", "claude-opus-4-6"), ("Claude Opus 4.5 - Premium, max intelligence", "claude-opus-4-5"), ("Claude Sonnet 4.6 - Best speed and intelligence balance", "claude-sonnet-4-6"), - ("Claude Sonnet 4.5 - Agents and coding", "claude-sonnet-4-5"), ], }, "google": { From 4f057e290cd31b5f1e182ce7a56893abc4202c76 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 11 May 2026 03:32:00 +0000 Subject: [PATCH 12/21] feat(llm): swap Gemini 3.1 Flash-Lite to GA stable gemini-3.1-flash-lite is now GA per ai.google.dev. Use the stable version (fewer rate limits, stronger compat guarantees) instead of the -preview suffix. Labels mark preview vs GA explicitly. --- tradingagents/llm_clients/model_catalog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tradingagents/llm_clients/model_catalog.py b/tradingagents/llm_clients/model_catalog.py index b94758f15..e7f262b33 100644 --- a/tradingagents/llm_clients/model_catalog.py +++ b/tradingagents/llm_clients/model_catalog.py @@ -59,14 +59,14 @@ MODEL_OPTIONS: ProviderModeOptions = { }, "google": { "quick": [ - ("Gemini 3 Flash - Next-gen fast", "gemini-3-flash-preview"), + ("Gemini 3 Flash - Next-gen fast (preview)", "gemini-3-flash-preview"), ("Gemini 2.5 Flash - Balanced, stable", "gemini-2.5-flash"), - ("Gemini 3.1 Flash Lite - Most cost-efficient", "gemini-3.1-flash-lite-preview"), + ("Gemini 3.1 Flash Lite - Most cost-efficient (GA)", "gemini-3.1-flash-lite"), ("Gemini 2.5 Flash Lite - Fast, low-cost", "gemini-2.5-flash-lite"), ], "deep": [ - ("Gemini 3.1 Pro - Reasoning-first, complex workflows", "gemini-3.1-pro-preview"), - ("Gemini 3 Flash - Next-gen fast", "gemini-3-flash-preview"), + ("Gemini 3.1 Pro - Reasoning-first, complex workflows (preview)", "gemini-3.1-pro-preview"), + ("Gemini 3 Flash - Next-gen fast (preview)", "gemini-3-flash-preview"), ("Gemini 2.5 Pro - Stable pro model", "gemini-2.5-pro"), ("Gemini 2.5 Flash - Balanced, stable", "gemini-2.5-flash"), ], From 0011b5ebf54d6709cc9e6f1ab129684b6166653b Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 11 May 2026 03:45:43 +0000 Subject: [PATCH 13/21] =?UTF-8?q?feat(llm):=20align=20xAI=20catalog=20with?= =?UTF-8?q?=20docs=20=E2=80=94=20adopt=20grok-4.20=20frontier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xAI's official docs lead with grok-4.20-reasoning and grok-4.20-non-reasoning across all SDK examples. Replace the prior grok-4-1-fast-* entries (hyphens where docs use dots, no literal code example) with the verified grok-4.20 family. Keep grok-4-0709 and grok-4-fast variants that are still referenced. --- tradingagents/llm_clients/model_catalog.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tradingagents/llm_clients/model_catalog.py b/tradingagents/llm_clients/model_catalog.py index e7f262b33..aeb04b1f3 100644 --- a/tradingagents/llm_clients/model_catalog.py +++ b/tradingagents/llm_clients/model_catalog.py @@ -73,15 +73,15 @@ MODEL_OPTIONS: ProviderModeOptions = { }, "xai": { "quick": [ - ("Grok 4.1 Fast (Non-Reasoning) - Speed optimized, 2M ctx", "grok-4-1-fast-non-reasoning"), + ("Grok 4.20 (Non-Reasoning) - Latest, speed-optimized", "grok-4.20-non-reasoning"), ("Grok 4 Fast (Non-Reasoning) - Speed optimized", "grok-4-fast-non-reasoning"), - ("Grok 4.1 Fast (Reasoning) - High-performance, 2M ctx", "grok-4-1-fast-reasoning"), + ("Grok 4 Fast (Reasoning) - High-performance", "grok-4-fast-reasoning"), ], "deep": [ - ("Grok 4 - Flagship model", "grok-4-0709"), - ("Grok 4.1 Fast (Reasoning) - High-performance, 2M ctx", "grok-4-1-fast-reasoning"), + ("Grok 4.20 (Reasoning) - Latest frontier reasoning model", "grok-4.20-reasoning"), + ("Grok 4 - Flagship (dated build)", "grok-4-0709"), ("Grok 4 Fast (Reasoning) - High-performance", "grok-4-fast-reasoning"), - ("Grok 4.1 Fast (Non-Reasoning) - Speed optimized, 2M ctx", "grok-4-1-fast-non-reasoning"), + ("Grok 4.20 - Auto-select reasoning behavior", "grok-4.20"), ], }, "deepseek": { From faaeebac70bce1aed48ee53a164b4305460a27a4 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 11 May 2026 04:16:11 +0000 Subject: [PATCH 14/21] feat(cli): collapse regional duplicates; refresh Qwen catalog Qwen and MiniMax each had two main-dropdown entries (intl + CN); consolidate to one entry per provider and prompt for region as a secondary step. Internal provider keys (qwen-cn, minimax-cn) and endpoint routing unchanged. Add qwen3.6-flash to the Qwen catalog and drop the version-less aliases (qwen-flash, qwen-plus) that auto-shift their backing model per Alibaba's docs. #758 --- cli/main.py | 8 +++ cli/utils.py | 57 +++++++++++++++++++++- tradingagents/llm_clients/model_catalog.py | 42 +++++++++++----- 3 files changed, 92 insertions(+), 15 deletions(-) diff --git a/cli/main.py b/cli/main.py index ecfc63d59..478b82fde 100644 --- a/cli/main.py +++ b/cli/main.py @@ -559,6 +559,14 @@ def get_user_selections(): ) selected_llm_provider, backend_url = select_llm_provider() + # Providers with regional endpoints prompt for the region as a secondary + # step so the main dropdown stays clean (mainland China and international + # accounts cannot share API keys). + if selected_llm_provider == "qwen": + selected_llm_provider, backend_url = ask_qwen_region() + elif selected_llm_provider == "minimax": + selected_llm_provider, backend_url = ask_minimax_region() + # Step 7: Thinking agents console.print( create_question_box( diff --git a/cli/utils.py b/cli/utils.py index 1fda29203..1245eea78 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -237,10 +237,9 @@ def select_llm_provider() -> tuple[str, str | None]: ("Anthropic", "anthropic", "https://api.anthropic.com/"), ("xAI", "xai", "https://api.x.ai/v1"), ("DeepSeek", "deepseek", "https://api.deepseek.com"), - ("Qwen", "qwen", "https://dashscope.aliyuncs.com/compatible-mode/v1"), + ("Qwen", "qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"), ("GLM", "glm", "https://open.bigmodel.cn/api/paas/v4/"), ("MiniMax", "minimax", "https://api.minimax.io/v1"), - ("MiniMax CN", "minimax-cn", "https://api.minimaxi.com/v1"), ("OpenRouter", "openrouter", "https://openrouter.ai/api/v1"), ("Azure OpenAI", "azure", None), ("Ollama", "ollama", "http://localhost:11434/v1"), @@ -330,6 +329,60 @@ def ask_gemini_thinking_config() -> str | None: ).ask() +def ask_qwen_region() -> tuple[str, str]: + """Ask which Qwen region (international vs China) to use. + + Alibaba DashScope exposes two endpoints with separate accounts — + a key from one region does NOT authenticate against the other + (fixes #758). Returns (provider_key, backend_url). + """ + return questionary.select( + "Select Qwen region:", + choices=[ + questionary.Choice( + "International — dashscope-intl.aliyuncs.com (uses DASHSCOPE_API_KEY)", + value=("qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"), + ), + questionary.Choice( + "China — dashscope.aliyuncs.com (uses DASHSCOPE_CN_API_KEY)", + value=("qwen-cn", "https://dashscope.aliyuncs.com/compatible-mode/v1"), + ), + ], + style=questionary.Style([ + ("selected", "fg:cyan noinherit"), + ("highlighted", "fg:cyan noinherit"), + ("pointer", "fg:cyan noinherit"), + ]), + ).ask() + + +def ask_minimax_region() -> tuple[str, str]: + """Ask which MiniMax region (global vs China) to use. + + MiniMax exposes two endpoints with separate accounts — a key from + one region does NOT authenticate against the other. Returns + (provider_key, backend_url). + """ + return questionary.select( + "Select MiniMax region:", + choices=[ + questionary.Choice( + "Global — api.minimax.io (uses MINIMAX_API_KEY)", + value=("minimax", "https://api.minimax.io/v1"), + ), + questionary.Choice( + "China — api.minimaxi.com (uses MINIMAX_CN_API_KEY)", + value=("minimax-cn", "https://api.minimaxi.com/v1"), + ), + ], + style=questionary.Style([ + ("selected", "fg:cyan noinherit"), + ("highlighted", "fg:cyan noinherit"), + ("pointer", "fg:cyan noinherit"), + ]), + ).ask() + + def ask_output_language() -> str: """Ask for report output language.""" choice = questionary.select( diff --git a/tradingagents/llm_clients/model_catalog.py b/tradingagents/llm_clients/model_catalog.py index aeb04b1f3..6a9ca999e 100644 --- a/tradingagents/llm_clients/model_catalog.py +++ b/tradingagents/llm_clients/model_catalog.py @@ -8,6 +8,31 @@ ModelOption = Tuple[str, str] ProviderModeOptions = Dict[str, Dict[str, List[ModelOption]]] +# Shared model list for Qwen's global (dashscope-intl) and CN (dashscope) endpoints. +# Source: modelstudio.console.alibabacloud.com (Featured Models — Flagship + Cost-optimized). +# +# Only versioned IDs are exposed in the dropdown. The version-less aliases +# (qwen-plus, qwen-flash) are documented by Alibaba as auto-upgrading +# pointers ("backbone, latest, and snapshot ... have been upgraded to the +# Qwen3 series"), which means their behavior shifts when Alibaba rotates +# the backing model. Users who want a specific generation pick it +# explicitly; users who really want auto-latest can enter the alias via +# "Custom model ID". +_QWEN_MODELS: Dict[str, List[ModelOption]] = { + "quick": [ + ("Qwen 3.6 Flash - Latest fast, agentic coding + vision-language", "qwen3.6-flash"), + ("Qwen 3.5 Flash - Previous-gen fast", "qwen3.5-flash"), + ("Custom model ID", "custom"), + ], + "deep": [ + ("Qwen 3.6 Plus - Flagship vision-language, agentic coding SOTA", "qwen3.6-plus"), + ("Qwen 3.5 Plus - Previous-gen flagship", "qwen3.5-plus"), + ("Qwen 3 Max - Specialized for agent programming + tool use", "qwen3-max"), + ("Custom model ID", "custom"), + ], +} + + # Shared model list for MiniMax's global and CN endpoints (same IDs). # Full official lineup per platform.minimax.io/docs/api-reference/text-openai-api. # All M2.x models share a 204,800-token context window. @@ -97,19 +122,10 @@ MODEL_OPTIONS: ProviderModeOptions = { ("Custom model ID", "custom"), ], }, - "qwen": { - "quick": [ - ("Qwen 3.5 Flash", "qwen3.5-flash"), - ("Qwen Plus", "qwen-plus"), - ("Custom model ID", "custom"), - ], - "deep": [ - ("Qwen 3.6 Plus", "qwen3.6-plus"), - ("Qwen 3.5 Plus", "qwen3.5-plus"), - ("Qwen 3 Max", "qwen3-max"), - ("Custom model ID", "custom"), - ], - }, + # Qwen: same model IDs across global (dashscope-intl) and China + # (dashscope) endpoints, so the two provider keys share one model list. + "qwen": _QWEN_MODELS, + "qwen-cn": _QWEN_MODELS, "glm": { "quick": [ ("GLM-4.7", "glm-4.7"), From d0dd0420ad20d1fc32759c738855b7d591ee8f69 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 11 May 2026 04:19:50 +0000 Subject: [PATCH 15/21] feat(llm): GLM dual-region split + catalog refresh Zhipu serves GLM under two brands with separate accounts (Z.AI international vs BigModel China); the CLI URL pointed at one while the openai_client default pointed at the other. Split into glm + glm-cn with secondary region prompt (same UX as Qwen + MiniMax). Catalog adds glm-5-turbo and glm-4.5-air per docs.z.ai. --- .env.example | 2 ++ README.md | 10 ++++--- cli/main.py | 2 ++ cli/utils.py | 26 ++++++++++++++++ tests/conftest.py | 2 ++ tradingagents/llm_clients/factory.py | 4 ++- tradingagents/llm_clients/model_catalog.py | 35 ++++++++++++++-------- tradingagents/llm_clients/openai_client.py | 8 +++++ 8 files changed, 72 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index af92fcf93..458d74956 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,9 @@ ANTHROPIC_API_KEY= XAI_API_KEY= DEEPSEEK_API_KEY= DASHSCOPE_API_KEY= +DASHSCOPE_CN_API_KEY= ZHIPU_API_KEY= +ZHIPU_CN_API_KEY= MINIMAX_API_KEY= MINIMAX_CN_API_KEY= OPENROUTER_API_KEY= diff --git a/README.md b/README.md index a897263f2..b4578a8c9 100644 --- a/README.md +++ b/README.md @@ -142,8 +142,10 @@ export GOOGLE_API_KEY=... # Google (Gemini) export ANTHROPIC_API_KEY=... # Anthropic (Claude) export XAI_API_KEY=... # xAI (Grok) export DEEPSEEK_API_KEY=... # DeepSeek -export DASHSCOPE_API_KEY=... # Qwen (Alibaba DashScope) -export ZHIPU_API_KEY=... # GLM (Zhipu) +export DASHSCOPE_API_KEY=... # Qwen — International (dashscope-intl.aliyuncs.com) +export DASHSCOPE_CN_API_KEY=... # Qwen — China (dashscope.aliyuncs.com) +export ZHIPU_API_KEY=... # GLM via Z.AI (international) +export ZHIPU_CN_API_KEY=... # GLM via BigModel (China, open.bigmodel.cn) export MINIMAX_API_KEY=... # MiniMax — Global (api.minimax.io, M2.x, 204K ctx) export MINIMAX_CN_API_KEY=... # MiniMax — China (api.minimaxi.com, M2.x, 204K ctx) export OPENROUTER_API_KEY=... # OpenRouter @@ -186,7 +188,7 @@ An interface will appear showing results as they load, letting you track the age ### Implementation Details -We built TradingAgents with LangGraph to ensure flexibility and modularity. The framework supports multiple LLM providers: OpenAI, Google, Anthropic, xAI, DeepSeek, Qwen (Alibaba DashScope), GLM (Zhipu), MiniMax (global + China), OpenRouter, Ollama for local models, and Azure OpenAI for enterprise. +We built TradingAgents with LangGraph to ensure flexibility and modularity. The framework supports multiple LLM providers: OpenAI, Google, Anthropic, xAI, DeepSeek, Qwen (Alibaba DashScope, international and China endpoints), GLM (Zhipu), MiniMax (global + China), OpenRouter, Ollama for local models, and Azure OpenAI for enterprise. ### Python Usage @@ -210,7 +212,7 @@ from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.default_config import DEFAULT_CONFIG config = DEFAULT_CONFIG.copy() -config["llm_provider"] = "openai" # openai, google, anthropic, xai, deepseek, qwen, glm, minimax, minimax-cn, openrouter, ollama, azure +config["llm_provider"] = "openai" # openai, google, anthropic, xai, deepseek, qwen, qwen-cn, glm, glm-cn, minimax, minimax-cn, openrouter, ollama, azure config["deep_think_llm"] = "gpt-5.4" # Model for complex reasoning config["quick_think_llm"] = "gpt-5.4-mini" # Model for quick tasks config["max_debate_rounds"] = 2 diff --git a/cli/main.py b/cli/main.py index 478b82fde..c466cb219 100644 --- a/cli/main.py +++ b/cli/main.py @@ -566,6 +566,8 @@ def get_user_selections(): selected_llm_provider, backend_url = ask_qwen_region() elif selected_llm_provider == "minimax": selected_llm_provider, backend_url = ask_minimax_region() + elif selected_llm_provider == "glm": + selected_llm_provider, backend_url = ask_glm_region() # Step 7: Thinking agents console.print( diff --git a/cli/utils.py b/cli/utils.py index 1245eea78..1ccc12302 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -329,6 +329,32 @@ def ask_gemini_thinking_config() -> str | None: ).ask() +def ask_glm_region() -> tuple[str, str]: + """Ask which GLM platform (Z.AI international vs BigModel China) to use. + + Zhipu serves the same GLM models under two brands with separate + accounts; keys aren't interchangeable. Returns (provider_key, backend_url). + """ + return questionary.select( + "Select GLM platform:", + choices=[ + questionary.Choice( + "Z.AI — api.z.ai (international, uses ZHIPU_API_KEY)", + value=("glm", "https://api.z.ai/api/paas/v4/"), + ), + questionary.Choice( + "BigModel — open.bigmodel.cn (China, uses ZHIPU_CN_API_KEY)", + value=("glm-cn", "https://open.bigmodel.cn/api/paas/v4/"), + ), + ], + style=questionary.Style([ + ("selected", "fg:cyan noinherit"), + ("highlighted", "fg:cyan noinherit"), + ("pointer", "fg:cyan noinherit"), + ]), + ).ask() + + def ask_qwen_region() -> tuple[str, str]: """Ask which Qwen region (international vs China) to use. diff --git a/tests/conftest.py b/tests/conftest.py index 5983446f5..506510cea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,7 +18,9 @@ _API_KEY_ENV_VARS = ( "XAI_API_KEY", "DEEPSEEK_API_KEY", "DASHSCOPE_API_KEY", + "DASHSCOPE_CN_API_KEY", "ZHIPU_API_KEY", + "ZHIPU_CN_API_KEY", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "OPENROUTER_API_KEY", diff --git a/tradingagents/llm_clients/factory.py b/tradingagents/llm_clients/factory.py index 32c3bed31..9bf1d9fba 100644 --- a/tradingagents/llm_clients/factory.py +++ b/tradingagents/llm_clients/factory.py @@ -4,7 +4,9 @@ from .base_client import BaseLLMClient # Providers that use the OpenAI-compatible chat completions API _OPENAI_COMPATIBLE = ( - "openai", "xai", "deepseek", "qwen", "glm", + "openai", "xai", "deepseek", + "qwen", "qwen-cn", + "glm", "glm-cn", "minimax", "minimax-cn", "ollama", "openrouter", ) diff --git a/tradingagents/llm_clients/model_catalog.py b/tradingagents/llm_clients/model_catalog.py index 6a9ca999e..fac741bcf 100644 --- a/tradingagents/llm_clients/model_catalog.py +++ b/tradingagents/llm_clients/model_catalog.py @@ -8,6 +8,25 @@ ModelOption = Tuple[str, str] ProviderModeOptions = Dict[str, Dict[str, List[ModelOption]]] +# Shared model list for GLM via Z.AI (international) and BigModel (China). +# Source: docs.z.ai (GLM Coding Plan supported models + LLM guides). +# All GLM 4.7+ entries support thinking mode via thinking={"type":"enabled"}. +_GLM_MODELS: Dict[str, List[ModelOption]] = { + "quick": [ + ("GLM-5-Turbo - Fast, switchable thinking modes", "glm-5-turbo"), + ("GLM-4.7 - Previous-gen flagship", "glm-4.7"), + ("GLM-4.5-Air - Lightweight, cost-efficient", "glm-4.5-air"), + ("Custom model ID", "custom"), + ], + "deep": [ + ("GLM-5.1 - Latest flagship, 204K ctx", "glm-5.1"), + ("GLM-5 - Flagship, 204K ctx", "glm-5"), + ("GLM-4.7 - Previous-gen flagship", "glm-4.7"), + ("Custom model ID", "custom"), + ], +} + + # Shared model list for Qwen's global (dashscope-intl) and CN (dashscope) endpoints. # Source: modelstudio.console.alibabacloud.com (Featured Models — Flagship + Cost-optimized). # @@ -126,18 +145,10 @@ MODEL_OPTIONS: ProviderModeOptions = { # (dashscope) endpoints, so the two provider keys share one model list. "qwen": _QWEN_MODELS, "qwen-cn": _QWEN_MODELS, - "glm": { - "quick": [ - ("GLM-4.7", "glm-4.7"), - ("GLM-5", "glm-5"), - ("Custom model ID", "custom"), - ], - "deep": [ - ("GLM-5.1", "glm-5.1"), - ("GLM-5", "glm-5"), - ("Custom model ID", "custom"), - ], - }, + # GLM: Z.AI (international) and BigModel (China) host the same model + # IDs; the two provider keys share one model list. + "glm": _GLM_MODELS, + "glm-cn": _GLM_MODELS, # MiniMax: same model IDs across global (.io) and China (.com) regions, # so the two provider keys share one model list. "minimax": _MINIMAX_MODELS, diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py index 5a159a12d..89c67e31d 100644 --- a/tradingagents/llm_clients/openai_client.py +++ b/tradingagents/llm_clients/openai_client.py @@ -139,8 +139,16 @@ _PASSTHROUGH_KWARGS = ( _PROVIDER_CONFIG = { "xai": ("https://api.x.ai/v1", "XAI_API_KEY"), "deepseek": ("https://api.deepseek.com", "DEEPSEEK_API_KEY"), + # DashScope exposes two regional endpoints with separate accounts; an + # international key won't authenticate against the China endpoint and + # vice versa (fixes issue #758). "qwen": ("https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "DASHSCOPE_API_KEY"), + "qwen-cn": ("https://dashscope.aliyuncs.com/compatible-mode/v1", "DASHSCOPE_CN_API_KEY"), + # Zhipu exposes the same GLM models under two brands with separate + # accounts: Z.AI (international, api.z.ai) and BigModel + # (open.bigmodel.cn, China). Keys aren't interchangeable across them. "glm": ("https://api.z.ai/api/paas/v4/", "ZHIPU_API_KEY"), + "glm-cn": ("https://open.bigmodel.cn/api/paas/v4/", "ZHIPU_CN_API_KEY"), # MiniMax exposes two regional endpoints with separate keys; mainland # Chinese users hit .com while global users hit .io. "minimax": ("https://api.minimax.io/v1", "MINIMAX_API_KEY"), From 0fcf13624e8ab20b1cb3e10ebfd51dccf0b0213a Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 11 May 2026 05:20:07 +0000 Subject: [PATCH 16/21] feat(agents): rename to sentiment_analyst; integrate StockTwits + Reddit Pre-fetches news + StockTwits + Reddit via no-auth public endpoints and injects structured data blocks into the prompt with professional analysis instructions. Replaces the prompt-vs-tool mismatch that caused fabricated social-platform content. Backward-compat alias + "social" CLI key preserved. #557 #607 --- tradingagents/agents/__init__.py | 8 +- .../agents/analysts/sentiment_analyst.py | 184 ++++++++++++++++++ .../agents/analysts/social_media_analyst.py | 68 ++----- tradingagents/dataflows/reddit.py | 106 ++++++++++ tradingagents/dataflows/stocktwits.py | 83 ++++++++ tradingagents/graph/setup.py | 6 +- 6 files changed, 401 insertions(+), 54 deletions(-) create mode 100644 tradingagents/agents/analysts/sentiment_analyst.py create mode 100644 tradingagents/dataflows/reddit.py create mode 100644 tradingagents/dataflows/stocktwits.py diff --git a/tradingagents/agents/__init__.py b/tradingagents/agents/__init__.py index 2fb4e1bac..f88261408 100644 --- a/tradingagents/agents/__init__.py +++ b/tradingagents/agents/__init__.py @@ -4,7 +4,10 @@ from .utils.agent_states import AgentState, InvestDebateState, RiskDebateState from .analysts.fundamentals_analyst import create_fundamentals_analyst from .analysts.market_analyst import create_market_analyst from .analysts.news_analyst import create_news_analyst -from .analysts.social_media_analyst import create_social_media_analyst +from .analysts.sentiment_analyst import ( + create_sentiment_analyst, + create_social_media_analyst, # deprecated alias kept for back-compat +) from .researchers.bear_researcher import create_bear_researcher from .researchers.bull_researcher import create_bull_researcher @@ -33,6 +36,7 @@ __all__ = [ "create_aggressive_debator", "create_portfolio_manager", "create_conservative_debator", - "create_social_media_analyst", + "create_sentiment_analyst", + "create_social_media_analyst", # deprecated; will be removed in a future version "create_trader", ] diff --git a/tradingagents/agents/analysts/sentiment_analyst.py b/tradingagents/agents/analysts/sentiment_analyst.py new file mode 100644 index 000000000..e1e4ee4f4 --- /dev/null +++ b/tradingagents/agents/analysts/sentiment_analyst.py @@ -0,0 +1,184 @@ +"""Sentiment analyst — multi-source sentiment analysis for a target ticker. + +Previously named ``social_media_analyst``. Renamed and redesigned because +the old version had a prompt that demanded social-media analysis but the +only tool available was Yahoo Finance news — which led LLMs to fabricate +Reddit/X/StockTwits content under prompt pressure (verified live). + +The redesigned agent pre-fetches three complementary data sources before +the LLM is invoked and injects them into the prompt as structured blocks: + + 1. News headlines — Yahoo Finance (institutional framing) + 2. StockTwits messages — retail-trader posts indexed by cashtag, with + user-labeled Bullish/Bearish sentiment tags + 3. Reddit posts — r/wallstreetbets, r/stocks, r/investing + +The agent does not use tool-calling; the data is in the prompt from +turn 0. The LLM produces the sentiment report in a single invocation. + +See: https://github.com/TauricResearch/TradingAgents/issues/557 +""" + +from datetime import datetime, timedelta + +from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder +from tradingagents.agents.utils.agent_utils import ( + build_instrument_context, + get_language_instruction, + get_news, +) +from tradingagents.dataflows.reddit import fetch_reddit_posts +from tradingagents.dataflows.stocktwits import fetch_stocktwits_messages + + +def _seven_days_back(trade_date: str) -> str: + return (datetime.strptime(trade_date, "%Y-%m-%d") - timedelta(days=7)).strftime("%Y-%m-%d") + + +def create_sentiment_analyst(llm): + """Create a sentiment analyst node for the trading graph. + + Pre-fetches news + StockTwits + Reddit data, injects them into the + prompt as structured blocks, and produces a sentiment report in a + single LLM call. + """ + + def sentiment_analyst_node(state): + ticker = state["company_of_interest"] + end_date = state["trade_date"] + start_date = _seven_days_back(end_date) + instrument_context = build_instrument_context(ticker) + + # Pre-fetch all three sources. Each fetcher degrades gracefully and + # returns a string (no exceptions surface from here), so the LLM + # always sees something — either real data or a clear placeholder. + news_block = get_news.func(ticker, start_date, end_date) + stocktwits_block = fetch_stocktwits_messages(ticker, limit=30) + reddit_block = fetch_reddit_posts(ticker) + + system_message = _build_system_message( + ticker=ticker, + start_date=start_date, + end_date=end_date, + news_block=news_block, + stocktwits_block=stocktwits_block, + reddit_block=reddit_block, + ) + + prompt = ChatPromptTemplate.from_messages( + [ + ( + "system", + "You are a helpful AI assistant, collaborating with other assistants." + " If you or any other assistant has the FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** or deliverable," + " prefix your response with FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** so the team knows to stop." + "\n{system_message}\n" + "For your reference, the current date is {current_date}. {instrument_context}", + ), + MessagesPlaceholder(variable_name="messages"), + ] + ) + + prompt = prompt.partial(system_message=system_message) + prompt = prompt.partial(current_date=end_date) + prompt = prompt.partial(instrument_context=instrument_context) + + # No bind_tools — the data is already in the prompt; a single LLM + # call produces the report directly. + chain = prompt | llm + result = chain.invoke(state["messages"]) + + return { + "messages": [result], + "sentiment_report": result.content, + } + + return sentiment_analyst_node + + +def _build_system_message( + *, + ticker: str, + start_date: str, + end_date: str, + news_block: str, + stocktwits_block: str, + reddit_block: str, +) -> str: + """Assemble the sentiment-analyst system message with structured data blocks.""" + return f"""You are a financial market sentiment analyst. Your task is to produce a comprehensive sentiment report for {ticker} covering the period from {start_date} to {end_date}, drawing on three complementary data sources that have already been collected for you. + +## Data sources (pre-fetched, in this prompt) + +### News headlines — Yahoo Finance, past 7 days +Institutional framing. Fact-driven, slower-moving signal. + + +{news_block} + + +### StockTwits messages — retail-trader social platform indexed by cashtag +Fast-moving signal. Each message carries a user-labeled sentiment tag (Bullish / Bearish / no-label) plus the message body. + + +{stocktwits_block} + + +### Reddit posts — r/wallstreetbets, r/stocks, r/investing (past 7 days) +Community discussion. Engagement signal via upvote score and comment count. Subreddit character matters (r/wallstreetbets is often contrarian/exuberant; r/stocks more measured; r/investing longer-term). + + +{reddit_block} + + +## How to analyze this data (best practices) + +1. **Read the StockTwits Bullish/Bearish ratio as a leading retail-sentiment signal.** A 70/30 bullish/bearish split is moderately bullish; ≥90/10 may indicate over-extension and contrarian risk; 50/50 is uncertainty. Sample size matters — base rates on the actual message count, not percentages alone. + +2. **Look for cross-source divergences.** If news framing is bearish but StockTwits is overwhelmingly bullish, that mismatch is itself a signal — it can mean retail is leaning into a thesis the news flow hasn't caught up to (or vice versa, that retail is chasing while institutions are cautious). + +3. **Weight Reddit posts by engagement.** A 400-upvote / 200-comment thread reflects community attention; a 3-upvote post is noise. Read the body excerpts for context — the title alone often misleads. + +4. **Distinguish opinion from event.** A news headline ("Nvidia announces $500M Corning deal") is an event; a StockTwits post ("buying NVDA, this is going to moon") is opinion. Both are inputs but should be weighted differently in your conclusions. + +5. **Identify recurring narrative themes.** What topic keeps coming up across sources? That's the dominant narrative driving current sentiment. + +6. **Be honest about data limits.** If StockTwits returned only a handful of messages, or one or more sources returned an "" placeholder, the sentiment read is less robust — flag this caveat explicitly. If the sources are silent on a given subreddit, say so. + +7. **Identify catalysts and risks** that emerge across sources — news of upcoming earnings, product launches, competitive threats, macro headlines, etc. + +8. **Past sentiment is not predictive.** Frame your conclusions as signal for the trader to weigh alongside fundamentals and technicals, not as a price call. + +## Output + +Produce a sentiment report covering, in order: + +1. **Overall sentiment direction** — Bullish / Bearish / Neutral / Mixed — with a brief confidence note based on data quality and sample size. +2. **Source-by-source breakdown** — what each of news / StockTwits / Reddit is telling you, with specific evidence (cite message counts, ratios, notable posts). +3. **Divergences, alignments, and key narratives** across sources. +4. **Catalysts and risks** surfaced by the data. +5. **Markdown table** at the end summarizing key sentiment signals, their direction, source, and supporting evidence. + +{get_language_instruction()}""" + + +# --------------------------------------------------------------------------- +# Backwards-compatibility shim +# --------------------------------------------------------------------------- +def create_social_media_analyst(llm): + """Deprecated alias for :func:`create_sentiment_analyst`. + + Kept so existing code that imports ``create_social_media_analyst`` + continues to work. + + .. deprecated:: + Import :func:`create_sentiment_analyst` directly instead. + """ + import warnings + warnings.warn( + "create_social_media_analyst is deprecated and will be removed in a " + "future version. Use create_sentiment_analyst instead.", + DeprecationWarning, + stacklevel=2, + ) + return create_sentiment_analyst(llm) diff --git a/tradingagents/agents/analysts/social_media_analyst.py b/tradingagents/agents/analysts/social_media_analyst.py index 34a53c462..03cd7a44c 100644 --- a/tradingagents/agents/analysts/social_media_analyst.py +++ b/tradingagents/agents/analysts/social_media_analyst.py @@ -1,57 +1,23 @@ -from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder -from tradingagents.agents.utils.agent_utils import build_instrument_context, get_language_instruction, get_news -from tradingagents.dataflows.config import get_config +"""Backwards-compatibility shim for the renamed social_media_analyst module. +The social media analyst has been renamed to ``sentiment_analyst`` because its +only data tool is ``get_news`` (Yahoo Finance), not a social media feed. -def create_social_media_analyst(llm): - def social_media_analyst_node(state): - current_date = state["trade_date"] - instrument_context = build_instrument_context(state["company_of_interest"]) +Import from ``tradingagents.agents.analysts.sentiment_analyst`` going forward. - tools = [ - get_news, - ] +See: https://github.com/TauricResearch/TradingAgents/issues/557 +""" - system_message = ( - "You are a social media and company specific news researcher/analyst tasked with analyzing social media posts, recent company news, and public sentiment for a specific company over the past week. You will be given a company's name your objective is to write a comprehensive long report detailing your analysis, insights, and implications for traders and investors on this company's current state after looking at social media and what people are saying about that company, analyzing sentiment data of what people feel each day about the company, and looking at recent company news. Use the get_news(query, start_date, end_date) tool to search for company-specific news and social media discussions. Try to look at all sources possible from social media to sentiment to news. Provide specific, actionable insights with supporting evidence to help traders make informed decisions." - + """ Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read.""" - + get_language_instruction() - ) +import warnings as _warnings - prompt = ChatPromptTemplate.from_messages( - [ - ( - "system", - "You are a helpful AI assistant, collaborating with other assistants." - " Use the provided tools to progress towards answering the question." - " If you are unable to fully answer, that's OK; another assistant with different tools" - " will help where you left off. Execute what you can to make progress." - " If you or any other assistant has the FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** or deliverable," - " prefix your response with FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** so the team knows to stop." - " You have access to the following tools: {tool_names}.\n{system_message}" - "For your reference, the current date is {current_date}. {instrument_context}", - ), - MessagesPlaceholder(variable_name="messages"), - ] - ) +from tradingagents.agents.analysts.sentiment_analyst import ( # noqa: F401 + create_sentiment_analyst, + create_social_media_analyst, +) - prompt = prompt.partial(system_message=system_message) - prompt = prompt.partial(tool_names=", ".join([tool.name for tool in tools])) - prompt = prompt.partial(current_date=current_date) - prompt = prompt.partial(instrument_context=instrument_context) - - chain = prompt | llm.bind_tools(tools) - - result = chain.invoke(state["messages"]) - - report = "" - - if len(result.tool_calls) == 0: - report = result.content - - return { - "messages": [result], - "sentiment_report": report, - } - - return social_media_analyst_node +_warnings.warn( + "tradingagents.agents.analysts.social_media_analyst is deprecated. " + "Import from tradingagents.agents.analysts.sentiment_analyst instead.", + DeprecationWarning, + stacklevel=2, +) diff --git a/tradingagents/dataflows/reddit.py b/tradingagents/dataflows/reddit.py new file mode 100644 index 000000000..c8e01b92c --- /dev/null +++ b/tradingagents/dataflows/reddit.py @@ -0,0 +1,106 @@ +"""Reddit search fetcher for ticker-specific discussion posts. + +Uses Reddit's public JSON endpoints (``reddit.com/r/{sub}/search.json``) +which do not require an API key. Public throughput is ~10 requests per +minute per IP, well within budget for a single agent run that queries +a handful of finance subreddits per ticker. + +Returns formatted plaintext blocks ready for prompt injection. Degrades +gracefully — returns a placeholder string rather than raising, so callers +never have to special-case missing data. +""" + +from __future__ import annotations + +import json +import logging +import time +from typing import Iterable +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +logger = logging.getLogger(__name__) + +_API = "https://www.reddit.com/r/{sub}/search.json?{qs}" +_UA = "tradingagents/0.2 (+https://github.com/TauricResearch/TradingAgents)" + +# Default subreddits ordered roughly by signal density for ticker-specific +# discussion. wallstreetbets has the most volume but most noise; stocks / +# investing trend more measured. Caller can override. +DEFAULT_SUBREDDITS = ("wallstreetbets", "stocks", "investing") + + +def _fetch_subreddit( + ticker: str, + sub: str, + limit: int, + timeout: float, +) -> list[dict]: + qs = urlencode({ + "q": ticker, + "restrict_sr": "on", + "sort": "new", + "t": "week", # last 7 days + "limit": limit, + }) + url = _API.format(sub=sub, qs=qs) + req = Request(url, headers={"User-Agent": _UA, "Accept": "application/json"}) + try: + with urlopen(req, timeout=timeout) as resp: + payload = json.loads(resp.read()) + except (HTTPError, URLError, json.JSONDecodeError, TimeoutError) as exc: + logger.warning("Reddit fetch failed for r/%s · %s: %s", sub, ticker, exc) + return [] + children = (payload.get("data") or {}).get("children") or [] + return [c.get("data", {}) for c in children if isinstance(c, dict)] + + +def fetch_reddit_posts( + ticker: str, + subreddits: Iterable[str] = DEFAULT_SUBREDDITS, + limit_per_sub: int = 5, + timeout: float = 10.0, + inter_request_delay: float = 0.4, +) -> str: + """Fetch recent Reddit posts mentioning ``ticker`` across finance + subreddits and return them as a formatted plaintext block. + + ``inter_request_delay`` keeps us under Reddit's public rate limit + (~10 req/min per IP) even if the caller queries many subreddits. + """ + blocks = [] + total_posts = 0 + for i, sub in enumerate(subreddits): + if i > 0: + time.sleep(inter_request_delay) + posts = _fetch_subreddit(ticker, sub, limit_per_sub, timeout) + total_posts += len(posts) + if not posts: + blocks.append(f"r/{sub}: ") + continue + + lines = [f"r/{sub} — {len(posts)} recent posts mentioning {ticker.upper()}:"] + for p in posts: + title = (p.get("title") or "").replace("\n", " ").strip() + score = p.get("score", 0) + comments = p.get("num_comments", 0) + created = p.get("created_utc") + created_str = ( + time.strftime("%Y-%m-%d", time.gmtime(created)) if created else "?" + ) + selftext = (p.get("selftext") or "").replace("\n", " ").strip() + if len(selftext) > 240: + selftext = selftext[:240] + "…" + lines.append( + f" [{created_str} · {score:>4}↑ · {comments:>3}c] {title}" + + (f"\n body excerpt: {selftext}" if selftext else "") + ) + blocks.append("\n".join(lines)) + + if total_posts == 0: + return ( + f"" + ) + return "\n\n".join(blocks) diff --git a/tradingagents/dataflows/stocktwits.py b/tradingagents/dataflows/stocktwits.py new file mode 100644 index 000000000..a1b2992ba --- /dev/null +++ b/tradingagents/dataflows/stocktwits.py @@ -0,0 +1,83 @@ +"""StockTwits public symbol-stream fetcher. + +StockTwits exposes a per-symbol message stream at +``api.stocktwits.com/api/2/streams/symbol/{ticker}.json`` that requires no +API key, no OAuth, and no registration. Each message includes a +user-labeled sentiment field (``Bullish``/``Bearish``/null), the message +body, timestamp, and posting user. + +The function is deliberately self-contained: short timeout, graceful +degradation on any HTTP or parse failure, and a string return type so +the calling agent gets a uniform interface regardless of whether the +network call succeeded. +""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from typing import Optional +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +logger = logging.getLogger(__name__) + +_API = "https://api.stocktwits.com/api/2/streams/symbol/{ticker}.json" +_UA = "tradingagents/0.2 (+https://github.com/TauricResearch/TradingAgents)" + + +def fetch_stocktwits_messages(ticker: str, limit: int = 30, timeout: float = 10.0) -> str: + """Fetch recent StockTwits messages for ``ticker`` and return them as a + formatted plaintext block ready for prompt injection. + + Returns a placeholder string when the endpoint is unreachable, the + symbol has no messages, or the response shape is unexpected — the + caller never has to special-case None or exceptions. + """ + url = _API.format(ticker=ticker.upper()) + req = Request(url, headers={"User-Agent": _UA, "Accept": "application/json"}) + try: + with urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read()) + except (HTTPError, URLError, json.JSONDecodeError, TimeoutError) as exc: + logger.warning("StockTwits fetch failed for %s: %s", ticker, exc) + return f"" + + messages = data.get("messages", []) if isinstance(data, dict) else [] + if not messages: + return f"" + + lines = [] + bullish = bearish = unlabeled = 0 + for m in messages[:limit]: + created = m.get("created_at", "") + user = (m.get("user") or {}).get("username", "?") + entities = m.get("entities") or {} + sentiment_obj = entities.get("sentiment") or {} + sentiment = sentiment_obj.get("basic") if isinstance(sentiment_obj, dict) else None + body = (m.get("body") or "").replace("\n", " ").strip() + if len(body) > 280: + body = body[:280] + "…" + + if sentiment == "Bullish": + bullish += 1 + tag = "Bullish" + elif sentiment == "Bearish": + bearish += 1 + tag = "Bearish" + else: + unlabeled += 1 + tag = "no-label" + lines.append(f"[{created} · @{user} · {tag}] {body}") + + total = bullish + bearish + unlabeled + bull_pct = round(100 * bullish / total) if total else 0 + bear_pct = round(100 * bearish / total) if total else 0 + summary = ( + f"Bullish: {bullish} ({bull_pct}%) · " + f"Bearish: {bearish} ({bear_pct}%) · " + f"Unlabeled: {unlabeled} · " + f"Total: {total} most-recent messages" + ) + return summary + "\n\n" + "\n".join(lines) diff --git a/tradingagents/graph/setup.py b/tradingagents/graph/setup.py index 45d6bfd38..3d328c89f 100644 --- a/tradingagents/graph/setup.py +++ b/tradingagents/graph/setup.py @@ -54,7 +54,11 @@ class GraphSetup: tool_nodes["market"] = self.tool_nodes["market"] if "social" in selected_analysts: - analyst_nodes["social"] = create_social_media_analyst( + # "social" selector key preserved for back-compat with existing + # user configs; the underlying agent has been renamed to + # sentiment_analyst (the old name advertised social-media data + # the agent never had access to — see issue #557). + analyst_nodes["social"] = create_sentiment_analyst( self.quick_thinking_llm ) delete_nodes["social"] = create_msg_delete() From 384fe1a3d2b507ca5e970bab1540bdd1346c384e Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 11 May 2026 05:30:52 +0000 Subject: [PATCH 17/21] feat(news): configurable fetch params via DEFAULT_CONFIG Per-ticker article limit, global article limit, global lookback window, and macro query list are now read from get_config() instead of being hardcoded. Tool wrapper get_global_news passes None defaults so config overrides flow through the LLM-tool path too. Macro query defaults broadened from 4 US-centric strings to 5 covering Fed, S&P 500, geopolitics, ECB/BOJ/BOE, commodities. #606 #558 #562 --- tradingagents/agents/utils/news_data_tools.py | 16 ++++++---- tradingagents/dataflows/yfinance_news.py | 29 +++++++++++-------- tradingagents/default_config.py | 15 ++++++++++ 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/tradingagents/agents/utils/news_data_tools.py b/tradingagents/agents/utils/news_data_tools.py index 781e793c3..f503c4d3a 100644 --- a/tradingagents/agents/utils/news_data_tools.py +++ b/tradingagents/agents/utils/news_data_tools.py @@ -1,5 +1,5 @@ from langchain_core.tools import tool -from typing import Annotated +from typing import Annotated, Optional from tradingagents.dataflows.interface import route_to_vendor @tool @@ -23,16 +23,20 @@ def get_news( @tool def get_global_news( curr_date: Annotated[str, "Current date in yyyy-mm-dd format"], - look_back_days: Annotated[int, "Number of days to look back"] = 7, - limit: Annotated[int, "Maximum number of articles to return"] = 5, + look_back_days: Annotated[Optional[int], "Days to look back; omit to use the configured default"] = None, + limit: Annotated[Optional[int], "Max articles to return; omit to use the configured default"] = None, ) -> str: """ Retrieve global news data. - Uses the configured news_data vendor. + Uses the configured news_data vendor. Defaults for look_back_days and + limit come from DEFAULT_CONFIG (global_news_lookback_days, + global_news_article_limit); pass explicit values to override. + Args: curr_date (str): Current date in yyyy-mm-dd format - look_back_days (int): Number of days to look back (default 7) - limit (int): Maximum number of articles to return (default 5) + look_back_days (int): Number of days to look back; omit to inherit config + limit (int): Maximum number of articles to return; omit to inherit config + Returns: str: A formatted string containing global news data """ diff --git a/tradingagents/dataflows/yfinance_news.py b/tradingagents/dataflows/yfinance_news.py index dd1046f54..55c5d2512 100644 --- a/tradingagents/dataflows/yfinance_news.py +++ b/tradingagents/dataflows/yfinance_news.py @@ -1,9 +1,12 @@ """yfinance-based news data fetching functions.""" +from typing import Optional + import yfinance as yf from datetime import datetime from dateutil.relativedelta import relativedelta +from .config import get_config from .stockstats_utils import yf_retry @@ -64,9 +67,10 @@ def get_news_yfinance( Returns: Formatted string containing news articles """ + article_limit = get_config()["news_article_limit"] try: stock = yf.Ticker(ticker) - news = yf_retry(lambda: stock.get_news(count=20)) + news = yf_retry(lambda: stock.get_news(count=article_limit)) if not news: return f"No news found for {ticker}" @@ -106,27 +110,28 @@ def get_news_yfinance( def get_global_news_yfinance( curr_date: str, - look_back_days: int = 7, - limit: int = 10, + look_back_days: Optional[int] = None, + limit: Optional[int] = None, ) -> str: """ Retrieve global/macro economic news using yfinance Search. Args: curr_date: Current date in yyyy-mm-dd format - look_back_days: Number of days to look back - limit: Maximum number of articles to return + look_back_days: Number of days to look back. ``None`` falls back to + ``global_news_lookback_days`` from the active config. + limit: Maximum number of articles to return. ``None`` falls back to + ``global_news_article_limit`` from the active config. Returns: Formatted string containing global news articles """ - # Search queries for macro/global news - search_queries = [ - "stock market economy", - "Federal Reserve interest rates", - "inflation economic outlook", - "global markets trading", - ] + config = get_config() + if look_back_days is None: + look_back_days = config["global_news_lookback_days"] + if limit is None: + limit = config["global_news_article_limit"] + search_queries = config["global_news_queries"] all_news = [] seen_titles = set() diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index fa6d5742c..faa71f591 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -35,6 +35,21 @@ DEFAULT_CONFIG = { "max_debate_rounds": 1, "max_risk_discuss_rounds": 1, "max_recur_limit": 100, + # News / data fetching parameters + # Increase for longer lookback strategies or to broaden macro coverage; + # decrease to reduce token usage in agent prompts. + "news_article_limit": 20, # max articles per ticker (ticker-news) + "global_news_article_limit": 10, # max articles for global/macro news + "global_news_lookback_days": 7, # macro news lookback window + # Search queries used by get_global_news for macro headlines. Extend or + # replace to broaden geographic / sector coverage. + "global_news_queries": [ + "Federal Reserve interest rates inflation", + "S&P 500 earnings GDP economic outlook", + "geopolitical risk trade war sanctions", + "ECB Bank of England BOJ central bank policy", + "oil commodities supply chain energy", + ], # Data vendor configuration # Category-level configuration (default for all tools in category) "data_vendors": { From 6b384f74f94c2b2d16e6114a709f1b5bb2d4fcd5 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 11 May 2026 05:41:42 +0000 Subject: [PATCH 18/21] feat(i18n): localize researchers, risk debators, research mgr, trader output_language config now propagates to every user-facing agent. Previously only the four analysts and portfolio manager respected the setting, producing partial-localization reports with English debate text interleaved with non-English analyst sections. Verified live: 7 agents produce Chinese output when config is set to Chinese. #575 --- tradingagents/agents/managers/research_manager.py | 7 +++++-- tradingagents/agents/researchers/bear_researcher.py | 3 ++- tradingagents/agents/researchers/bull_researcher.py | 3 ++- tradingagents/agents/risk_mgmt/aggressive_debator.py | 3 ++- tradingagents/agents/risk_mgmt/conservative_debator.py | 3 ++- tradingagents/agents/risk_mgmt/neutral_debator.py | 3 ++- tradingagents/agents/trader/trader.py | 6 +++++- tradingagents/agents/utils/agent_utils.py | 6 ++++-- 8 files changed, 24 insertions(+), 10 deletions(-) diff --git a/tradingagents/agents/managers/research_manager.py b/tradingagents/agents/managers/research_manager.py index 0e2206b2e..924b36b4d 100644 --- a/tradingagents/agents/managers/research_manager.py +++ b/tradingagents/agents/managers/research_manager.py @@ -3,7 +3,10 @@ from __future__ import annotations from tradingagents.agents.schemas import ResearchPlan, render_research_plan -from tradingagents.agents.utils.agent_utils import build_instrument_context +from tradingagents.agents.utils.agent_utils import ( + build_instrument_context, + get_language_instruction, +) from tradingagents.agents.utils.structured import ( bind_structured, invoke_structured_or_freetext, @@ -37,7 +40,7 @@ Commit to a clear stance whenever the debate's strongest arguments warrant one; --- **Debate History:** -{history}""" +{history}""" + get_language_instruction() investment_plan = invoke_structured_or_freetext( structured_llm, diff --git a/tradingagents/agents/researchers/bear_researcher.py b/tradingagents/agents/researchers/bear_researcher.py index 9cde9d39c..c78923eb2 100644 --- a/tradingagents/agents/researchers/bear_researcher.py +++ b/tradingagents/agents/researchers/bear_researcher.py @@ -1,3 +1,4 @@ +from tradingagents.agents.utils.agent_utils import get_language_instruction def create_bear_researcher(llm): @@ -31,7 +32,7 @@ Company fundamentals report: {fundamentals_report} Conversation history of the debate: {history} Last bull argument: {current_response} 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. -""" +""" + get_language_instruction() response = llm.invoke(prompt) diff --git a/tradingagents/agents/researchers/bull_researcher.py b/tradingagents/agents/researchers/bull_researcher.py index d16bc2371..c639c0bed 100644 --- a/tradingagents/agents/researchers/bull_researcher.py +++ b/tradingagents/agents/researchers/bull_researcher.py @@ -1,3 +1,4 @@ +from tradingagents.agents.utils.agent_utils import get_language_instruction def create_bull_researcher(llm): @@ -29,7 +30,7 @@ Company fundamentals report: {fundamentals_report} Conversation history of the debate: {history} Last bear argument: {current_response} 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. -""" +""" + get_language_instruction() response = llm.invoke(prompt) diff --git a/tradingagents/agents/risk_mgmt/aggressive_debator.py b/tradingagents/agents/risk_mgmt/aggressive_debator.py index 2dab1152a..212e73d6e 100644 --- a/tradingagents/agents/risk_mgmt/aggressive_debator.py +++ b/tradingagents/agents/risk_mgmt/aggressive_debator.py @@ -1,3 +1,4 @@ +from tradingagents.agents.utils.agent_utils import get_language_instruction def create_aggressive_debator(llm): @@ -28,7 +29,7 @@ Latest World Affairs Report: {news_report} Company Fundamentals Report: {fundamentals_report} Here is the current conversation history: {history} Here are the last arguments from the conservative analyst: {current_conservative_response} Here are the last arguments from the neutral analyst: {current_neutral_response}. If there are no responses from the other viewpoints yet, present your own argument based on the available data. -Engage actively by addressing any specific concerns raised, refuting the weaknesses in their logic, and asserting the benefits of risk-taking to outpace market norms. Maintain a focus on debating and persuading, not just presenting data. Challenge each counterpoint to underscore why a high-risk approach is optimal. Output conversationally as if you are speaking without any special formatting.""" +Engage actively by addressing any specific concerns raised, refuting the weaknesses in their logic, and asserting the benefits of risk-taking to outpace market norms. Maintain a focus on debating and persuading, not just presenting data. Challenge each counterpoint to underscore why a high-risk approach is optimal. Output conversationally as if you are speaking without any special formatting.""" + get_language_instruction() response = llm.invoke(prompt) diff --git a/tradingagents/agents/risk_mgmt/conservative_debator.py b/tradingagents/agents/risk_mgmt/conservative_debator.py index 99a8315e0..a7f7342fa 100644 --- a/tradingagents/agents/risk_mgmt/conservative_debator.py +++ b/tradingagents/agents/risk_mgmt/conservative_debator.py @@ -1,3 +1,4 @@ +from tradingagents.agents.utils.agent_utils import get_language_instruction def create_conservative_debator(llm): @@ -28,7 +29,7 @@ Latest World Affairs Report: {news_report} Company Fundamentals Report: {fundamentals_report} Here is the current conversation history: {history} Here is the last response from the aggressive analyst: {current_aggressive_response} Here is the last response from the neutral analyst: {current_neutral_response}. If there are no responses from the other viewpoints yet, present your own argument based on the available data. -Engage by questioning their optimism and emphasizing the potential downsides they may have overlooked. Address each of their counterpoints to showcase why a conservative stance is ultimately the safest path for the firm's assets. Focus on debating and critiquing their arguments to demonstrate the strength of a low-risk strategy over their approaches. Output conversationally as if you are speaking without any special formatting.""" +Engage by questioning their optimism and emphasizing the potential downsides they may have overlooked. Address each of their counterpoints to showcase why a conservative stance is ultimately the safest path for the firm's assets. Focus on debating and critiquing their arguments to demonstrate the strength of a low-risk strategy over their approaches. Output conversationally as if you are speaking without any special formatting.""" + get_language_instruction() response = llm.invoke(prompt) diff --git a/tradingagents/agents/risk_mgmt/neutral_debator.py b/tradingagents/agents/risk_mgmt/neutral_debator.py index e99ff0af1..73b306078 100644 --- a/tradingagents/agents/risk_mgmt/neutral_debator.py +++ b/tradingagents/agents/risk_mgmt/neutral_debator.py @@ -1,3 +1,4 @@ +from tradingagents.agents.utils.agent_utils import get_language_instruction def create_neutral_debator(llm): @@ -28,7 +29,7 @@ Latest World Affairs Report: {news_report} Company Fundamentals Report: {fundamentals_report} Here is the current conversation history: {history} Here is the last response from the aggressive analyst: {current_aggressive_response} Here is the last response from the conservative analyst: {current_conservative_response}. If there are no responses from the other viewpoints yet, present your own argument based on the available data. -Engage actively by analyzing both sides critically, addressing weaknesses in the aggressive and conservative arguments to advocate for a more balanced approach. Challenge each of their points to illustrate why a moderate risk strategy might offer the best of both worlds, providing growth potential while safeguarding against extreme volatility. Focus on debating rather than simply presenting data, aiming to show that a balanced view can lead to the most reliable outcomes. Output conversationally as if you are speaking without any special formatting.""" +Engage actively by analyzing both sides critically, addressing weaknesses in the aggressive and conservative arguments to advocate for a more balanced approach. Challenge each of their points to illustrate why a moderate risk strategy might offer the best of both worlds, providing growth potential while safeguarding against extreme volatility. Focus on debating rather than simply presenting data, aiming to show that a balanced view can lead to the most reliable outcomes. Output conversationally as if you are speaking without any special formatting.""" + get_language_instruction() response = llm.invoke(prompt) diff --git a/tradingagents/agents/trader/trader.py b/tradingagents/agents/trader/trader.py index ea3f6b232..970350b1d 100644 --- a/tradingagents/agents/trader/trader.py +++ b/tradingagents/agents/trader/trader.py @@ -7,7 +7,10 @@ import functools from langchain_core.messages import AIMessage from tradingagents.agents.schemas import TraderProposal, render_trader_proposal -from tradingagents.agents.utils.agent_utils import build_instrument_context +from tradingagents.agents.utils.agent_utils import ( + build_instrument_context, + get_language_instruction, +) from tradingagents.agents.utils.structured import ( bind_structured, invoke_structured_or_freetext, @@ -29,6 +32,7 @@ def create_trader(llm): "You are a trading agent analyzing market data to make investment decisions. " "Based on your analysis, provide a specific recommendation to buy, sell, or hold. " "Anchor your reasoning in the analysts' reports and the research plan." + + get_language_instruction() ), }, { diff --git a/tradingagents/agents/utils/agent_utils.py b/tradingagents/agents/utils/agent_utils.py index 4ba40a803..03340b3fe 100644 --- a/tradingagents/agents/utils/agent_utils.py +++ b/tradingagents/agents/utils/agent_utils.py @@ -24,8 +24,10 @@ def get_language_instruction() -> str: """Return a prompt instruction for the configured output language. Returns empty string when English (default), so no extra tokens are used. - Only applied to user-facing agents (analysts, portfolio manager). - Internal debate agents stay in English for reasoning quality. + Applied to every agent whose output reaches the saved report — + analysts, researchers, debaters, research manager, trader, and + portfolio manager — so a non-English run produces a fully localized + report rather than a mix of languages. """ from tradingagents.dataflows.config import get_config lang = get_config().get("output_language", "English") From d13e9b79469abe142fcda7b26d03fcaf6a99262b Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 11 May 2026 06:12:31 +0000 Subject: [PATCH 19/21] feat(config): TRADINGAGENTS_* env-var overlay for DEFAULT_CONFIG Adds a single _ENV_OVERRIDES table in default_config.py with type-aware coercion (str/int/bool), so users can switch llm_provider, deep/quick models, backend URL, output language, debate rounds, and the checkpoint flag purely via .env. Centralizes load_dotenv in the package __init__ so the overlay applies for every entry point (CLI, main.py, programmatic). Drops the hardcoded model assignments and duplicate dotenv loads in main.py and cli/main.py. Verified live with OpenAI and Gemini. #602 --- .env.example | 13 +++++ cli/main.py | 12 ++-- main.py | 21 ++----- tests/test_env_overrides.py | 98 +++++++++++++++++++++++++++++++++ tradingagents/__init__.py | 15 +++++ tradingagents/default_config.py | 42 +++++++++++++- 6 files changed, 176 insertions(+), 25 deletions(-) create mode 100644 tests/test_env_overrides.py diff --git a/.env.example b/.env.example index 458d74956..a0a10050a 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,16 @@ ZHIPU_CN_API_KEY= MINIMAX_API_KEY= MINIMAX_CN_API_KEY= OPENROUTER_API_KEY= + +# Optional: override DEFAULT_CONFIG without editing code. +# Any TRADINGAGENTS_* variable below, when set, replaces the matching key +# in tradingagents/default_config.py. Values are coerced to the type of +# the existing default (bool / int / str), so "true"/"3" work as expected. +#TRADINGAGENTS_LLM_PROVIDER=openai +#TRADINGAGENTS_DEEP_THINK_LLM=gpt-5.4 +#TRADINGAGENTS_QUICK_THINK_LLM=gpt-5.4-mini +#TRADINGAGENTS_LLM_BACKEND_URL= +#TRADINGAGENTS_OUTPUT_LANGUAGE=English +#TRADINGAGENTS_MAX_DEBATE_ROUNDS=1 +#TRADINGAGENTS_MAX_RISK_ROUNDS=1 +#TRADINGAGENTS_CHECKPOINT_ENABLED=false diff --git a/cli/main.py b/cli/main.py index c466cb219..3af0cbbe2 100644 --- a/cli/main.py +++ b/cli/main.py @@ -5,13 +5,6 @@ import questionary from pathlib import Path from functools import wraps from rich.console import Console -from dotenv import find_dotenv, load_dotenv - -# Search starts from the user's CWD so the installed `tradingagents` -# console script picks up the project's .env instead of walking up from -# site-packages. -load_dotenv(find_dotenv(usecwd=True)) -load_dotenv(find_dotenv(".env.enterprise", usecwd=True), override=False) from rich.panel import Panel from rich.spinner import Spinner from rich.live import Live @@ -569,6 +562,11 @@ def get_user_selections(): elif selected_llm_provider == "glm": selected_llm_provider, backend_url = ask_glm_region() + # Confirm the provider's API key is present; prompt the user to paste + # one and persist it to .env if it's missing, so the analysis run + # doesn't fail later at the first API call. + ensure_api_key(selected_llm_provider) + # Step 7: Thinking agents console.print( create_question_box( diff --git a/main.py b/main.py index fa3024af8..fea2f3680 100644 --- a/main.py +++ b/main.py @@ -1,23 +1,12 @@ from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.default_config import DEFAULT_CONFIG -from dotenv import find_dotenv, load_dotenv - -load_dotenv(find_dotenv(usecwd=True)) - -# Create a custom config +# DEFAULT_CONFIG already applies TRADINGAGENTS_* env-var overrides +# (llm_provider, deep_think_llm, quick_think_llm, backend_url, etc.), +# so users can switch models or endpoints purely via .env without +# editing this script. Override individual keys here only when you +# want a hard-coded value that should ignore the environment. config = DEFAULT_CONFIG.copy() -config["deep_think_llm"] = "gpt-5.4-mini" # Use a different model -config["quick_think_llm"] = "gpt-5.4-mini" # Use a different model -config["max_debate_rounds"] = 1 # Increase debate rounds - -# Configure data vendors (default uses yfinance, no extra API keys needed) -config["data_vendors"] = { - "core_stock_apis": "yfinance", # Options: alpha_vantage, yfinance - "technical_indicators": "yfinance", # Options: alpha_vantage, yfinance - "fundamental_data": "yfinance", # Options: alpha_vantage, yfinance - "news_data": "yfinance", # Options: alpha_vantage, yfinance -} # Initialize with custom config ta = TradingAgentsGraph(debug=True, config=config) diff --git a/tests/test_env_overrides.py b/tests/test_env_overrides.py new file mode 100644 index 000000000..c12ce5f18 --- /dev/null +++ b/tests/test_env_overrides.py @@ -0,0 +1,98 @@ +"""Tests for TRADINGAGENTS_* env-var overlay onto DEFAULT_CONFIG.""" + +from __future__ import annotations + +import importlib + +import pytest + +import tradingagents.default_config as default_config_module + + +def _reload_with_env(monkeypatch, **overrides): + """Set/clear env vars then reload default_config to re-evaluate DEFAULT_CONFIG.""" + for key in list(default_config_module._ENV_OVERRIDES): + monkeypatch.delenv(key, raising=False) + for key, val in overrides.items(): + monkeypatch.setenv(key, val) + return importlib.reload(default_config_module) + + +def test_no_env_uses_built_in_defaults(monkeypatch): + dc = _reload_with_env(monkeypatch) + assert dc.DEFAULT_CONFIG["llm_provider"] == "openai" + assert dc.DEFAULT_CONFIG["deep_think_llm"] == "gpt-5.4" + assert dc.DEFAULT_CONFIG["quick_think_llm"] == "gpt-5.4-mini" + assert dc.DEFAULT_CONFIG["backend_url"] is None + assert dc.DEFAULT_CONFIG["max_debate_rounds"] == 1 + assert dc.DEFAULT_CONFIG["checkpoint_enabled"] is False + + +def test_string_overrides(monkeypatch): + dc = _reload_with_env( + monkeypatch, + TRADINGAGENTS_LLM_PROVIDER="google", + TRADINGAGENTS_DEEP_THINK_LLM="gemini-3-pro-preview", + TRADINGAGENTS_QUICK_THINK_LLM="gemini-3-flash-preview", + TRADINGAGENTS_LLM_BACKEND_URL="https://example.invalid/v1", + TRADINGAGENTS_OUTPUT_LANGUAGE="Chinese", + ) + assert dc.DEFAULT_CONFIG["llm_provider"] == "google" + assert dc.DEFAULT_CONFIG["deep_think_llm"] == "gemini-3-pro-preview" + assert dc.DEFAULT_CONFIG["quick_think_llm"] == "gemini-3-flash-preview" + assert dc.DEFAULT_CONFIG["backend_url"] == "https://example.invalid/v1" + assert dc.DEFAULT_CONFIG["output_language"] == "Chinese" + + +def test_int_coercion(monkeypatch): + dc = _reload_with_env( + monkeypatch, + TRADINGAGENTS_MAX_DEBATE_ROUNDS="3", + TRADINGAGENTS_MAX_RISK_ROUNDS="2", + ) + assert dc.DEFAULT_CONFIG["max_debate_rounds"] == 3 + assert isinstance(dc.DEFAULT_CONFIG["max_debate_rounds"], int) + assert dc.DEFAULT_CONFIG["max_risk_discuss_rounds"] == 2 + assert isinstance(dc.DEFAULT_CONFIG["max_risk_discuss_rounds"], int) + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("true", True), ("True", True), ("1", True), ("yes", True), ("on", True), + ("false", False), ("False", False), ("0", False), ("no", False), ("off", False), + ], +) +def test_bool_coercion(monkeypatch, raw, expected): + dc = _reload_with_env(monkeypatch, TRADINGAGENTS_CHECKPOINT_ENABLED=raw) + assert dc.DEFAULT_CONFIG["checkpoint_enabled"] is expected + + +def test_empty_env_value_is_passthrough(monkeypatch): + """Empty TRADINGAGENTS_* values must not clobber the built-in default.""" + dc = _reload_with_env( + monkeypatch, + TRADINGAGENTS_LLM_PROVIDER="", + TRADINGAGENTS_MAX_DEBATE_ROUNDS="", + ) + assert dc.DEFAULT_CONFIG["llm_provider"] == "openai" + assert dc.DEFAULT_CONFIG["max_debate_rounds"] == 1 + + +def test_invalid_int_raises(monkeypatch): + """Garbage int values should surface a ValueError at import, not silently misconfigure.""" + monkeypatch.setenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", "not-a-number") + with pytest.raises(ValueError): + importlib.reload(default_config_module) + # Restore module state for subsequent tests in this process + monkeypatch.delenv("TRADINGAGENTS_MAX_DEBATE_ROUNDS", raising=False) + importlib.reload(default_config_module) + + +def test_unknown_env_var_is_ignored(monkeypatch): + """Env vars outside _ENV_OVERRIDES must not bleed into DEFAULT_CONFIG.""" + dc = _reload_with_env( + monkeypatch, + TRADINGAGENTS_NONEXISTENT_KEY="oops", + ) + assert "nonexistent_key" not in dc.DEFAULT_CONFIG diff --git a/tradingagents/__init__.py b/tradingagents/__init__.py index 893a3d678..5f83f2a52 100644 --- a/tradingagents/__init__.py +++ b/tradingagents/__init__.py @@ -1,5 +1,20 @@ import warnings +# Load .env files at package import so DEFAULT_CONFIG's env-var overlay +# (and every llm_clients consumer) sees the user's keys regardless of +# which entry point started the process. find_dotenv(usecwd=True) walks +# from the CWD, so the installed `tradingagents` console script picks up +# the project's .env instead of stepping up from site-packages. +# load_dotenv defaults to override=False, so it never clobbers values +# the caller has already exported. +try: + from dotenv import find_dotenv, load_dotenv + + load_dotenv(find_dotenv(usecwd=True)) + load_dotenv(find_dotenv(".env.enterprise", usecwd=True), override=False) +except ImportError: + pass + # langchain-core 1.3.3 calls surface_langchain_deprecation_warnings() in # its own __init__, which prepends default-action filters for its # subclassed warning categories. To suppress a specific warning we must diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index faa71f591..fe5a6f755 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -2,7 +2,45 @@ import os _TRADINGAGENTS_HOME = os.path.join(os.path.expanduser("~"), ".tradingagents") -DEFAULT_CONFIG = { +# Single source of truth for env-var → config-key overrides. To expose +# a new config key for environment-based override, add a row here — no +# entry-point script changes required. Coercion is driven by the type +# of the existing default, so users can keep writing plain strings in +# their .env file. +_ENV_OVERRIDES = { + "TRADINGAGENTS_LLM_PROVIDER": "llm_provider", + "TRADINGAGENTS_DEEP_THINK_LLM": "deep_think_llm", + "TRADINGAGENTS_QUICK_THINK_LLM": "quick_think_llm", + "TRADINGAGENTS_LLM_BACKEND_URL": "backend_url", + "TRADINGAGENTS_OUTPUT_LANGUAGE": "output_language", + "TRADINGAGENTS_MAX_DEBATE_ROUNDS": "max_debate_rounds", + "TRADINGAGENTS_MAX_RISK_ROUNDS": "max_risk_discuss_rounds", + "TRADINGAGENTS_CHECKPOINT_ENABLED": "checkpoint_enabled", +} + + +def _coerce(value: str, reference): + """Coerce env-var string to the type of the existing default value.""" + if isinstance(reference, bool): + return value.strip().lower() in ("true", "1", "yes", "on") + if isinstance(reference, int) and not isinstance(reference, bool): + return int(value) + if isinstance(reference, float): + return float(value) + return value + + +def _apply_env_overrides(config: dict) -> dict: + """Apply TRADINGAGENTS_* env vars to the config dict in-place.""" + for env_var, key in _ENV_OVERRIDES.items(): + raw = os.environ.get(env_var) + if raw is None or raw == "": + continue + config[key] = _coerce(raw, config.get(key)) + return config + + +DEFAULT_CONFIG = _apply_env_overrides({ "project_dir": os.path.abspath(os.path.join(os.path.dirname(__file__), ".")), "results_dir": os.getenv("TRADINGAGENTS_RESULTS_DIR", os.path.join(_TRADINGAGENTS_HOME, "logs")), "data_cache_dir": os.getenv("TRADINGAGENTS_CACHE_DIR", os.path.join(_TRADINGAGENTS_HOME, "cache")), @@ -62,4 +100,4 @@ DEFAULT_CONFIG = { "tool_vendors": { # Example: "get_stock_data": "alpha_vantage", # Override category default }, -} +}) From 9f7abfcbd576686685210f2dc6b8ec52c5d744ba Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 11 May 2026 06:12:34 +0000 Subject: [PATCH 20/21] feat(cli): detect missing provider API keys and persist to .env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a canonical PROVIDER_API_KEY_ENV mapping (14 providers including the three dual-region pairs) and an ensure_api_key() helper. When the selected provider's key is absent from the environment, the CLI prompts via questionary.password, writes the value to .env via python-dotenv's set_key (preserves existing lines), and exports it into os.environ so the run continues without restart. Wired into cli/main.py right after the region prompts so qwen-cn, glm-cn, and minimax-cn each check their own region-specific key. openai_client refactored to consult the same mapping, eliminating its private duplicate of provider→env-var data. --- cli/utils.py | 49 ++++++- tests/test_api_key_env.py | 149 +++++++++++++++++++++ tradingagents/llm_clients/api_key_env.py | 44 ++++++ tradingagents/llm_clients/openai_client.py | 43 +++--- 4 files changed, 261 insertions(+), 24 deletions(-) create mode 100644 tests/test_api_key_env.py create mode 100644 tradingagents/llm_clients/api_key_env.py diff --git a/cli/utils.py b/cli/utils.py index 1ccc12302..5fd0b806c 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -1,9 +1,13 @@ -import questionary +import os +from pathlib import Path from typing import List, Optional, Tuple, Dict +import questionary +from dotenv import find_dotenv, set_key from rich.console import Console from cli.models import AnalystType +from tradingagents.llm_clients.api_key_env import get_api_key_env from tradingagents.llm_clients.model_catalog import get_model_options console = Console() @@ -409,6 +413,49 @@ def ask_minimax_region() -> tuple[str, str]: ).ask() +def ensure_api_key(provider: str) -> Optional[str]: + """Make sure the API key for `provider` is available in the environment. + + If the env var is already set, returns its value untouched. Otherwise + interactively prompts the user, persists the value to the project's + .env file via python-dotenv's set_key (creating .env if needed), and + exports it into os.environ so the current process picks it up. + + Returns None for providers that do not require a key (e.g. ollama) + and for providers not found in the canonical mapping. + """ + env_var = get_api_key_env(provider) + if env_var is None: + return None # ollama / unknown — no key check possible + + existing = os.environ.get(env_var) + if existing: + return existing + + console.print( + f"\n[yellow]{env_var} is not set in your environment.[/yellow]" + ) + key = questionary.password( + f"Paste your {env_var} (will be saved to .env):", + style=questionary.Style([ + ("text", "fg:cyan"), + ("highlighted", "noinherit"), + ]), + ).ask() + if not key: + console.print( + f"[red]Skipped. API calls will fail until {env_var} is set.[/red]" + ) + return None + + env_path = find_dotenv(usecwd=True) or str(Path.cwd() / ".env") + Path(env_path).touch(exist_ok=True) + set_key(env_path, env_var, key) + os.environ[env_var] = key + console.print(f"[green]Saved {env_var} to {env_path}[/green]") + return key + + def ask_output_language() -> str: """Ask for report output language.""" choice = questionary.select( diff --git a/tests/test_api_key_env.py b/tests/test_api_key_env.py new file mode 100644 index 000000000..dde5a4886 --- /dev/null +++ b/tests/test_api_key_env.py @@ -0,0 +1,149 @@ +"""Tests for the canonical provider->env-var mapping and the CLI key-prompt helper.""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from tradingagents.llm_clients.api_key_env import PROVIDER_API_KEY_ENV, get_api_key_env + + +# ---- Mapping coverage ----------------------------------------------------- + + +def test_every_select_llm_provider_choice_has_an_entry(): + """select_llm_provider() must not present a provider the mapping doesn't know about.""" + # Mirrors the dropdown order in cli/utils.select_llm_provider so the two + # stay in lockstep. Region-specific keys (qwen-cn / minimax-cn / glm-cn) + # are reached via the secondary region prompt, so they must also be present. + expected = { + "openai", "google", "anthropic", "xai", "deepseek", + "qwen", "qwen-cn", + "glm", "glm-cn", + "minimax", "minimax-cn", + "openrouter", "azure", "ollama", + } + assert expected.issubset(PROVIDER_API_KEY_ENV.keys()) + + +@pytest.mark.parametrize( + "provider,env_var", + [ + ("openai", "OPENAI_API_KEY"), + ("anthropic", "ANTHROPIC_API_KEY"), + ("google", "GOOGLE_API_KEY"), + ("azure", "AZURE_OPENAI_API_KEY"), + ("xai", "XAI_API_KEY"), + ("deepseek", "DEEPSEEK_API_KEY"), + ("qwen", "DASHSCOPE_API_KEY"), + ("qwen-cn", "DASHSCOPE_CN_API_KEY"), + ("glm", "ZHIPU_API_KEY"), + ("glm-cn", "ZHIPU_CN_API_KEY"), + ("minimax", "MINIMAX_API_KEY"), + ("minimax-cn", "MINIMAX_CN_API_KEY"), + ("openrouter", "OPENROUTER_API_KEY"), + ], +) +def test_known_providers_resolve(provider, env_var): + assert get_api_key_env(provider) == env_var + + +def test_ollama_has_no_key(): + assert get_api_key_env("ollama") is None + + +def test_unknown_provider_returns_none(): + assert get_api_key_env("not-a-real-provider") is None + + +def test_case_insensitive_lookup(): + assert get_api_key_env("OpenAI") == "OPENAI_API_KEY" + assert get_api_key_env("QWEN-CN") == "DASHSCOPE_CN_API_KEY" + + +# ---- ensure_api_key behavior --------------------------------------------- + + +@pytest.fixture +def cli_utils(monkeypatch): + """Import cli.utils with a fresh environment so module-level state is consistent.""" + import importlib + import cli.utils as cli_utils_module + return importlib.reload(cli_utils_module) + + +def test_ensure_api_key_returns_existing(monkeypatch, cli_utils): + monkeypatch.setenv("OPENAI_API_KEY", "sk-already-set") + result = cli_utils.ensure_api_key("openai") + assert result == "sk-already-set" + + +def test_ensure_api_key_no_op_for_ollama(monkeypatch, cli_utils): + # Even with no env var set, ollama should not prompt and should return None. + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + with patch.object(cli_utils, "questionary") as mock_q: + result = cli_utils.ensure_api_key("ollama") + assert result is None + mock_q.password.assert_not_called() + + +def test_ensure_api_key_unknown_provider_no_prompt(monkeypatch, cli_utils): + with patch.object(cli_utils, "questionary") as mock_q: + result = cli_utils.ensure_api_key("totally-fake-provider") + assert result is None + mock_q.password.assert_not_called() + + +def test_ensure_api_key_prompts_and_writes_to_env(monkeypatch, tmp_path, cli_utils): + """When key is missing, user-pasted value must be written to .env AND os.environ.""" + monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) + monkeypatch.chdir(tmp_path) + + fake_prompt = type("P", (), {"ask": staticmethod(lambda: "sk-deepseek-test")})() + with patch.object(cli_utils.questionary, "password", return_value=fake_prompt): + result = cli_utils.ensure_api_key("deepseek") + + assert result == "sk-deepseek-test" + assert os.environ["DEEPSEEK_API_KEY"] == "sk-deepseek-test" + env_file = tmp_path / ".env" + assert env_file.exists() + assert "DEEPSEEK_API_KEY" in env_file.read_text() + assert "sk-deepseek-test" in env_file.read_text() + + +def test_ensure_api_key_user_cancels_returns_none(monkeypatch, tmp_path, cli_utils): + """Empty prompt response (user cancelled) must not write to .env.""" + monkeypatch.delenv("XAI_API_KEY", raising=False) + monkeypatch.chdir(tmp_path) + + fake_prompt = type("P", (), {"ask": staticmethod(lambda: None)})() + with patch.object(cli_utils.questionary, "password", return_value=fake_prompt): + result = cli_utils.ensure_api_key("xai") + + assert result is None + assert "XAI_API_KEY" not in os.environ + # .env may or may not exist depending on find_dotenv's walk, but if it + # does it must not contain the key. + env_file = tmp_path / ".env" + if env_file.exists(): + assert "XAI_API_KEY" not in env_file.read_text() + + +def test_ensure_api_key_updates_existing_env_file(monkeypatch, tmp_path, cli_utils): + """An existing .env with other keys must be preserved on writeback.""" + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.chdir(tmp_path) + env_file = tmp_path / ".env" + env_file.write_text("OPENAI_API_KEY=sk-existing\nOTHER=value\n") + + fake_prompt = type("P", (), {"ask": staticmethod(lambda: "sk-openrouter-new")})() + with patch.object(cli_utils.questionary, "password", return_value=fake_prompt): + cli_utils.ensure_api_key("openrouter") + + content = env_file.read_text() + assert "OPENAI_API_KEY" in content and "sk-existing" in content + assert "OTHER=value" in content + assert "OPENROUTER_API_KEY" in content and "sk-openrouter-new" in content diff --git a/tradingagents/llm_clients/api_key_env.py b/tradingagents/llm_clients/api_key_env.py new file mode 100644 index 000000000..ff03d441a --- /dev/null +++ b/tradingagents/llm_clients/api_key_env.py @@ -0,0 +1,44 @@ +"""Canonical provider -> API-key env-var mapping. + +A single source of truth for which environment variable holds the API +key for each supported LLM provider. Used by the CLI's interactive key +prompt (cli/utils.ensure_api_key) and by anything else that needs to +ask "does this provider require a key, and which env var is it?". + +When adding a new provider, register its env var here so the CLI flow +prompts for it automatically instead of failing on first API call. +""" + +from __future__ import annotations + +from typing import Optional + + +PROVIDER_API_KEY_ENV: dict[str, Optional[str]] = { + "openai": "OPENAI_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "google": "GOOGLE_API_KEY", + "azure": "AZURE_OPENAI_API_KEY", + "xai": "XAI_API_KEY", + "deepseek": "DEEPSEEK_API_KEY", + # Dual-region providers each carry their own account; keys are not + # interchangeable between the international and China endpoints. + "qwen": "DASHSCOPE_API_KEY", + "qwen-cn": "DASHSCOPE_CN_API_KEY", + "glm": "ZHIPU_API_KEY", + "glm-cn": "ZHIPU_CN_API_KEY", + "minimax": "MINIMAX_API_KEY", + "minimax-cn": "MINIMAX_CN_API_KEY", + "openrouter": "OPENROUTER_API_KEY", + # Local runtimes do not authenticate. + "ollama": None, +} + + +def get_api_key_env(provider: str) -> Optional[str]: + """Return the env var name for `provider`'s API key, or None if not applicable. + + Unknown providers also return None — callers should treat that as + "no key check possible" rather than as "no key required". + """ + return PROVIDER_API_KEY_ENV.get(provider.lower()) diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py index 89c67e31d..771b28127 100644 --- a/tradingagents/llm_clients/openai_client.py +++ b/tradingagents/llm_clients/openai_client.py @@ -4,6 +4,7 @@ from typing import Any, Optional from langchain_core.messages import AIMessage from langchain_openai import ChatOpenAI +from .api_key_env import get_api_key_env from .base_client import BaseLLMClient, normalize_content from .capabilities import get_capabilities from .validators import validate_model @@ -135,26 +136,22 @@ _PASSTHROUGH_KWARGS = ( "api_key", "callbacks", "http_client", "http_async_client", ) -# Provider base URLs and API key env vars -_PROVIDER_CONFIG = { - "xai": ("https://api.x.ai/v1", "XAI_API_KEY"), - "deepseek": ("https://api.deepseek.com", "DEEPSEEK_API_KEY"), - # DashScope exposes two regional endpoints with separate accounts; an - # international key won't authenticate against the China endpoint and - # vice versa (fixes issue #758). - "qwen": ("https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "DASHSCOPE_API_KEY"), - "qwen-cn": ("https://dashscope.aliyuncs.com/compatible-mode/v1", "DASHSCOPE_CN_API_KEY"), - # Zhipu exposes the same GLM models under two brands with separate - # accounts: Z.AI (international, api.z.ai) and BigModel - # (open.bigmodel.cn, China). Keys aren't interchangeable across them. - "glm": ("https://api.z.ai/api/paas/v4/", "ZHIPU_API_KEY"), - "glm-cn": ("https://open.bigmodel.cn/api/paas/v4/", "ZHIPU_CN_API_KEY"), - # MiniMax exposes two regional endpoints with separate keys; mainland - # Chinese users hit .com while global users hit .io. - "minimax": ("https://api.minimax.io/v1", "MINIMAX_API_KEY"), - "minimax-cn": ("https://api.minimaxi.com/v1", "MINIMAX_CN_API_KEY"), - "openrouter": ("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY"), - "ollama": ("http://localhost:11434/v1", None), +# Provider base URLs. API-key env vars live in api_key_env.PROVIDER_API_KEY_ENV +# (one canonical mapping consulted by both this client and the CLI's +# interactive key-prompt). Dual-region providers (qwen/glm/minimax) keep +# separate endpoints because international and China accounts cannot share +# credentials (#758). +_PROVIDER_BASE_URL = { + "xai": "https://api.x.ai/v1", + "deepseek": "https://api.deepseek.com", + "qwen": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + "qwen-cn": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "glm": "https://api.z.ai/api/paas/v4/", + "glm-cn": "https://open.bigmodel.cn/api/paas/v4/", + "minimax": "https://api.minimax.io/v1", + "minimax-cn": "https://api.minimaxi.com/v1", + "openrouter": "https://openrouter.ai/api/v1", + "ollama": "http://localhost:11434/v1", } @@ -185,9 +182,9 @@ class OpenAIClient(BaseLLMClient): # Provider-specific base URL and auth. An explicit base_url on the # client (e.g. a corporate proxy) takes precedence over the # provider default so users can route through their own gateway. - if self.provider in _PROVIDER_CONFIG: - default_base, api_key_env = _PROVIDER_CONFIG[self.provider] - llm_kwargs["base_url"] = self.base_url or default_base + if self.provider in _PROVIDER_BASE_URL: + llm_kwargs["base_url"] = self.base_url or _PROVIDER_BASE_URL[self.provider] + api_key_env = get_api_key_env(self.provider) if api_key_env: api_key = os.environ.get(api_key_env) if api_key: From 879e2bb5da53b0a3f78de014bb165ba7de55e83b Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 11 May 2026 06:25:22 +0000 Subject: [PATCH 21/21] refactor: align display label and docs with sentiment_analyst rename The agent ingests news, StockTwits, and Reddit, but CLI labels, the README description, and the legacy shim docstring still framed it as social-media-only. Updates all user-visible surfaces so the name and the implementation match. --- README.md | 2 +- cli/main.py | 12 ++++++------ cli/models.py | 2 ++ cli/utils.py | 2 +- .../agents/analysts/social_media_analyst.py | 10 +++++----- tradingagents/agents/utils/agent_states.py | 2 +- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b4578a8c9..a74c4f471 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Our framework decomposes complex trading tasks into specialized roles. This ensu ### Analyst Team - Fundamentals Analyst: Evaluates company financials and performance metrics, identifying intrinsic values and potential red flags. -- Sentiment Analyst: Analyzes social media and public sentiment using sentiment scoring algorithms to gauge short-term market mood. +- Sentiment Analyst: Aggregates news headlines, StockTwits, and Reddit chatter into a single sentiment read to gauge short-term market mood. - News Analyst: Monitors global news and macroeconomic indicators, interpreting the impact of events on market conditions. - Technical Analyst: Utilizes technical indicators (like MACD and RSI) to detect trading patterns and forecast price movements. diff --git a/cli/main.py b/cli/main.py index 3af0cbbe2..043c2311f 100644 --- a/cli/main.py +++ b/cli/main.py @@ -49,7 +49,7 @@ class MessageBuffer: # Analyst name mapping ANALYST_MAPPING = { "market": "Market Analyst", - "social": "Social Analyst", + "social": "Sentiment Analyst", "news": "News Analyst", "fundamentals": "Fundamentals Analyst", } @@ -59,7 +59,7 @@ class MessageBuffer: # finalizing_agent: which agent must be "completed" for this report to count as done REPORT_SECTIONS = { "market_report": ("market", "Market Analyst"), - "sentiment_report": ("social", "Social Analyst"), + "sentiment_report": ("social", "Sentiment Analyst"), "news_report": ("news", "News Analyst"), "fundamentals_report": ("fundamentals", "Fundamentals Analyst"), "investment_plan": (None, "Research Manager"), @@ -280,7 +280,7 @@ def update_display(layout, spinner_text=None, stats_handler=None, start_time=Non all_teams = { "Analyst Team": [ "Market Analyst", - "Social Analyst", + "Sentiment Analyst", "News Analyst", "Fundamentals Analyst", ], @@ -680,7 +680,7 @@ def save_report_to_disk(final_state, ticker: str, save_path: Path): if final_state.get("sentiment_report"): analysts_dir.mkdir(exist_ok=True) (analysts_dir / "sentiment.md").write_text(final_state["sentiment_report"], encoding="utf-8") - analyst_parts.append(("Social Analyst", final_state["sentiment_report"])) + analyst_parts.append(("Sentiment Analyst", final_state["sentiment_report"])) if final_state.get("news_report"): analysts_dir.mkdir(exist_ok=True) (analysts_dir / "news.md").write_text(final_state["news_report"], encoding="utf-8") @@ -765,7 +765,7 @@ def display_complete_report(final_state): if final_state.get("market_report"): analysts.append(("Market Analyst", final_state["market_report"])) if final_state.get("sentiment_report"): - analysts.append(("Social Analyst", final_state["sentiment_report"])) + analysts.append(("Sentiment Analyst", final_state["sentiment_report"])) if final_state.get("news_report"): analysts.append(("News Analyst", final_state["news_report"])) if final_state.get("fundamentals_report"): @@ -827,7 +827,7 @@ def update_research_team_status(status): ANALYST_ORDER = ["market", "social", "news", "fundamentals"] ANALYST_AGENT_NAMES = { "market": "Market Analyst", - "social": "Social Analyst", + "social": "Sentiment Analyst", "news": "News Analyst", "fundamentals": "Fundamentals Analyst", } diff --git a/cli/models.py b/cli/models.py index f68c3da1c..d1c5c24b1 100644 --- a/cli/models.py +++ b/cli/models.py @@ -5,6 +5,8 @@ from pydantic import BaseModel class AnalystType(str, Enum): MARKET = "market" + # Wire value stays "social" for saved-config and string-keyed-caller + # back-compat; the user-facing label is "Sentiment Analyst". SOCIAL = "social" NEWS = "news" FUNDAMENTALS = "fundamentals" diff --git a/cli/utils.py b/cli/utils.py index 5fd0b806c..15371400a 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -16,7 +16,7 @@ TICKER_INPUT_EXAMPLES = "Examples: SPY, CNC.TO, 7203.T, 0700.HK" ANALYST_ORDER = [ ("Market Analyst", AnalystType.MARKET), - ("Social Media Analyst", AnalystType.SOCIAL), + ("Sentiment Analyst", AnalystType.SOCIAL), ("News Analyst", AnalystType.NEWS), ("Fundamentals Analyst", AnalystType.FUNDAMENTALS), ] diff --git a/tradingagents/agents/analysts/social_media_analyst.py b/tradingagents/agents/analysts/social_media_analyst.py index 03cd7a44c..8c72df082 100644 --- a/tradingagents/agents/analysts/social_media_analyst.py +++ b/tradingagents/agents/analysts/social_media_analyst.py @@ -1,9 +1,9 @@ -"""Backwards-compatibility shim for the renamed social_media_analyst module. +"""Backwards-compatibility shim for the renamed module. -The social media analyst has been renamed to ``sentiment_analyst`` because its -only data tool is ``get_news`` (Yahoo Finance), not a social media feed. - -Import from ``tradingagents.agents.analysts.sentiment_analyst`` going forward. +The agent is now ``sentiment_analyst`` and aggregates Yahoo Finance news, +StockTwits cashtag streams, and Reddit posts into a single sentiment +report. Import from ``tradingagents.agents.analysts.sentiment_analyst`` +going forward; this module will be removed in a future release. See: https://github.com/TauricResearch/TradingAgents/issues/557 """ diff --git a/tradingagents/agents/utils/agent_states.py b/tradingagents/agents/utils/agent_states.py index 6151a3863..d3a441a1a 100644 --- a/tradingagents/agents/utils/agent_states.py +++ b/tradingagents/agents/utils/agent_states.py @@ -51,7 +51,7 @@ class AgentState(MessagesState): # research step market_report: Annotated[str, "Report from the Market Analyst"] - sentiment_report: Annotated[str, "Report from the Social Media Analyst"] + sentiment_report: Annotated[str, "Report from the Sentiment Analyst"] news_report: Annotated[ str, "Report from the News Researcher of current world affairs" ]