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 }, -} +})