mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-06-16 21:06:15 +03:00
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:
@@ -18,7 +18,11 @@ _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",
|
||||
"AZURE_OPENAI_API_KEY",
|
||||
"ALPHA_VANTAGE_API_KEY",
|
||||
|
||||
149
tests/test_api_key_env.py
Normal file
149
tests/test_api_key_env.py
Normal file
@@ -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
|
||||
107
tests/test_capabilities.py
Normal file
107
tests/test_capabilities.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""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 and MiniMax 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
|
||||
|
||||
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:
|
||||
"""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]
|
||||
61
tests/test_dataflows_config.py
Normal file
61
tests/test_dataflows_config.py
Normal file
@@ -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")
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
98
tests/test_env_overrides.py
Normal file
98
tests/test_env_overrides.py
Normal file
@@ -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
|
||||
73
tests/test_minimax.py
Normal file
73
tests/test_minimax.py
Normal file
@@ -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 <think> 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}"
|
||||
Reference in New Issue
Block a user