Merge remote-tracking branch 'upstream/main' into crypto-analysis-mvp

# Conflicts:
#	cli/utils.py
#	tradingagents/agents/analysts/social_media_analyst.py
#	tradingagents/agents/researchers/bear_researcher.py
This commit is contained in:
CadeYu
2026-05-11 16:41:09 +08:00
40 changed files with 1731 additions and 254 deletions

View File

@@ -1,14 +1,10 @@
from typing import Optional
import datetime
import typer
import questionary
from pathlib import Path
from functools import wraps
from rich.console import Console
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
load_dotenv(".env.enterprise", override=False)
from rich.panel import Panel
from rich.spinner import Spinner
from rich.live import Live
@@ -53,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",
}
@@ -63,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"),
@@ -284,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",
],
@@ -560,6 +556,21 @@ 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()
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(
@@ -618,8 +629,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():
@@ -656,7 +685,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")
@@ -741,7 +770,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"):
@@ -803,7 +832,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",
}
@@ -1160,8 +1189,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

View File

@@ -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"

View File

@@ -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, AssetType
from tradingagents.llm_clients.api_key_env import get_api_key_env
from tradingagents.llm_clients.model_catalog import get_model_options
console = Console()
@@ -12,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),
]
@@ -264,8 +268,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"),
("OpenRouter", "openrouter", "https://openrouter.ai/api/v1"),
("Azure OpenAI", "azure", None),
("Ollama", "ollama", "http://localhost:11434/v1"),
@@ -316,7 +321,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:",
@@ -353,6 +360,129 @@ 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.
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 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(