diff --git a/.env.example b/.env.example index a81234734..b6f14ed41 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,10 @@ NVIDIA_API_KEY= # own default (usually 2). Raise it to ride out bursty 429 rate-limit throttling # on rate-limited deployments (e.g. Azure OpenAI) instead of aborting the run. #TRADINGAGENTS_LLM_MAX_RETRIES=6 +# Cap on output tokens forwarded to every provider (Gemini's max_output_tokens +# too). Unset leaves each provider at its default. Set it to bound a model that +# emits unbounded reasoning/output and hangs or trips a gateway idle timeout. +#TRADINGAGENTS_MAX_TOKENS=8192 # Provider-specific reasoning/thinking depth (optional; unset = provider # default). Setting one also skips the matching interactive prompt. #TRADINGAGENTS_OPENAI_REASONING_EFFORT=medium diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e926b004..9071c0445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,63 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Breaking changes within the 0.x line are called out explicitly. +## [0.4.0] — 2026-08-31 + +Look-ahead and point-in-time fixes across the data and memory layers, clearer +decision signals, working CLI checkpoint resume, and the GPT-5.6 / GLM-5.3 models. + +### Fixed + +- **FRED macro look-ahead.** Historical macro requests were served from today's + data vintage, leaking later revisions into a backtest; both the observations + and metadata requests now pin the vintage to the as-of date. (#1275) +- **Social sentiment look-ahead.** StockTwits and Reddit were fetched with no + date, so a historical run showed today's chatter as if it were from the as-of + date; the social path is now trimmed to the analysis window, via one shared + UTC half-open window rule (`dataflows/date_window`) used by news too. (#1220) +- **Memory point-in-time guard.** `get_past_context` returned every resolved + lesson regardless of the run date; each resolved entry now records the date + its outcome became known, and a historical run only sees lessons resolved by + the trade date. (#1251) +- **Premature reflection.** A decision was settled on a partial return if a rerun + happened before its holding window fully traded; resolution now waits for the + full window. (#1169) +- **Latest OHLCV bar dropped.** The newest bar with a NaN close was silently + dropped before the date cutoff, making the previous trading day look like the + latest; dates are normalized per element (DST- and non-US-market safe) and a + missing latest close raises rather than falling back. (#1201) +- **Debate opening fabrication.** The first speaker in each debate round rebutted + an empty opponent response, fabricating the other side; all five debators now + open with their own case when no opponent has spoken. (#1176) +- **Silent Hold.** An unparseable Portfolio Manager rating (including a fullwidth + colon) was coerced to a tradeable Hold; it now surfaces a `REVIEW` sentinel, + with `parse_rating` keeping its silent default for compatibility callers. (#1170) +- **`--checkpoint` was a no-op on the CLI.** Checkpoint setup lived only in + `propagate()`; the CLI streamed the checkpointer-less graph. The lifecycle is + now shared, and a resume feeds `None` so LangGraph continues the interrupted + run instead of duplicating messages. (#1249) +- **DeepSeek via OpenRouter.** `deepseek/` fell through to default + capabilities and had object-form `tool_choice` forced on it; the official + namespace is stripped so it reuses the native DeepSeek quirks. (#1199) +- **Trader price grounding.** The Trader saw only the digested plan; it now also + receives the technical market report so entry/stop levels anchor to real price + structure. (#1167) + +### Added + +- **Configurable output-token cap.** `max_tokens` / `TRADINGAGENTS_MAX_TOKENS`, + forwarded to every provider (Gemini as `max_output_tokens`), so a model that + emits unbounded reasoning can be bounded instead of hanging. (#1204) +- **Latest models.** Added the GPT-5.6 family (`gpt-5.6` / `gpt-5.6-terra` / + `gpt-5.6-luna`) and GLM-5.3 (`glm-5.3`, `glm-5.3-flash`). The default models + are now `gpt-5.6` (deep) and `gpt-5.6-luna` (quick). + +### Contributors + +Thanks to everyone who reported these or sent a fix: + +[@PyriteResearch](https://github.com/PyriteResearch), [@yiran1268](https://github.com/yiran1268), [@fabiolenine](https://github.com/fabiolenine), [@lx7720](https://github.com/lx7720), [@taro0915](https://github.com/taro0915), [@Jaswanth-Sriram-Veturi](https://github.com/Jaswanth-Sriram-Veturi), [@ariesy](https://github.com/ariesy), [@liangzj1999](https://github.com/liangzj1999), [@zkwang616](https://github.com/zkwang616), [@aniketshukla1](https://github.com/aniketshukla1), [@loulanyue](https://github.com/loulanyue), [@hudsonwa](https://github.com/hudsonwa), [@daleselaji-dev](https://github.com/daleselaji-dev), [@wolfoswald777-crypto](https://github.com/wolfoswald777-crypto). + ## [0.3.1] — 2026-07-05 Correctness and stability patch: data look-ahead, graph-router crash-safety, diff --git a/README.md b/README.md index def85acbe..505b69df4 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ # TradingAgents: Multi-Agents LLM Financial Trading Framework ## News -- [2026-07] **TradingAgents v0.3.1** released with correctness and stability fixes: Alpha Vantage look-ahead filtering, graph-router crash-safety, graph-shape-aware checkpoint resume, working crypto sentiment sources, a configurable LLM retry budget, Bedrock API-key auth, and Claude Sonnet 5 / Fable 5 support. See [CHANGELOG.md](CHANGELOG.md) for the full list. +- [2026-08] **TradingAgents v0.4.0** released with look-ahead / point-in-time fixes across FRED macro, social sentiment, and the decision-log memory; clearer decision signals; working CLI checkpoint resume; Trader price grounding; and the GPT-5.6 and GLM-5.3 models. See [CHANGELOG.md](CHANGELOG.md) for the full list. +- [2026-07] **TradingAgents v0.3.1** released with correctness and stability fixes: Alpha Vantage look-ahead filtering, graph-router crash-safety, graph-shape-aware checkpoint resume, working crypto sentiment sources, a configurable LLM retry budget, Bedrock API-key auth, and Claude Sonnet 5 / Fable 5 support. - [2026-06] **TradingAgents v0.3.0** released with a verified data-access contract, an expanded provider registry (NVIDIA, Kimi, Groq, Mistral, Bedrock, and any OpenAI-compatible endpoint), FRED and Polymarket data vendors, a current-generation model catalog, and a CI gate. - [2026-05] **TradingAgents v0.2.5** released with the grounded Sentiment Analyst, GPT-5.5 etc. model coverage, Qwen/GLM/MiniMax dual-region support, `TRADINGAGENTS_*` env-var configurability with API-key auto-detection, remote Ollama support, non-US alpha benchmarks, and ticker path-traversal hardening. - [2026-04] **TradingAgents v0.2.4** released with structured-output agents (Research Manager, Trader, Portfolio Manager), LangGraph checkpoint resume, persistent decision log, DeepSeek/Qwen/GLM/Azure provider support, Docker, and a Windows UTF-8 encoding fix. @@ -222,8 +223,8 @@ from tradingagents.default_config import DEFAULT_CONFIG config = DEFAULT_CONFIG.copy() config["llm_provider"] = "openai" # e.g. openai, google, anthropic, deepseek, groq, ollama; openai_compatible covers any OpenAI-compatible endpoint (vLLM, LM Studio, llama.cpp, ...) -config["deep_think_llm"] = "gpt-5.5" # Model for complex reasoning -config["quick_think_llm"] = "gpt-5.4-mini" # Model for quick tasks +config["deep_think_llm"] = "gpt-5.6" # Model for complex reasoning +config["quick_think_llm"] = "gpt-5.6-luna" # Model for quick tasks config["max_debate_rounds"] = 2 ta = TradingAgentsGraph(debug=True, config=config) diff --git a/cli/main.py b/cli/main.py index 2e0c41d0d..b64487367 100644 --- a/cli/main.py +++ b/cli/main.py @@ -1127,109 +1127,130 @@ def run_analysis(checkpoint: bool | None = None): # (LLM tracking is handled separately via LLM constructor) args = graph.propagator.get_graph_args(callbacks=[stats_handler]) - # Stream the analysis + # Recompile with a checkpointer and inject the thread_id so --checkpoint + # actually saves and resumes on the CLI path (#1249); a no-op when + # checkpointing is disabled. Torn down in the finally below. + checkpoint_tid = graph.begin_checkpoint( + selections["ticker"], selections["analysis_date"], selections["asset_type"] + ) + if checkpoint_tid is not None: + args.setdefault("config", {}).setdefault("configurable", {})["thread_id"] = checkpoint_tid + + # Stream the analysis. On resume, feed None so LangGraph continues the + # interrupted run instead of re-appending the initial state (#1249); the + # try/finally tears the checkpointer down even if the stream raises. trace = [] - for chunk in graph.graph.stream(init_agent_state, **args): - # Process all messages in chunk, deduplicating by message ID - for message in chunk.get("messages", []): - msg_id = getattr(message, "id", None) - if msg_id is not None: - if msg_id in message_buffer._processed_message_ids: - continue - message_buffer._processed_message_ids.add(msg_id) + try: + for chunk in graph.graph.stream(graph.checkpoint_input(init_agent_state), **args): + # Process all messages in chunk, deduplicating by message ID + for message in chunk.get("messages", []): + msg_id = getattr(message, "id", None) + if msg_id is not None: + if msg_id in message_buffer._processed_message_ids: + continue + message_buffer._processed_message_ids.add(msg_id) - msg_type, content = classify_message_type(message) - if content and content.strip(): - message_buffer.add_message(msg_type, content) + msg_type, content = classify_message_type(message) + if content and content.strip(): + message_buffer.add_message(msg_type, content) - if hasattr(message, "tool_calls") and message.tool_calls: - for tool_call in message.tool_calls: - if isinstance(tool_call, dict): - message_buffer.add_tool_call(tool_call["name"], tool_call["args"]) - else: - message_buffer.add_tool_call(tool_call.name, tool_call.args) + if hasattr(message, "tool_calls") and message.tool_calls: + for tool_call in message.tool_calls: + if isinstance(tool_call, dict): + message_buffer.add_tool_call(tool_call["name"], tool_call["args"]) + else: + message_buffer.add_tool_call(tool_call.name, tool_call.args) - # Update analyst statuses based on report state (runs on every chunk) - update_analyst_statuses( - message_buffer, - chunk, - wall_time_tracker=analyst_wall_time_tracker, - ) - - # Research Team - Handle Investment Debate State - if chunk.get("investment_debate_state"): - debate_state = chunk["investment_debate_state"] - bull_hist = debate_state.get("bull_history", "").strip() - bear_hist = debate_state.get("bear_history", "").strip() - judge = debate_state.get("judge_decision", "").strip() - - # Only update status when there's actual content - if bull_hist or bear_hist: - update_research_team_status("in_progress") - if bull_hist: - message_buffer.update_report_section( - "investment_plan", f"### Bull Researcher Analysis\n{bull_hist}" - ) - if bear_hist: - message_buffer.update_report_section( - "investment_plan", f"### Bear Researcher Analysis\n{bear_hist}" - ) - if judge: - message_buffer.update_report_section( - "investment_plan", f"### Research Manager Decision\n{judge}" - ) - update_research_team_status("completed") - message_buffer.update_agent_status("Trader", "in_progress") - - # Trading Team - if chunk.get("trader_investment_plan"): - message_buffer.update_report_section( - "trader_investment_plan", chunk["trader_investment_plan"] + # Update analyst statuses based on report state (runs on every chunk) + update_analyst_statuses( + message_buffer, + chunk, + wall_time_tracker=analyst_wall_time_tracker, ) - if message_buffer.agent_status.get("Trader") != "completed": - message_buffer.update_agent_status("Trader", "completed") - message_buffer.update_agent_status("Aggressive Analyst", "in_progress") - # Risk Management Team - Handle Risk Debate State - if chunk.get("risk_debate_state"): - risk_state = chunk["risk_debate_state"] - agg_hist = risk_state.get("aggressive_history", "").strip() - con_hist = risk_state.get("conservative_history", "").strip() - neu_hist = risk_state.get("neutral_history", "").strip() - judge = risk_state.get("judge_decision", "").strip() + # Research Team - Handle Investment Debate State + if chunk.get("investment_debate_state"): + debate_state = chunk["investment_debate_state"] + bull_hist = debate_state.get("bull_history", "").strip() + bear_hist = debate_state.get("bear_history", "").strip() + judge = debate_state.get("judge_decision", "").strip() - if agg_hist: - if message_buffer.agent_status.get("Aggressive Analyst") != "completed": + # Only update status when there's actual content + if bull_hist or bear_hist: + update_research_team_status("in_progress") + if bull_hist: + message_buffer.update_report_section( + "investment_plan", f"### Bull Researcher Analysis\n{bull_hist}" + ) + if bear_hist: + message_buffer.update_report_section( + "investment_plan", f"### Bear Researcher Analysis\n{bear_hist}" + ) + if judge: + message_buffer.update_report_section( + "investment_plan", f"### Research Manager Decision\n{judge}" + ) + update_research_team_status("completed") + message_buffer.update_agent_status("Trader", "in_progress") + + # Trading Team + if chunk.get("trader_investment_plan"): + message_buffer.update_report_section( + "trader_investment_plan", chunk["trader_investment_plan"] + ) + if message_buffer.agent_status.get("Trader") != "completed": + message_buffer.update_agent_status("Trader", "completed") message_buffer.update_agent_status("Aggressive Analyst", "in_progress") - message_buffer.update_report_section( - "final_trade_decision", f"### Aggressive Analyst Analysis\n{agg_hist}" - ) - if con_hist: - if message_buffer.agent_status.get("Conservative Analyst") != "completed": - message_buffer.update_agent_status("Conservative Analyst", "in_progress") - message_buffer.update_report_section( - "final_trade_decision", f"### Conservative Analyst Analysis\n{con_hist}" - ) - if neu_hist: - if message_buffer.agent_status.get("Neutral Analyst") != "completed": - message_buffer.update_agent_status("Neutral Analyst", "in_progress") - message_buffer.update_report_section( - "final_trade_decision", f"### Neutral Analyst Analysis\n{neu_hist}" - ) - if judge and message_buffer.agent_status.get("Portfolio Manager") != "completed": - message_buffer.update_agent_status("Portfolio Manager", "in_progress") - message_buffer.update_report_section( - "final_trade_decision", f"### Portfolio Manager Decision\n{judge}" - ) - message_buffer.update_agent_status("Aggressive Analyst", "completed") - message_buffer.update_agent_status("Conservative Analyst", "completed") - message_buffer.update_agent_status("Neutral Analyst", "completed") - message_buffer.update_agent_status("Portfolio Manager", "completed") - # Update the display - update_display(layout, stats_handler=stats_handler, start_time=start_time) + # Risk Management Team - Handle Risk Debate State + if chunk.get("risk_debate_state"): + risk_state = chunk["risk_debate_state"] + agg_hist = risk_state.get("aggressive_history", "").strip() + con_hist = risk_state.get("conservative_history", "").strip() + neu_hist = risk_state.get("neutral_history", "").strip() + judge = risk_state.get("judge_decision", "").strip() - trace.append(chunk) + if agg_hist: + if message_buffer.agent_status.get("Aggressive Analyst") != "completed": + message_buffer.update_agent_status("Aggressive Analyst", "in_progress") + message_buffer.update_report_section( + "final_trade_decision", f"### Aggressive Analyst Analysis\n{agg_hist}" + ) + if con_hist: + if message_buffer.agent_status.get("Conservative Analyst") != "completed": + message_buffer.update_agent_status("Conservative Analyst", "in_progress") + message_buffer.update_report_section( + "final_trade_decision", f"### Conservative Analyst Analysis\n{con_hist}" + ) + if neu_hist: + if message_buffer.agent_status.get("Neutral Analyst") != "completed": + message_buffer.update_agent_status("Neutral Analyst", "in_progress") + message_buffer.update_report_section( + "final_trade_decision", f"### Neutral Analyst Analysis\n{neu_hist}" + ) + if judge and message_buffer.agent_status.get("Portfolio Manager") != "completed": + message_buffer.update_agent_status("Portfolio Manager", "in_progress") + message_buffer.update_report_section( + "final_trade_decision", f"### Portfolio Manager Decision\n{judge}" + ) + message_buffer.update_agent_status("Aggressive Analyst", "completed") + message_buffer.update_agent_status("Conservative Analyst", "completed") + message_buffer.update_agent_status("Neutral Analyst", "completed") + message_buffer.update_agent_status("Portfolio Manager", "completed") + + # Update the display + update_display(layout, stats_handler=stats_handler, start_time=start_time) + + trace.append(chunk) + + # Clean run: drop this run's checkpoint so a later run starts fresh. + # A mid-stream failure skips this, keeping the checkpoint for resume. + graph.clear_checkpoint_on_success( + selections["ticker"], selections["analysis_date"], selections["asset_type"] + ) + finally: + # Always restore the plain uncheckpointed graph, even on failure. + graph.end_checkpoint() # Streamed chunks are per-node deltas, not full state. Merge them # so every report field populated across the run is present. diff --git a/pyproject.toml b/pyproject.toml index c3021a5bf..b6e2974a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tradingagents" -version = "0.3.1" +version = "0.4.0" description = "TradingAgents: Multi-Agents LLM Financial Trading Framework" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index debbb756f..2ab28dc2d 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -116,6 +116,37 @@ class TestDefault: assert caps.supports_tool_choice is True +@pytest.mark.unit +class TestOpenRouterDeepSeekNamespace: + """OpenRouter namespaces DeepSeek as ``deepseek/``; strip it so the + same quirks apply as the native provider (#1199).""" + + def test_prefixed_v4_flash_suppresses_tool_choice(self): + # Was falling through to _DEFAULT (tool_choice on) -> slow object-form call. + assert get_capabilities("deepseek/deepseek-v4-flash").supports_tool_choice is False + + def test_prefixed_reasoner_suppresses_tool_choice(self): + assert get_capabilities("deepseek/deepseek-reasoner").supports_tool_choice is False + + def test_prefixed_chat_selects_deepseek_chat_not_default(self): + # Must resolve to _DEEPSEEK_CHAT, not _DEFAULT: supports_json_schema=False + # is what distinguishes them (both keep tool_choice). + caps = get_capabilities("deepseek/deepseek-chat") + assert caps.supports_tool_choice is True + assert caps.supports_json_schema is False # _DEEPSEEK_CHAT, not _DEFAULT + + def test_only_official_namespace_is_stripped(self): + # A third-party publisher whose model name WOULD match a deepseek pattern + # must stay _DEFAULT: proves we strip only "deepseek/", not any "*/". + caps = get_capabilities("tngtech/deepseek-v4-flash") + assert caps.supports_tool_choice is True # not thinking + assert caps.supports_json_schema is True # _DEFAULT + + def test_native_ids_unchanged(self): + assert get_capabilities("deepseek-v4-flash").supports_tool_choice is False + assert get_capabilities("deepseek-chat").supports_tool_choice is True + + @pytest.mark.unit def test_capabilities_dataclass_is_frozen(): """Capability rows are immutable so they can be safely shared.""" diff --git a/tests/test_checkpoint_lifecycle.py b/tests/test_checkpoint_lifecycle.py new file mode 100644 index 000000000..1e5e2adf5 --- /dev/null +++ b/tests/test_checkpoint_lifecycle.py @@ -0,0 +1,155 @@ +"""The checkpoint lifecycle is reusable so --checkpoint works on the CLI path (#1249). + +Checkpoint setup previously lived only inside ``propagate``; the CLI streamed the +checkpointer-less graph, so ``--checkpoint`` neither saved nor resumed. The +lifecycle is now ``begin_checkpoint`` / ``end_checkpoint`` / +``clear_checkpoint_on_success`` on TradingAgentsGraph, used by both paths. These +tests drive that lifecycle exactly as the CLI does (begin -> stream self.graph -> +clear/end) and prove state is saved and resumed. +""" +from __future__ import annotations + +import tempfile +from typing import TypedDict + +import pytest +from langgraph.graph import END, StateGraph + +from tradingagents.graph.checkpointer import checkpoint_step +from tradingagents.graph.trading_graph import TradingAgentsGraph + +_should_crash = False + + +class _State(TypedDict): + count: int + + +def _node_a(state: _State) -> dict: + return {"count": state["count"] + 1} + + +def _node_b(state: _State) -> dict: + if _should_crash: + raise RuntimeError("simulated mid-stream crash") + return {"count": state["count"] + 10} + + +def _workflow() -> StateGraph: + b = StateGraph(_State) + b.add_node("analyst", _node_a) + b.add_node("trader", _node_b) + b.set_entry_point("analyst") + b.add_edge("analyst", "trader") + b.add_edge("trader", END) + return b + + +def _bare_graph(tmpdir, *, enabled=True): + g = object.__new__(TradingAgentsGraph) + g.config = { + "checkpoint_enabled": enabled, "data_cache_dir": tmpdir, + "max_debate_rounds": 1, "max_risk_discuss_rounds": 1, + } + g.selected_analysts = ("market",) + g.workflow = _workflow() + g.graph = g.workflow.compile() + g._checkpointer_ctx = None + return g + + + + +@pytest.mark.unit +def test_disabled_is_a_noop(): + with tempfile.TemporaryDirectory() as tmp: + g = _bare_graph(tmp, enabled=False) + plain = g.graph + assert g.begin_checkpoint("AAPL", "2026-05-08", "stock") is None + assert g.graph is plain # graph not recompiled + g.end_checkpoint() # safe no-op + + +@pytest.mark.unit +def test_begin_returns_thread_id_and_recompiles(): + with tempfile.TemporaryDirectory() as tmp: + g = _bare_graph(tmp) + plain = g.graph + tid = g.begin_checkpoint("AAPL", "2026-05-08", "stock") + try: + assert tid # a real thread_id + assert g.graph is not plain # recompiled with a checkpointer + finally: + g.end_checkpoint() + assert g._checkpointer_ctx is None # restored + + +@pytest.mark.unit +def test_checkpoint_input_is_none_only_when_resuming(): + global _should_crash + with tempfile.TemporaryDirectory() as tmp: + init = {"count": 0} + args = ("AAPL", "2026-05-08", "stock") + # Fresh run: no checkpoint yet -> stream the initial state, then crash. + g1 = _bare_graph(tmp) + tid = g1.begin_checkpoint(*args) + try: + assert g1._resuming is False + assert g1.checkpoint_input(init) is init # not resuming -> initial state + _should_crash = True + with pytest.raises(RuntimeError): + for _ in g1.graph.stream(init, config={"configurable": {"thread_id": tid}}): + pass + finally: + g1.end_checkpoint() + assert g1.checkpoint_input(init) is init # reset after teardown + + # A later run finds the checkpoint -> resume by feeding None, not the + # initial state (re-passing it would duplicate messages, #1249). + _should_crash = False + g2 = _bare_graph(tmp) + g2.begin_checkpoint(*args) + try: + assert g2._resuming is True + assert g2.checkpoint_input(init) is None + finally: + g2.end_checkpoint() + + +@pytest.mark.unit +def test_cli_style_usage_saves_then_resumes(): + global _should_crash + with tempfile.TemporaryDirectory() as tmp: + cfg_args = ("AAPL", "2026-05-08", "stock") + + # Run 1 (the CLI path): begin -> stream self.graph -> crash at 'trader'. + _should_crash = True + g1 = _bare_graph(tmp) + tid = g1.begin_checkpoint(*cfg_args) + args = {"config": {"configurable": {"thread_id": tid}}} + try: + with pytest.raises(RuntimeError): + for _ in g1.graph.stream({"count": 0}, **args): + pass + finally: + g1.end_checkpoint() + + # A checkpoint was saved for this run signature (so --checkpoint works). + + sig = g1._run_signature("stock") + assert checkpoint_step(tmp, "AAPL", "2026-05-08", sig) is not None + + # Run 2 (fresh graph, as a new CLI invocation): resume and finish. + _should_crash = False + g2 = _bare_graph(tmp) + tid2 = g2.begin_checkpoint(*cfg_args) + assert tid2 == tid # stable id -> same thread resumes + try: + result = g2.graph.invoke(None, config={"configurable": {"thread_id": tid2}}) + assert result["count"] == 11 # analyst(+1) resumed into trader(+10) + g2.clear_checkpoint_on_success(*cfg_args) + finally: + g2.end_checkpoint() + + # Cleared on success -> a later run starts fresh. + assert checkpoint_step(tmp, "AAPL", "2026-05-08", sig) is None diff --git a/tests/test_debate_opening.py b/tests/test_debate_opening.py new file mode 100644 index 000000000..a20a08b8a --- /dev/null +++ b/tests/test_debate_opening.py @@ -0,0 +1,111 @@ +"""The first speaker in each debate must not rebut a nonexistent argument (#1176). + +Each debate round's opening speaker receives an empty opponent response; the +prompt used to interpolate it into a "refute the opponent" instruction, so models +fabricated the other side's position. All five debators (bull, bear, and the +three risk analysts) now substitute an explicit opening marker when the opponent +has not spoken, and pass a real argument through unchanged. +""" +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from tradingagents.agents.researchers.bear_researcher import create_bear_researcher +from tradingagents.agents.researchers.bull_researcher import create_bull_researcher +from tradingagents.agents.risk_mgmt.aggressive_debator import create_aggressive_debator +from tradingagents.agents.risk_mgmt.conservative_debator import create_conservative_debator +from tradingagents.agents.risk_mgmt.neutral_debator import create_neutral_debator +from tradingagents.agents.utils.agent_utils import opponent_argument_or_opening + +_REPORTS = { + "company_of_interest": "AAPL", "asset_type": "stock", + "market_report": "m", "sentiment_report": "s", + "news_report": "n", "fundamentals_report": "f", +} + + +def _capturing_llm(captured: dict): + llm = MagicMock() + llm.invoke.side_effect = lambda prompt: ( + captured.__setitem__("prompt", prompt) or MagicMock(content="argument") + ) + return llm + + +def _investment_state(current_response): + return { + **_REPORTS, + "count": 0, + "investment_debate_state": { + "history": "", "bull_history": "", "bear_history": "", + "current_response": current_response, "count": 0, + }, + } + + +def _risk_state(**responses): + base = { + "current_aggressive_response": "", "current_conservative_response": "", + "current_neutral_response": "", "history": "", "aggressive_history": "", + "conservative_history": "", "neutral_history": "", "count": 0, + } + base.update(responses) + return {**_REPORTS, "trader_investment_plan": "plan", "risk_debate_state": base} + + +# --- shared helper ---------------------------------------------------------- + +@pytest.mark.unit +def test_helper_marks_empty_and_passes_through(): + assert "has not spoken yet" in opponent_argument_or_opening("", "bear analyst") + assert opponent_argument_or_opening(" real point ", "bear") == "real point" + + +# --- researchers ------------------------------------------------------------ + +@pytest.mark.unit +@pytest.mark.parametrize( + "factory,opponent", + [(create_bull_researcher, "bear"), (create_bear_researcher, "bull")], +) +def test_researcher_opening_has_no_phantom_opponent(factory, opponent): + captured = {} + factory(_capturing_llm(captured))(_investment_state("")) + assert "has not spoken yet" in captured["prompt"] + + +@pytest.mark.unit +def test_researcher_passes_real_opponent_argument(): + captured = {} + state = _investment_state("Bear Analyst: valuation is stretched") + create_bull_researcher(_capturing_llm(captured))(state) + assert "valuation is stretched" in captured["prompt"] + assert "has not spoken yet" not in captured["prompt"] + + +# --- risk debators ---------------------------------------------------------- + +@pytest.mark.unit +@pytest.mark.parametrize( + "factory", [create_aggressive_debator, create_conservative_debator, create_neutral_debator] +) +def test_risk_opening_has_no_phantom_opponent(factory): + captured = {} + factory(_capturing_llm(captured))(_risk_state()) + # Both opponent slots were empty -> two opening markers, no fabricated args. + assert captured["prompt"].count("has not spoken yet") == 2 + + +@pytest.mark.unit +def test_risk_passes_real_opponent_arguments(): + captured = {} + state = _risk_state( + current_conservative_response="Conservative Analyst: trim risk", + current_neutral_response="Neutral Analyst: hold steady", + ) + create_aggressive_debator(_capturing_llm(captured))(state) + assert "trim risk" in captured["prompt"] + assert "hold steady" in captured["prompt"] + assert "has not spoken yet" not in captured["prompt"] diff --git a/tests/test_env_overrides.py b/tests/test_env_overrides.py index b9fd59cbf..16f286f67 100644 --- a/tests/test_env_overrides.py +++ b/tests/test_env_overrides.py @@ -21,8 +21,8 @@ def _reload_with_env(monkeypatch, **overrides): 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.5" - assert dc.DEFAULT_CONFIG["quick_think_llm"] == "gpt-5.4-mini" + assert dc.DEFAULT_CONFIG["deep_think_llm"] == "gpt-5.6" + assert dc.DEFAULT_CONFIG["quick_think_llm"] == "gpt-5.6-luna" assert dc.DEFAULT_CONFIG["backend_url"] is None assert dc.DEFAULT_CONFIG["max_debate_rounds"] == 1 assert dc.DEFAULT_CONFIG["checkpoint_enabled"] is False diff --git a/tests/test_fred.py b/tests/test_fred.py index 367c28feb..2a03f6456 100644 --- a/tests/test_fred.py +++ b/tests/test_fred.py @@ -150,6 +150,23 @@ class FredFormattingTests(unittest.TestCase): self.assertEqual(obs_params["observation_end"], "2025-09-30") self.assertEqual(obs_params["observation_start"], "2025-07-02") # 90d back + def test_requests_pin_the_data_vintage(self): + # #1275: both the metadata and observations requests must set + # realtime_start=realtime_end=curr_date, or FRED serves the latest + # revision and revision-prone series leak future information. + captured = {} + + def _capture(path, params): + captured[path] = params + return _META if path == "series" else _OBS + + with mock.patch.object(fred, "_request", side_effect=_capture): + fred.get_macro_data("cpi", "2025-09-30", 90) + + for path in ("series", "series/observations"): + self.assertEqual(captured[path]["realtime_start"], "2025-09-30", path) + self.assertEqual(captured[path]["realtime_end"], "2025-09-30", path) + @pytest.mark.unit class FredRoutingTests(unittest.TestCase): diff --git a/tests/test_llm_max_tokens.py b/tests/test_llm_max_tokens.py new file mode 100644 index 000000000..87cddba52 --- /dev/null +++ b/tests/test_llm_max_tokens.py @@ -0,0 +1,122 @@ +"""Configurable output-token cap (#1204). + +Some model/gateway combinations (e.g. deepseek-v4-flash deployments) emit +unbounded reasoning/output and hang or trip an idle timeout. An opt-in +``max_tokens`` config knob is forwarded to every provider so a run can bound it; +Gemini names the parameter ``max_output_tokens``, so it is forwarded under the +right key per provider. +""" +from __future__ import annotations + +import importlib + +import pytest + +import tradingagents.default_config as default_config_module +from tradingagents.graph.trading_graph import TradingAgentsGraph, _coerce_max_tokens + +# --- coercion / validation ------------------------------------------------- + +@pytest.mark.unit +@pytest.mark.parametrize("value,expected", [(1, 1), (8192, 8192), ("4096", 4096)]) +def test_coerce_accepts_positive_ints_and_numeric_strings(value, expected): + assert _coerce_max_tokens(value) == expected + + +@pytest.mark.unit +@pytest.mark.parametrize("bad", [0, -1, "0", "-5"]) +def test_coerce_rejects_non_positive(bad): + with pytest.raises(ValueError, match="> 0"): + _coerce_max_tokens(bad) + + +@pytest.mark.unit +@pytest.mark.parametrize("bad", [True, False]) +def test_coerce_rejects_booleans(bad): + with pytest.raises(ValueError, match="boolean"): + _coerce_max_tokens(bad) + + +@pytest.mark.unit +@pytest.mark.parametrize("bad", ["abc", "1.5", None]) +def test_coerce_rejects_non_integers(bad): + with pytest.raises(ValueError, match="integer"): + _coerce_max_tokens(bad) + + +# --- forwarding into provider kwargs (right key per provider) -------------- + +def _bare_graph(config): + g = object.__new__(TradingAgentsGraph) + g.config = config + return g + + +@pytest.mark.unit +def test_not_forwarded_when_unset(): + kwargs = _bare_graph({"llm_provider": "openai", "max_tokens": None})._get_provider_kwargs() + assert "max_tokens" not in kwargs + assert "max_output_tokens" not in kwargs + + +@pytest.mark.unit +@pytest.mark.parametrize("provider", ["openai", "anthropic", "deepseek", "openai_compatible"]) +def test_forwarded_as_max_tokens_for_non_google(provider): + kwargs = _bare_graph({"llm_provider": provider, "max_tokens": 8192})._get_provider_kwargs() + assert kwargs["max_tokens"] == 8192 + assert "max_output_tokens" not in kwargs + + +@pytest.mark.unit +def test_forwarded_as_max_output_tokens_for_google(): + # Gemini's kwarg name differs; forwarding plain max_tokens would be rejected. + kwargs = _bare_graph({"llm_provider": "google", "max_tokens": 8192})._get_provider_kwargs() + assert kwargs["max_output_tokens"] == 8192 + assert "max_tokens" not in kwargs + + +@pytest.mark.unit +def test_env_string_is_coerced(): + kwargs = _bare_graph({"llm_provider": "openai", "max_tokens": "4096"})._get_provider_kwargs() + assert kwargs["max_tokens"] == 4096 + + +@pytest.mark.unit +def test_invalid_value_fails_loudly(): + with pytest.raises(ValueError): + _bare_graph({"llm_provider": "openai", "max_tokens": 0})._get_provider_kwargs() + + +# --- client-side allowlists carry the kwarg -------------------------------- + +@pytest.mark.unit +def test_openai_and_google_clients_accept_the_kwarg(): + from tradingagents.llm_clients import openai_client + from tradingagents.llm_clients.google_client import GoogleClient # noqa: F401 + assert "max_tokens" in openai_client._PASSTHROUGH_KWARGS + # Google client forwards max_output_tokens through construction. + llm = GoogleClient("gemini-3.5-flash", api_key="x", max_output_tokens=8192).get_llm() + assert getattr(llm, "max_output_tokens", None) == 8192 + + +# --- env overlay ----------------------------------------------------------- + +def _reload_with_env(monkeypatch, **overrides): + 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) + + +@pytest.mark.unit +def test_default_is_none(monkeypatch): + dc = _reload_with_env(monkeypatch) + assert dc.DEFAULT_CONFIG["max_tokens"] is None + + +@pytest.mark.unit +def test_env_override_sets_config(monkeypatch): + dc = _reload_with_env(monkeypatch, TRADINGAGENTS_MAX_TOKENS="8192") + assert dc.DEFAULT_CONFIG["max_tokens"] == "8192" + assert _coerce_max_tokens(dc.DEFAULT_CONFIG["max_tokens"]) == 8192 diff --git a/tests/test_memory_log.py b/tests/test_memory_log.py index 69fdb8047..3db0ac531 100644 --- a/tests/test_memory_log.py +++ b/tests/test_memory_log.py @@ -54,9 +54,14 @@ def _resolve_entry(log, ticker, date, decision, reflection="Good call."): log.update_with_outcome(ticker, date, 0.05, 0.02, 5, reflection) -def _price_df(prices): - """Minimal DataFrame matching yfinance .history() output shape.""" - return pd.DataFrame({"Close": prices}) +def _price_df(prices, start="2026-01-05"): + """Minimal DataFrame matching yfinance .history() output shape. + + Uses a DatetimeIndex like real yfinance output, so resolution-date + extraction (stock.index[holding_days]) works (#1251). + """ + idx = pd.date_range(start=start, periods=len(prices), freq="D") + return pd.DataFrame({"Close": prices}, index=idx) def _make_pm_state(past_context=""): @@ -496,35 +501,38 @@ class TestDeferredReflection: m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices) return m mock_ticker_cls.side_effect = _make_ticker - raw, alpha, days = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-01-05") + raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-01-05") assert raw is not None and alpha is not None and days is not None assert isinstance(raw, float) and isinstance(alpha, float) and isinstance(days, int) assert days == 5 + # resolution date = the bar `days` sessions after the trade date (#1251) + assert resolved == "2026-01-10" def test_fetch_returns_too_recent(self): - """Only 1 data point available → returns (None, None, None), no crash.""" + """Only 1 data point available → returns all-None, no crash.""" mock_graph = MagicMock(spec=TradingAgentsGraph) with patch("yfinance.Ticker") as mock_ticker_cls: m = MagicMock() m.history.return_value = _price_df([100.0]) mock_ticker_cls.return_value = m - raw, alpha, days = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-04-19") - assert raw is None and alpha is None and days is None + raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-04-19") + assert (raw, alpha, days, resolved) == (None, None, None, None) def test_fetch_returns_delisted(self): - """Empty DataFrame → returns (None, None, None), no crash.""" + """Empty DataFrame → returns all-None, no crash.""" mock_graph = MagicMock(spec=TradingAgentsGraph) with patch("yfinance.Ticker") as mock_ticker_cls: m = MagicMock() m.history.return_value = pd.DataFrame({"Close": []}) mock_ticker_cls.return_value = m - raw, alpha, days = TradingAgentsGraph._fetch_returns(mock_graph, "XXXXXFAKE", "2026-01-10") - assert raw is None and alpha is None and days is None + raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "XXXXXFAKE", "2026-01-10") + assert (raw, alpha, days, resolved) == (None, None, None, None) def test_fetch_returns_spy_shorter_than_stock(self): - """SPY having fewer rows than the stock must not raise IndexError.""" - stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0] - spy_prices = [400.0, 402.0, 403.0] + """SPY having fewer rows than the stock (but still a full window) must + not raise IndexError.""" + stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0, 107.0, 108.0] # 8 rows + spy_prices = [400.0, 402.0, 403.0, 405.0, 406.0, 407.0] # 6 rows mock_graph = MagicMock(spec=TradingAgentsGraph) with patch("yfinance.Ticker") as mock_ticker_cls: def _make_ticker(sym): @@ -532,9 +540,26 @@ class TestDeferredReflection: m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices) return m mock_ticker_cls.side_effect = _make_ticker - raw, alpha, days = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-01-05") - assert raw is not None and alpha is not None and days is not None - assert days == 2 + raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-01-05") + assert raw is not None and alpha is not None + assert days == 5 # full holding window used for both series + assert resolved == "2026-01-10" + + def test_fetch_returns_incomplete_window_stays_pending(self): + """#1169: a rerun before the full holding window has traded returns + unavailable (all-None) so the entry stays pending, rather than settling + on a premature partial return.""" + stock_prices = [100.0, 102.0, 104.0] # only 3 rows; holding window is 5 + spy_prices = [400.0, 402.0, 404.0] + mock_graph = MagicMock(spec=TradingAgentsGraph) + with patch("yfinance.Ticker") as mock_ticker_cls: + def _make_ticker(sym): + m = MagicMock() + m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices) + return m + mock_ticker_cls.side_effect = _make_ticker + result = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-01-05") + assert result == (None, None, None, None) # TradingAgentsGraph._resolve_benchmark — picks index for alpha calc @@ -641,7 +666,7 @@ class TestDeferredReflection: log.store_decision("AAPL", "2026-01-10", DECISION_BUY) mock_graph = MagicMock(spec=TradingAgentsGraph) mock_graph.memory_log = log - mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5)) + mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5, "2026-01-12")) TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA") mock_graph._fetch_returns.assert_not_called() assert len(log.get_pending_entries()) == 1 @@ -655,7 +680,7 @@ class TestDeferredReflection: mock_graph = MagicMock(spec=TradingAgentsGraph) mock_graph.memory_log = log mock_graph.reflector = mock_reflector - mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5)) + mock_graph._fetch_returns = MagicMock(return_value=(0.05, 0.02, 5, "2026-01-12")) TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA") assert log.get_pending_entries() == [] entries = log.load_entries() @@ -665,6 +690,20 @@ class TestDeferredReflection: assert "+5.0%" in entries[0]["raw"] assert "+2.0%" in entries[0]["alpha"] + def test_resolve_leaves_premature_entry_pending(self, tmp_path): + """#1169: when the outcome can't be settled yet (_fetch_returns None), + the entry stays pending and the reflector is never called.""" + log = make_log(tmp_path) + log.store_decision("NVDA", "2026-01-05", DECISION_BUY) + mock_reflector = MagicMock() + mock_graph = MagicMock(spec=TradingAgentsGraph) + mock_graph.memory_log = log + mock_graph.reflector = mock_reflector + mock_graph._fetch_returns = MagicMock(return_value=(None, None, None, None)) + TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA") + assert len(log.get_pending_entries()) == 1 # still pending + mock_reflector.reflect_on_final_decision.assert_not_called() + # --------------------------------------------------------------------------- # Portfolio Manager injection: past_context in state and prompt diff --git a/tests/test_memory_pointintime.py b/tests/test_memory_pointintime.py new file mode 100644 index 000000000..8d312c7c5 --- /dev/null +++ b/tests/test_memory_pointintime.py @@ -0,0 +1,95 @@ +"""Memory-log lessons must be point-in-time safe in a backtest (#1251). + +get_past_context previously returned every resolved lesson regardless of the run +date, so a historical run could learn from an outcome that had not happened yet. +Resolved entries now record the date their outcome became known (``resolved:``), +and get_past_context(as_of=...) filters on it. Legacy entries without a +resolution date are excluded from a point-in-time query (conservative migration). +""" +from __future__ import annotations + +import pytest + +from tradingagents.agents.utils.memory import TradingMemoryLog + + +def _log(tmp_path): + return TradingMemoryLog({"memory_log_path": str(tmp_path / "mem.md")}) + + +def _resolve(log, ticker, date, resolution_date, reflection): + log.store_decision(ticker, date, f"Rating: Buy\n{reflection}") + log.update_with_outcome( + ticker, date, 0.05, 0.02, 5, reflection, resolution_date=resolution_date, + ) + + +@pytest.mark.unit +def test_resolution_date_is_stored_and_parsed(tmp_path): + log = _log(tmp_path) + _resolve(log, "NVDA", "2026-01-05", "2026-01-10", "outcome known 01-10") + entry = log.load_entries()[0] + assert entry["resolved"] == "2026-01-10" + assert "resolved:2026-01-10" in (tmp_path / "mem.md").read_text() + + +@pytest.mark.unit +def test_as_of_excludes_lessons_resolved_after_the_run_date(tmp_path): + log = _log(tmp_path) + # Decision on 01-05, outcome only known on 01-10. + _resolve(log, "NVDA", "2026-01-05", "2026-01-10", "great trade") + + # A run as-of 01-07 must NOT see it (the outcome was still in the future). + assert log.get_past_context("NVDA", as_of="2026-01-07") == "" + # A run as-of 01-10 (and later) sees it. + assert "great trade" in log.get_past_context("NVDA", as_of="2026-01-10") + assert "great trade" in log.get_past_context("NVDA", as_of="2026-02-01") + + +@pytest.mark.unit +def test_no_as_of_is_unfiltered_live_behavior(tmp_path): + log = _log(tmp_path) + _resolve(log, "NVDA", "2026-01-05", "2026-01-10", "great trade") + # Live run (no as_of): unchanged behavior, lesson is shown. + assert "great trade" in log.get_past_context("NVDA") + + +@pytest.mark.unit +def test_legacy_entry_without_resolution_date_excluded_in_backtest(tmp_path): + log = _log(tmp_path) + # Simulate a pre-migration resolved entry: no resolution_date recorded. + log.store_decision("NVDA", "2026-01-05", "Rating: Buy\nlegacy lesson") + log.update_with_outcome("NVDA", "2026-01-05", 0.05, 0.02, 5, "legacy lesson") + entry = log.load_entries()[0] + assert entry["resolved"] is None + + # Conservative: excluded from a point-in-time query (can't prove it was known)... + assert log.get_past_context("NVDA", as_of="2026-06-01") == "" + # ...but still available on a live (unfiltered) run. + assert "legacy lesson" in log.get_past_context("NVDA") + + +@pytest.mark.unit +def test_cross_ticker_lessons_are_also_gated(tmp_path): + log = _log(tmp_path) + _resolve(log, "AAPL", "2026-01-05", "2026-01-10", "cross lesson") + # Querying a different ticker as-of before resolution: no cross lesson leaks. + assert log.get_past_context("NVDA", as_of="2026-01-07") == "" + assert "cross lesson" in log.get_past_context("NVDA", as_of="2026-01-10") + + +@pytest.mark.unit +def test_memory_as_of_gates_historical_but_not_live(): + # The graph filters only for a past trade date; a current-date run passes + # None so live behavior and legacy entries are unaffected (#1251). + from datetime import datetime, timedelta + + from tradingagents.graph.trading_graph import TradingAgentsGraph + + g = object.__new__(TradingAgentsGraph) + past = "2024-01-01" + today = datetime.now().strftime("%Y-%m-%d") + future = (datetime.now() + timedelta(days=30)).strftime("%Y-%m-%d") + assert g._memory_as_of(past) == past # backtest -> filter on the trade date + assert g._memory_as_of(today) is None # live -> no filter + assert g._memory_as_of(future) is None # future-dated run -> no filter diff --git a/tests/test_news_lookahead.py b/tests/test_news_lookahead.py index c55f2ed8d..367566edc 100644 --- a/tests/test_news_lookahead.py +++ b/tests/test_news_lookahead.py @@ -11,6 +11,7 @@ from datetime import datetime, timezone import pytest import tradingagents.dataflows.yfinance_news as ynews +from tradingagents.dataflows.date_window import in_window def _epoch(date_str): @@ -36,16 +37,16 @@ def test_window_excludes_future_and_undated_in_backtest(): end = datetime(2025, 5, 9) # historical window (well in the past) inside = datetime(2025, 5, 5) future = datetime(2025, 6, 1) - assert ynews._in_news_window(inside, start, end) is True - assert ynews._in_news_window(future, start, end) is False # look-ahead blocked - assert ynews._in_news_window(None, start, end) is False # undated -> excluded in backtest + assert in_window(inside, start, end) is True + assert in_window(future, start, end) is False # look-ahead blocked + assert in_window(None, start, end) is False # undated -> excluded in backtest @pytest.mark.unit def test_window_keeps_undated_in_live_window(): # Live window (reaches today): undated articles can't be "future", so keep them. now = datetime.now(timezone.utc) - assert ynews._in_news_window(None, now, now) is True + assert in_window(None, now, now) is True @pytest.mark.unit @@ -56,8 +57,8 @@ def test_upper_bound_is_exclusive(): end = datetime(2025, 5, 9) midnight_after = datetime(2025, 5, 10, 0, 0, 0, tzinfo=timezone.utc) last_moment = datetime(2025, 5, 9, 23, 59, 59, tzinfo=timezone.utc) - assert ynews._in_news_window(midnight_after, start, end) is False - assert ynews._in_news_window(last_moment, start, end) is True + assert in_window(midnight_after, start, end) is False + assert in_window(last_moment, start, end) is True @pytest.mark.unit @@ -67,7 +68,7 @@ def test_offset_aware_timestamp_is_converted_not_truncated(): start = datetime(2025, 5, 1) end = datetime(2025, 5, 9) aware = datetime.fromisoformat("2025-05-10T01:00:00+05:00") - assert ynews._in_news_window(aware, start, end) is True + assert in_window(aware, start, end) is True @pytest.mark.unit diff --git a/tests/test_ohlcv_latest_bar.py b/tests/test_ohlcv_latest_bar.py new file mode 100644 index 000000000..7a26b0e00 --- /dev/null +++ b/tests/test_ohlcv_latest_bar.py @@ -0,0 +1,136 @@ +"""The latest trading day's bar must not silently vanish (#1201). + +yfinance can return the newest in-range bar with a NaN close (an unsettled or +glitched session). The old path parsed dates without normalizing timezone and +dropped every NaN-close row before applying the curr_date cutoff, so the latest +bar disappeared and the previous trading day looked like the latest. Now dates +are normalized, and a latest in-range bar with no close raises rather than +silently falling back. +""" +from __future__ import annotations + +import pandas as pd +import pytest + +from tradingagents.dataflows import stockstats_utils as su +from tradingagents.dataflows.symbol_utils import NoMarketDataError + +# --- date normalization ----------------------------------------------------- + +@pytest.mark.unit +def test_normalize_dates_strips_tz_and_normalizes_to_midnight(): + aware = pd.Series(pd.to_datetime( + ["2026-05-08 09:30:00-04:00", "2026-05-09 16:00:00-04:00"] + )) + out = su._normalize_dates(aware) + assert out.dt.tz is None + assert list(out) == [pd.Timestamp("2026-05-08"), pd.Timestamp("2026-05-09")] + + +@pytest.mark.unit +def test_normalize_dates_leaves_naive_dates_at_midnight(): + naive = pd.Series(pd.to_datetime(["2026-05-08 14:30:00", "2026-05-09 00:00:00"])) + out = su._normalize_dates(naive) + assert out.dt.tz is None + assert list(out) == [pd.Timestamp("2026-05-08"), pd.Timestamp("2026-05-09")] + + +@pytest.mark.unit +def test_normalize_dates_handles_mixed_dst_offsets(): + # 5y of US bars span DST; via a cache CSV they arrive as mixed-offset + # strings, which pd.to_datetime can't unify. Each keeps its own local date. + mixed = pd.Series([ + "2026-01-08 00:00:00-05:00", # EST + "2026-06-08 00:00:00-04:00", # EDT + "not-a-date", # -> NaT + ]) + out = su._normalize_dates(mixed) + assert out.iloc[0] == pd.Timestamp("2026-01-08") + assert out.iloc[1] == pd.Timestamp("2026-06-08") + assert pd.isna(out.iloc[2]) + + +@pytest.mark.unit +def test_normalize_dates_keeps_positive_offset_local_date(): + # A Tokyo bar at local midnight (+09:00) must stay on its own calendar day, + # not shift to the previous UTC day (which utc=True parsing would cause). + jst = pd.Series(["2026-05-08 00:00:00+09:00"]) + assert su._normalize_dates(jst).iloc[0] == pd.Timestamp("2026-05-08") + + +# --- fill vs guard responsibilities ---------------------------------------- + +@pytest.mark.unit +def test_clean_dataframe_keeps_nan_close_for_the_caller_to_inspect(): + # _clean_dataframe normalizes but no longer drops the NaN close itself. + df = pd.DataFrame({"Date": ["2026-05-08", "2026-05-09"], "Close": [100.0, float("nan")]}) + cleaned = su._clean_dataframe(df) + assert len(cleaned) == 2 + assert pd.isna(cleaned["Close"].iloc[-1]) + + +@pytest.mark.unit +def test_fill_price_gaps_drops_nan_close_rows(): + df = pd.DataFrame({"Date": pd.to_datetime(["2026-05-07", "2026-05-08"]), + "Close": [float("nan"), 100.0]}) + filled = su._fill_price_gaps(df) + assert len(filled) == 1 + assert filled["Close"].iloc[0] == 100.0 + + +# --- load_ohlcv end-to-end (with a mocked cache read) ----------------------- + +def _run_load(monkeypatch, tmp_path, frame, curr_date): + """Drive load_ohlcv against a pre-seeded cache frame (no network).""" + monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) + today = pd.Timestamp(curr_date) + monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: today)) + start = (today - pd.DateOffset(years=5)).strftime("%Y-%m-%d") + end = (today + pd.Timedelta(days=1)).strftime("%Y-%m-%d") + (tmp_path / f"AAPL-YFin-data-{start}-{end}.csv").write_text(frame.to_csv(index=False)) + + def _fail_download(*a, **k): + raise AssertionError("should use the seeded cache, not download") + monkeypatch.setattr(su.yf, "download", _fail_download) + monkeypatch.setattr(su, "_assert_ohlcv_not_stale", lambda *a, **k: None) + return su.load_ohlcv("AAPL", curr_date) + + +@pytest.mark.unit +def test_latest_in_range_nan_close_raises_not_silent_fallback(monkeypatch, tmp_path): + # Newest bar (the curr_date) has no close -> raise, don't return Thursday. + frame = pd.DataFrame({ + "Date": ["2026-05-07", "2026-05-08"], + "Open": [100.0, 101.0], "High": [101.0, 102.0], "Low": [99.0, 100.0], + "Close": [100.5, float("nan")], "Volume": [1_000_000, 1_000_000], + }) + with pytest.raises(NoMarketDataError, match="no closing price"): + _run_load(monkeypatch, tmp_path, frame, "2026-05-08") + + +@pytest.mark.unit +def test_older_nan_close_row_is_still_dropped(monkeypatch, tmp_path): + # A stale gap mid-series is dropped; the valid latest bar is served. + frame = pd.DataFrame({ + "Date": ["2026-05-06", "2026-05-07", "2026-05-08"], + "Open": [100.0, 101.0, 102.0], "High": [101.0, 102.0, 103.0], + "Low": [99.0, 100.0, 101.0], + "Close": [100.5, float("nan"), 102.5], "Volume": [1_000_000, 1_000_000, 1_000_000], + }) + out = _run_load(monkeypatch, tmp_path, frame, "2026-05-08") + assert out["Close"].iloc[-1] == 102.5 + assert (out["Date"] == pd.Timestamp("2026-05-07")).sum() == 0 # the NaN row is gone + + +@pytest.mark.unit +def test_tz_aware_latest_bar_is_kept_at_the_cutoff(monkeypatch, tmp_path): + # A tz-aware/intraday latest bar on the cutoff day must not be filtered out + # by a naive-vs-aware comparison. + frame = pd.DataFrame({ + "Date": ["2026-05-07 09:30:00-04:00", "2026-05-08 09:30:00-04:00"], + "Open": [100.0, 101.0], "High": [101.0, 102.0], "Low": [99.0, 100.0], + "Close": [100.5, 101.5], "Volume": [1_000_000, 1_000_000], + }) + out = _run_load(monkeypatch, tmp_path, frame, "2026-05-08") + assert out["Close"].iloc[-1] == 101.5 + assert out["Date"].iloc[-1] == pd.Timestamp("2026-05-08") diff --git a/tests/test_signal_processing.py b/tests/test_signal_processing.py index 92520a8d1..705c81183 100644 --- a/tests/test_signal_processing.py +++ b/tests/test_signal_processing.py @@ -10,7 +10,13 @@ to it. import pytest -from tradingagents.agents.utils.rating import RATINGS_5_TIER, parse_rating +from tradingagents.agents.utils.rating import ( + RATING_REVIEW, + RATINGS_5_TIER, + extract_rating, + is_review, + parse_rating, +) from tradingagents.graph.signal_processing import SignalProcessor # --------------------------------------------------------------------------- @@ -84,6 +90,51 @@ class TestSignalProcessor: llm.invoke.assert_not_called() llm.with_structured_output.assert_not_called() - def test_default_when_no_rating_present(self): + def test_unparseable_signal_is_review_not_silent_hold(self): + # #1170: an unrecognizable decision must surface REVIEW, not a fabricated + # tradeable Hold. sp = SignalProcessor() - assert sp.process_signal("Plain prose without a recommendation.") == "Hold" + signal = sp.process_signal("Plain prose without a recommendation.") + assert signal == RATING_REVIEW + assert is_review(signal) + assert signal not in RATINGS_5_TIER + + def test_fullwidth_colon_is_parsed_not_reviewed(self): + # #1170: `Rating:Overweight` (fullwidth colon) used to defeat the regex + # and silently become Hold; NFKC normalization now parses it. + sp = SignalProcessor() + assert sp.process_signal("Rating:Overweight\n理由はこちら。") == "Overweight" + + +@pytest.mark.unit +class TestExtractRating: + def test_returns_none_when_absent(self): + assert extract_rating("No directional call here.") is None + assert extract_rating("") is None + + def test_whole_word_only(self): + # substrings inside larger words must not match + assert extract_rating("The buyer was holding shares.") is None + + def test_parse_rating_keeps_silent_default_for_compat(self): + # parse_rating (used by the memory log) intentionally keeps Hold default. + assert parse_rating("No rating here.") == "Hold" + assert parse_rating("No rating here.", default="Underweight") == "Underweight" + + +@pytest.mark.unit +class TestGraphSignalContract: + """The graph-facing signal (TradingAgentsGraph.process_signal) honors the + documented "5-tier or REVIEW" contract, not just the parser in isolation.""" + + def _bare_graph(self): + from tradingagents.graph.trading_graph import TradingAgentsGraph + g = object.__new__(TradingAgentsGraph) + g.signal_processor = SignalProcessor() + return g + + def test_graph_surfaces_review(self): + assert self._bare_graph().process_signal("no rating in here") == RATING_REVIEW + + def test_graph_returns_rating(self): + assert self._bare_graph().process_signal("**Rating**: Sell") == "Sell" diff --git a/tests/test_social_lookahead.py b/tests/test_social_lookahead.py new file mode 100644 index 000000000..57d12035b --- /dev/null +++ b/tests/test_social_lookahead.py @@ -0,0 +1,121 @@ +"""Historical social sentiment must not leak current data into a backtest (#1220). + +StockTwits and Reddit fetchers pull only recent items, so for a historical run +they must be trimmed to the analysis window (and yield a clear placeholder when +nothing qualifies) rather than showing today's chatter as if it were from the +as-of date. All three sources share dataflows.date_window.in_window. +""" +from __future__ import annotations + +import json +from datetime import datetime, timezone + +import pytest + +from tradingagents.dataflows import reddit, stocktwits +from tradingagents.dataflows.date_window import in_window + + +class _JsonResp: + """Minimal urlopen() context-manager stub returning a JSON body.""" + + def __init__(self, payload): + self._body = json.dumps(payload).encode() + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self): + return self._body + + +# --- shared window helper --------------------------------------------------- + +@pytest.mark.unit +def test_in_window_bounds_and_exclusive_upper(): + start = datetime(2026, 5, 1) + end = datetime(2026, 5, 9) + assert in_window(datetime(2026, 5, 5, tzinfo=timezone.utc), start, end) is True + assert in_window(datetime(2026, 5, 9, 23, 59, tzinfo=timezone.utc), start, end) is True + # exactly midnight after end -> excluded (no leak) + assert in_window(datetime(2026, 5, 10, 0, 0, tzinfo=timezone.utc), start, end) is False + # offset-aware converted, not truncated: 05-10T01:00+05:00 == 05-09T20:00Z + assert in_window(datetime.fromisoformat("2026-05-10T01:00:00+05:00"), start, end) is True + + +@pytest.mark.unit +def test_in_window_undated_excluded_in_backtest_kept_live(): + old = datetime(2026, 5, 9) + assert in_window(None, datetime(2026, 5, 1), old) is False # historical + now = datetime.now(timezone.utc) + assert in_window(None, now, now) is True # live + + +# --- StockTwits ------------------------------------------------------------- + +def _msg(created_iso, sentiment=None): + return { + "created_at": created_iso, + "user": {"username": "u"}, + "entities": {"sentiment": {"basic": sentiment}}, + "body": "text", + } + + +@pytest.mark.unit +def test_stocktwits_historical_window_excludes_recent(monkeypatch): + # All messages are "today"; a run as-of a past week must show none of them. + recent = [_msg("2026-08-30T12:00:00Z", "Bullish"), _msg("2026-08-29T09:00:00Z")] + monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": recent})) + out = stocktwits.fetch_stocktwits_messages("AAPL", start_date="2026-05-01", end_date="2026-05-08") + assert "no StockTwits messages" in out + assert "2026-05-01..2026-05-08" in out + assert "Bullish: 1" not in out # the recent bullish message did not leak + + +@pytest.mark.unit +def test_stocktwits_live_window_keeps_in_range(monkeypatch): + msgs = [_msg("2026-05-05T12:00:00Z", "Bullish"), _msg("2026-05-07T09:00:00Z", "Bearish")] + monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": msgs})) + out = stocktwits.fetch_stocktwits_messages("AAPL", start_date="2026-05-01", end_date="2026-05-08") + assert "Total: 2" in out + + +@pytest.mark.unit +def test_stocktwits_no_window_is_unfiltered(monkeypatch): + msgs = [_msg("2026-08-30T12:00:00Z", "Bullish")] + monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": msgs})) + out = stocktwits.fetch_stocktwits_messages("AAPL") # live caller, no dates + assert "Total: 1" in out + + +# --- Reddit ----------------------------------------------------------------- + +def _epoch(date_str): + return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()) + + +@pytest.mark.unit +def test_reddit_historical_window_excludes_recent(monkeypatch): + posts = [{"title": "NOW", "created_utc": _epoch("2026-08-30"), "source": "rss"}] + monkeypatch.setattr(reddit, "_fetch_subreddit", lambda *a, **k: posts) + out = reddit.fetch_reddit_posts( + "AAPL", subreddits=("stocks",), inter_request_delay=0, + start_date="2026-05-01", end_date="2026-05-08", + ) + assert "NOW" not in out + assert "no posts" in out.lower() or "no reddit posts" in out.lower() + + +@pytest.mark.unit +def test_reddit_live_window_keeps_in_range(monkeypatch): + posts = [{"title": "INRANGE", "created_utc": _epoch("2026-05-05"), "source": "rss"}] + monkeypatch.setattr(reddit, "_fetch_subreddit", lambda *a, **k: posts) + out = reddit.fetch_reddit_posts( + "AAPL", subreddits=("stocks",), inter_request_delay=0, + start_date="2026-05-01", end_date="2026-05-08", + ) + assert "INRANGE" in out diff --git a/tests/test_structured_agent_prompts.py b/tests/test_structured_agent_prompts.py index d876225cc..d7b24061c 100644 --- a/tests/test_structured_agent_prompts.py +++ b/tests/test_structured_agent_prompts.py @@ -52,6 +52,7 @@ def test_trader_prompt_states_constraint(): create_trader(llm)({ "company_of_interest": "NVDA", "investment_plan": "**Recommendation**: Buy", + "market_report": "Current price $189.5; ATR 4.2.", }) assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"]) diff --git a/tests/test_structured_agents.py b/tests/test_structured_agents.py index 6f5cdb206..50566cdd4 100644 --- a/tests/test_structured_agents.py +++ b/tests/test_structured_agents.py @@ -131,6 +131,7 @@ def _make_trader_state(): return { "company_of_interest": "NVDA", "investment_plan": "**Recommendation**: Buy\n**Rationale**: ...\n**Strategic Actions**: ...", + "market_report": "Current price $189.5; 14-day ATR 4.2; support $178, resistance $196.", } @@ -200,6 +201,31 @@ class TestTraderAgent: prompt = captured["prompt"] assert any("Proposed Investment Plan" in m["content"] for m in prompt) + def test_prompt_includes_market_report_for_price_levels(self): + # #1167: the Trader must see the technical market report so entry/stop + # levels are grounded in real price structure, not just the digested plan. + captured = {} + trader = create_trader(_structured_trader_llm(captured)) + trader(_make_trader_state()) + user = " ".join(m["content"] for m in captured["prompt"] if m["role"] == "user") + system = " ".join(m["content"] for m in captured["prompt"] if m["role"] == "system") + assert "Technical Market Report:" in user + assert "14-day ATR 4.2" in user # the actual report content reached the Trader + assert "support $178, resistance $196" in user + assert "Ground concrete price levels" in system + + def test_empty_market_report_omits_the_section_and_grounding(self): + # #1167: when the market analyst wasn't selected the report is empty, so + # don't tell the Trader to ground levels in a report it doesn't have. + captured = {} + state = _make_trader_state() + state["market_report"] = "" + create_trader(_structured_trader_llm(captured))(state) + text = " ".join(m["content"] for m in captured["prompt"]) + assert "Technical Market Report:" not in text + assert "Ground concrete price levels" not in text + assert "Proposed Investment Plan" in text # still present + def test_falls_back_to_freetext_when_structured_unavailable(self): plain_response = ( "**Action**: Sell\n\nGuidance cut hits margins.\n\n" diff --git a/tests/test_symbol_normalization_paths.py b/tests/test_symbol_normalization_paths.py index 1f4d9c88d..1e0bac6bf 100644 --- a/tests/test_symbol_normalization_paths.py +++ b/tests/test_symbol_normalization_paths.py @@ -41,18 +41,21 @@ def test_fetch_returns_normalizes_symbol(monkeypatch): queried.append(symbol) def history(self, *args, **kwargs): - return pd.DataFrame({"Close": [100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 106.0]}) + prices = [100.0, 101.0, 102.0, 103.0, 104.0, 105.0, 106.0] + idx = pd.date_range(start="2025-01-02", periods=len(prices), freq="D") + return pd.DataFrame({"Close": prices}, index=idx) monkeypatch.setattr(tg.yf, "Ticker", FakeTicker) # _fetch_returns does not use ``self``; call unbound to avoid building the graph. - raw, alpha, days = TradingAgentsGraph._fetch_returns( + raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns( None, "XAUUSD", "2025-01-02", holding_days=5, benchmark="SPY" ) assert queried[0] == "GC=F" # stock symbol normalized (#984) assert queried[1] == "SPY" # benchmark left as the canonical symbol assert raw is not None and days is not None + assert resolved == "2025-01-07" # resolution date recorded (#1251) def test_news_lookup_normalizes_symbol(monkeypatch): diff --git a/tradingagents/agents/analysts/sentiment_analyst.py b/tradingagents/agents/analysts/sentiment_analyst.py index e61dc1ae2..1bd11f2c5 100644 --- a/tradingagents/agents/analysts/sentiment_analyst.py +++ b/tradingagents/agents/analysts/sentiment_analyst.py @@ -68,8 +68,12 @@ def create_sentiment_analyst(llm): # 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) + # Pass the analysis window so a historical run trims social posts to it + # instead of leaking today's chatter into a backtest (#1220). + stocktwits_block = fetch_stocktwits_messages( + ticker, limit=30, start_date=start_date, end_date=end_date + ) + reddit_block = fetch_reddit_posts(ticker, start_date=start_date, end_date=end_date) system_message = _build_system_message( ticker=ticker, diff --git a/tradingagents/agents/researchers/bear_researcher.py b/tradingagents/agents/researchers/bear_researcher.py index 860ae4be5..9e3e2bb23 100644 --- a/tradingagents/agents/researchers/bear_researcher.py +++ b/tradingagents/agents/researchers/bear_researcher.py @@ -1,6 +1,7 @@ from tradingagents.agents.utils.agent_utils import ( get_instrument_context_from_state, get_language_instruction, + opponent_argument_or_opening, ) @@ -10,7 +11,9 @@ def create_bear_researcher(llm): history = investment_debate_state.get("history", "") bear_history = investment_debate_state.get("bear_history", "") - current_response = investment_debate_state.get("current_response", "") + current_response = opponent_argument_or_opening( + investment_debate_state.get("current_response", ""), "bull analyst" + ) market_research_report = state["market_report"] sentiment_report = state["sentiment_report"] news_report = state["news_report"] diff --git a/tradingagents/agents/researchers/bull_researcher.py b/tradingagents/agents/researchers/bull_researcher.py index 5f939ed72..6987ac81b 100644 --- a/tradingagents/agents/researchers/bull_researcher.py +++ b/tradingagents/agents/researchers/bull_researcher.py @@ -1,6 +1,7 @@ from tradingagents.agents.utils.agent_utils import ( get_instrument_context_from_state, get_language_instruction, + opponent_argument_or_opening, ) @@ -10,7 +11,9 @@ def create_bull_researcher(llm): history = investment_debate_state.get("history", "") bull_history = investment_debate_state.get("bull_history", "") - current_response = investment_debate_state.get("current_response", "") + current_response = opponent_argument_or_opening( + investment_debate_state.get("current_response", ""), "bear analyst" + ) market_research_report = state["market_report"] sentiment_report = state["sentiment_report"] news_report = state["news_report"] diff --git a/tradingagents/agents/risk_mgmt/aggressive_debator.py b/tradingagents/agents/risk_mgmt/aggressive_debator.py index 3fd0e124a..ff1efd879 100644 --- a/tradingagents/agents/risk_mgmt/aggressive_debator.py +++ b/tradingagents/agents/risk_mgmt/aggressive_debator.py @@ -1,6 +1,7 @@ from tradingagents.agents.utils.agent_utils import ( get_instrument_context_from_state, get_language_instruction, + opponent_argument_or_opening, ) @@ -10,8 +11,12 @@ def create_aggressive_debator(llm): history = risk_debate_state.get("history", "") aggressive_history = risk_debate_state.get("aggressive_history", "") - current_conservative_response = risk_debate_state.get("current_conservative_response", "") - current_neutral_response = risk_debate_state.get("current_neutral_response", "") + current_conservative_response = opponent_argument_or_opening( + risk_debate_state.get("current_conservative_response", ""), "conservative analyst" + ) + current_neutral_response = opponent_argument_or_opening( + risk_debate_state.get("current_neutral_response", ""), "neutral analyst" + ) market_research_report = state["market_report"] sentiment_report = state["sentiment_report"] diff --git a/tradingagents/agents/risk_mgmt/conservative_debator.py b/tradingagents/agents/risk_mgmt/conservative_debator.py index b84add0c3..77d7d064b 100644 --- a/tradingagents/agents/risk_mgmt/conservative_debator.py +++ b/tradingagents/agents/risk_mgmt/conservative_debator.py @@ -1,6 +1,7 @@ from tradingagents.agents.utils.agent_utils import ( get_instrument_context_from_state, get_language_instruction, + opponent_argument_or_opening, ) @@ -10,8 +11,12 @@ def create_conservative_debator(llm): history = risk_debate_state.get("history", "") conservative_history = risk_debate_state.get("conservative_history", "") - current_aggressive_response = risk_debate_state.get("current_aggressive_response", "") - current_neutral_response = risk_debate_state.get("current_neutral_response", "") + current_aggressive_response = opponent_argument_or_opening( + risk_debate_state.get("current_aggressive_response", ""), "aggressive analyst" + ) + current_neutral_response = opponent_argument_or_opening( + risk_debate_state.get("current_neutral_response", ""), "neutral analyst" + ) market_research_report = state["market_report"] sentiment_report = state["sentiment_report"] diff --git a/tradingagents/agents/risk_mgmt/neutral_debator.py b/tradingagents/agents/risk_mgmt/neutral_debator.py index b28ade25b..746c8834e 100644 --- a/tradingagents/agents/risk_mgmt/neutral_debator.py +++ b/tradingagents/agents/risk_mgmt/neutral_debator.py @@ -1,6 +1,7 @@ from tradingagents.agents.utils.agent_utils import ( get_instrument_context_from_state, get_language_instruction, + opponent_argument_or_opening, ) @@ -10,8 +11,12 @@ def create_neutral_debator(llm): history = risk_debate_state.get("history", "") neutral_history = risk_debate_state.get("neutral_history", "") - current_aggressive_response = risk_debate_state.get("current_aggressive_response", "") - current_conservative_response = risk_debate_state.get("current_conservative_response", "") + current_aggressive_response = opponent_argument_or_opening( + risk_debate_state.get("current_aggressive_response", ""), "aggressive analyst" + ) + current_conservative_response = opponent_argument_or_opening( + risk_debate_state.get("current_conservative_response", ""), "conservative analyst" + ) market_research_report = state["market_report"] sentiment_report = state["sentiment_report"] diff --git a/tradingagents/agents/trader/trader.py b/tradingagents/agents/trader/trader.py index 6c8ac50e4..402023bf1 100644 --- a/tradingagents/agents/trader/trader.py +++ b/tradingagents/agents/trader/trader.py @@ -25,6 +25,23 @@ def create_trader(llm): company_name = state["company_of_interest"] instrument_context = get_instrument_context_from_state(state) investment_plan = state["investment_plan"] + # The research plan digests the debate but loses exact price structure; + # give the Trader the technical market report so entry/stop levels are + # grounded in real ATR / support-resistance / current price (#1167). The + # report is empty when the user did not select the market analyst, so + # only offer it (and the grounding instruction) when it has content. + market_report = (state["market_report"] or "").strip() + + if market_report: + grounding = ( + "Ground concrete price levels (entry, stop-loss, position sizing) in the technical " + "market report's price structure -- current price, support/resistance, ATR, and " + "volatility -- and use the research plan for direction and strategy. " + ) + report_section = f"Technical Market Report:\n{market_report}\n\n" + else: + grounding = "" + report_section = "" messages = [ { @@ -32,7 +49,7 @@ def create_trader(llm): "content": ( "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. " + + grounding + NO_EXTERNAL_TOOLS + get_language_instruction() ), @@ -40,12 +57,11 @@ def create_trader(llm): { "role": "user", "content": ( - f"Based on a comprehensive analysis by a team of analysts, here is an investment " - f"plan tailored for {company_name}. {instrument_context} This plan incorporates " - f"insights from current technical market trends, macroeconomic indicators, and " - f"social media sentiment. Use this plan as a foundation for evaluating your next " - f"trading decision.\n\nProposed Investment Plan: {investment_plan}\n\n" - f"Leverage these insights to make an informed and strategic decision." + f"Here is the research team's investment plan for {company_name}. " + f"{instrument_context}\n\n" + f"{report_section}" + f"Proposed Investment Plan:\n{investment_plan}\n\n" + f"Make an informed, strategic trading decision." ), }, ] diff --git a/tradingagents/agents/utils/agent_utils.py b/tradingagents/agents/utils/agent_utils.py index 16f45c178..cb1d3a3d2 100644 --- a/tradingagents/agents/utils/agent_utils.py +++ b/tradingagents/agents/utils/agent_utils.py @@ -65,6 +65,20 @@ def get_language_instruction() -> str: return f" Write your entire response in {lang}." +def opponent_argument_or_opening(text: str, opponent: str) -> str: + """Opponent's latest argument, or an explicit opening marker when empty. + + The first speaker in each debate round receives an empty opponent response; + interpolating it into a "refute the opponent" prompt makes the model + fabricate the other side's position. Returning a clear "has not spoken yet" + marker instead lets it open with its own case (#1176). + """ + text = (text or "").strip() + if text: + return text + return f"(The {opponent} has not spoken yet — open the debate with your own case.)" + + def _clean_identity_value(value: Any) -> str | None: """Return a trimmed string, or None for empty / placeholder-ish values.""" if not isinstance(value, str): diff --git a/tradingagents/agents/utils/memory.py b/tradingagents/agents/utils/memory.py index ff9e94579..30b16339b 100644 --- a/tradingagents/agents/utils/memory.py +++ b/tradingagents/agents/utils/memory.py @@ -67,9 +67,21 @@ class TradingMemoryLog: """Return entries with outcome:pending (for Phase B).""" return [e for e in self.load_entries() if e.get("pending")] - def get_past_context(self, ticker: str, n_same: int = 5, n_cross: int = 3) -> str: - """Return formatted past context string for agent prompt injection.""" + def get_past_context( + self, ticker: str, n_same: int = 5, n_cross: int = 3, as_of: str | None = None + ) -> str: + """Return formatted past context string for agent prompt injection. + + When ``as_of`` (yyyy-mm-dd) is given, only lessons whose outcome was + already known by that date are included — an entry is kept only if it + stores a resolution date (``resolved:...``) that is on or before + ``as_of``. This keeps a historical/backtest run from learning from + outcomes that had not happened yet (#1251). ``as_of=None`` disables the + filter, so live runs and pre-migration entries are unaffected. + """ entries = [e for e in self.load_entries() if not e.get("pending")] + if as_of is not None: + entries = [e for e in entries if e.get("resolved") and e["resolved"] <= as_of] if not entries: return "" @@ -104,12 +116,14 @@ class TradingMemoryLog: alpha_return: float, holding_days: int, reflection: str, + resolution_date: str | None = None, ) -> None: """Replace pending tag and append REFLECTION section using atomic write. Finds the first pending entry matching (trade_date, ticker), updates - its tag with return figures, and appends a REFLECTION section. Uses - a temp-file + os.replace() so a crash mid-write never corrupts the log. + its tag with return figures (and the ``resolution_date`` the outcome + became known), and appends a REFLECTION section. Uses a temp-file + + os.replace() so a crash mid-write never corrupts the log. """ if not self._log_path or not self._log_path.exists(): return @@ -140,9 +154,8 @@ class TradingMemoryLog: # Parse rating from the existing pending tag fields = [f.strip() for f in tag_line[1:-1].split("|")] rating = fields[2] - new_tag = ( - f"[{trade_date} | {ticker} | {rating}" - f" | {raw_pct} | {alpha_pct} | {holding_days}d]" + new_tag = self._resolved_tag( + trade_date, ticker, rating, raw_pct, alpha_pct, holding_days, resolution_date ) rest = "\n".join(lines[1:]) new_blocks.append( @@ -194,9 +207,9 @@ class TradingMemoryLog: rating = fields[2] raw_pct = f"{upd['raw_return']:+.1%}" alpha_pct = f"{upd['alpha_return']:+.1%}" - new_tag = ( - f"[{trade_date} | {ticker} | {rating}" - f" | {raw_pct} | {alpha_pct} | {upd['holding_days']}d]" + new_tag = self._resolved_tag( + trade_date, ticker, rating, raw_pct, alpha_pct, + upd["holding_days"], upd.get("resolution_date"), ) rest = "\n".join(lines[1:]) new_blocks.append( @@ -217,6 +230,21 @@ class TradingMemoryLog: # --- Helpers --- + @staticmethod + def _resolved_tag( + trade_date, ticker, rating, raw_pct, alpha_pct, holding_days, resolution_date + ) -> str: + """Build a resolved entry tag, recording the outcome's known-by date. + + ``resolution_date`` (the date of the last price bar used for the return) + is the point-in-time cutoff a later run filters on (#1251). Omitted when + unavailable, keeping the legacy 6-field tag. + """ + tag = f"[{trade_date} | {ticker} | {rating} | {raw_pct} | {alpha_pct} | {holding_days}d" + if resolution_date: + tag += f" | resolved:{resolution_date}" + return tag + "]" + def _apply_rotation(self, blocks: list[str]) -> list[str]: """Drop oldest resolved blocks when their count exceeds max_entries. @@ -264,6 +292,12 @@ class TradingMemoryLog: fields = [f.strip() for f in tag_line[1:-1].split("|")] if len(fields) < 4: return None + # Optional trailing "resolved:YYYY-MM-DD" field records when the outcome + # became known, for point-in-time filtering (#1251). + resolved = None + for f in fields[6:]: + if f.startswith("resolved:"): + resolved = f[len("resolved:"):].strip() entry = { "date": fields[0], "ticker": fields[1], @@ -272,6 +306,7 @@ class TradingMemoryLog: "raw": fields[3] if fields[3] != "pending" else None, "alpha": fields[4] if len(fields) > 4 else None, "holding": fields[5] if len(fields) > 5 else None, + "resolved": resolved, } body = "\n".join(lines[1:]).strip() decision_match = self._DECISION_RE.search(body) diff --git a/tradingagents/agents/utils/rating.py b/tradingagents/agents/utils/rating.py index 234bc568d..1a828ea21 100644 --- a/tradingagents/agents/utils/rating.py +++ b/tradingagents/agents/utils/rating.py @@ -7,42 +7,77 @@ The same five-tier scale (Buy, Overweight, Hold, Underweight, Sell) is used by: - The memory log (rating tag stored alongside each decision entry) Centralising it here avoids drift between those call sites. + +``extract_rating`` returns ``None`` when no rating can be found, so the graph can +surface an explicit ``REVIEW`` signal instead of a fabricated ``Hold`` (#1170). +``parse_rating`` keeps the legacy silent-default behaviour for callers (e.g. the +memory log) that need a rating string regardless. """ from __future__ import annotations import re +import unicodedata # Canonical, ordered 5-tier scale (most bullish to most bearish). RATINGS_5_TIER: tuple[str, ...] = ( "Buy", "Overweight", "Hold", "Underweight", "Sell", ) +# Signal emitted when the model's decision has no recognizable rating. It is not +# a tradeable position: it flags output that needs a human/re-run rather than +# silently degrading to Hold. Callers that map the signal onto the 5-tier enum +# (e.g. ``PortfolioRating(signal)``) should guard with ``is_review`` first. +RATING_REVIEW = "REVIEW" + _RATING_SET = {r.lower() for r in RATINGS_5_TIER} # Matches "Rating: X" / "rating - X" / "Rating: **X**" — tolerates markdown # bold wrappers and either a colon or hyphen separator. _RATING_LABEL_RE = re.compile(r"rating.*?[:\-][\s*]*(\w+)", re.IGNORECASE) +# Standalone 5-tier word anywhere (word boundaries so "Buyer"/"Holding" don't match). +_RATING_WORD_RE = re.compile( + r"\b(" + "|".join(RATINGS_5_TIER) + r")\b", re.IGNORECASE +) -def parse_rating(text: str, default: str = "Hold") -> str: - """Heuristically extract a 5-tier rating from prose text. - Two-pass strategy: - 1. Look for an explicit "Rating: X" label (tolerant of markdown bold). - 2. Fall back to the first 5-tier rating word found anywhere in the text. +def extract_rating(text: str) -> str | None: + """Extract a 5-tier rating from prose, or ``None`` if none is present. - Returns a Title-cased rating string, or ``default`` if no rating word appears. + Two-pass strategy on the NFKC-normalized text (so fullwidth punctuation like + ``Rating:Overweight`` is matched the same as ASCII): + 1. An explicit "Rating: X" label (tolerant of markdown bold). + 2. The first standalone 5-tier rating word found anywhere. """ - for line in text.splitlines(): + if not text: + return None + norm = unicodedata.normalize("NFKC", text) + + for line in norm.splitlines(): m = _RATING_LABEL_RE.search(line) if m and m.group(1).lower() in _RATING_SET: return m.group(1).capitalize() - for line in text.splitlines(): - for word in line.lower().split(): - clean = word.strip("*:.,") - if clean in _RATING_SET: - return clean.capitalize() + m = _RATING_WORD_RE.search(norm) + if m: + return m.group(1).capitalize() - return default + return None + + +def parse_rating(text: str, default: str = "Hold") -> str: + """Extract a 5-tier rating, falling back to ``default`` when none is found. + + Legacy convenience wrapper: it always returns a rating string, so an + unparseable decision silently becomes ``default`` (``Hold``). Callers that + must distinguish "no rating" from a real Hold should use + :func:`extract_rating` (or the graph's REVIEW-surfacing signal) instead. + """ + rating = extract_rating(text) + return rating if rating is not None else default + + +def is_review(signal: str) -> bool: + """Whether a signal is the non-tradeable REVIEW sentinel (#1170).""" + return signal == RATING_REVIEW diff --git a/tradingagents/dataflows/date_window.py b/tradingagents/dataflows/date_window.py new file mode 100644 index 000000000..8b49dca26 --- /dev/null +++ b/tradingagents/dataflows/date_window.py @@ -0,0 +1,30 @@ +"""Shared look-ahead-safe date-window filtering for dated content. + +News, StockTwits, and Reddit all pull recent items that must be trimmed to the +analysis window so a historical/backtest run never sees content published after +its as-of date. Centralizing the rule keeps every source consistent (#1126, +#1220): every timestamp is normalized to UTC, the upper bound is exclusive at +midnight after ``end`` (so an item stamped exactly then can't leak), and an +undated item is kept only when the window reaches the present (a live run), since +in a backtest we can't prove it isn't future. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + + +def to_utc(dt: datetime) -> datetime: + """Normalize a datetime to UTC-aware; a naive value is assumed to be UTC.""" + return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc) + + +def in_window(pub_dt: datetime | None, start_dt: datetime, end_dt: datetime) -> bool: + """Whether an item belongs in the half-open window ``[start, end + 1 day)``. + + ``pub_dt`` None means undated: kept only when the window reaches the present. + """ + end = to_utc(end_dt) + if pub_dt is not None: + return to_utc(start_dt) <= to_utc(pub_dt) < end + timedelta(days=1) + return end >= datetime.now(timezone.utc) - timedelta(days=1) diff --git a/tradingagents/dataflows/fred.py b/tradingagents/dataflows/fred.py index eb3856e6a..38ab7d340 100644 --- a/tradingagents/dataflows/fred.py +++ b/tradingagents/dataflows/fred.py @@ -143,8 +143,12 @@ def get_macro_data( Args: indicator: A friendly alias (e.g. "cpi", "unemployment", "10y_treasury") or a raw FRED series ID (e.g. "CPIAUCSL", "DGS10"). - curr_date: End of the window (yyyy-mm-dd); no later observations are - returned, so a past date never leaks future data. + curr_date: The as-of date (yyyy-mm-dd). It bounds the observation window + AND pins the data vintage: FRED is queried with + ``realtime_start = realtime_end = curr_date`` so a historical run sees + the values that were actually published by that date, not later + revisions. Without this, revision-prone series (CPI, GDP, ...) would + leak future information into a backtest (#1275). look_back_days: Trailing window length; ``None`` uses DEFAULT_LOOKBACK_DAYS. Returns: @@ -157,6 +161,12 @@ def get_macro_data( end_dt = datetime.strptime(curr_date, "%Y-%m-%d") start_date = (end_dt - timedelta(days=look_back_days)).strftime("%Y-%m-%d") + # Pin the data vintage to curr_date. FRED defaults both realtime bounds to + # today, which serves the LATEST revision of every observation; a single-day + # realtime interval asks for the values known as of curr_date instead. This + # is applied to both the metadata and observations requests (#1275). + realtime = {"realtime_start": curr_date, "realtime_end": curr_date} + # Invalid LLM-supplied indicator: return guidance rather than raising, so a # bad argument doesn't abort the run (the routing layer also degrades macro # data, but a specific message is more useful to the analyst). @@ -165,7 +175,7 @@ def get_macro_data( except ValueError as e: return f"FRED: {e}" - meta = _request("series", {"series_id": series_id}).get("seriess") or [] + meta = _request("series", {"series_id": series_id, **realtime}).get("seriess") or [] if not meta: return ( f"FRED series '{series_id}' not found. Pass a known alias " @@ -184,6 +194,7 @@ def get_macro_data( "observation_start": start_date, "observation_end": curr_date, "sort_order": "asc", + **realtime, }, ).get("observations", []) diff --git a/tradingagents/dataflows/reddit.py b/tradingagents/dataflows/reddit.py index edb4aedfc..9595e621e 100644 --- a/tradingagents/dataflows/reddit.py +++ b/tradingagents/dataflows/reddit.py @@ -25,15 +25,35 @@ import re import time import xml.etree.ElementTree as ET from collections.abc import Iterable -from datetime import datetime +from datetime import datetime, timezone from urllib.error import HTTPError from urllib.parse import urlencode from urllib.request import Request, urlopen +from .date_window import in_window from .symbol_utils import crypto_base logger = logging.getLogger(__name__) + +def _within_window(posts, start_date, end_date): + """Keep only posts published in [start_date, end_date] (look-ahead safe). + + No window (both None) leaves the list untouched for live callers. A post with + no ``created_utc`` epoch is dropped in a historical window (#1220). + """ + if not (start_date and end_date): + return posts + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + kept = [] + for p in posts: + ts = p.get("created_utc") + created = datetime.fromtimestamp(ts, tz=timezone.utc) if ts else None + if in_window(created, start_dt, end_dt): + kept.append(p) + return kept + _API = "https://www.reddit.com/r/{sub}/search.json?{qs}" _RSS = "https://www.reddit.com/r/{sub}/search.rss?{qs}" # A descriptive, identified User-Agent (per Reddit's API etiquette). Reddit @@ -194,6 +214,8 @@ def fetch_reddit_posts( limit_per_sub: int = 5, timeout: float = 10.0, inter_request_delay: float = 1.0, + start_date: str | None = None, + end_date: str | None = None, ) -> str: """Fetch recent Reddit posts mentioning ``ticker`` across finance subreddits and return them as a formatted plaintext block. @@ -201,6 +223,10 @@ def fetch_reddit_posts( ``inter_request_delay`` paces the (now RSS-only) per-subreddit requests to stay under Reddit's public per-IP rate limit; combined with the RSS-first path it makes 429s rare even when several analyses run back-to-back. + + When ``start_date``/``end_date`` (yyyy-mm-dd) are given, posts are trimmed to + that window so a historical run does not leak current discussion into a + backtest (#1220). """ # Crypto reaches us as a Yahoo pair (BTC-USD); search Reddit for the base # ("BTC") so the query actually matches discussion instead of near-nothing. @@ -210,7 +236,8 @@ def fetch_reddit_posts( for i, sub in enumerate(subreddits): if i > 0: time.sleep(inter_request_delay) - posts = _fetch_subreddit(ticker, sub, limit_per_sub, timeout) + posts = _within_window(_fetch_subreddit(ticker, sub, limit_per_sub, timeout), + start_date, end_date) total_posts += len(posts) if not posts: blocks.append(f"r/{sub}: ") diff --git a/tradingagents/dataflows/stockstats_utils.py b/tradingagents/dataflows/stockstats_utils.py index 4ffe80422..94ba07ea8 100644 --- a/tradingagents/dataflows/stockstats_utils.py +++ b/tradingagents/dataflows/stockstats_utils.py @@ -60,17 +60,53 @@ def _ensure_date_column(data: pd.DataFrame) -> pd.DataFrame: return data +def _local_midnight(value) -> pd.Timestamp: + """A single timestamp as its naive, midnight-normalized local date (or NaT).""" + if pd.isna(value): + return pd.NaT + try: + ts = pd.Timestamp(value) + except (ValueError, TypeError): + return pd.NaT + if ts.tzinfo is not None: + ts = ts.tz_localize(None) # drop tz, keep the local wall-clock date + return ts.normalize() + + +def _normalize_dates(dates) -> pd.Series: + """Parse to naive, midnight-normalized dates so tz-aware or intraday + timestamps compare correctly against the naive ``curr_date`` cutoff (#1201). + + Normalized per element: 5 years of yfinance bars span daylight-saving + changes (and cache CSVs round-trip the offsets as strings), so the series can + carry mixed UTC offsets that ``pd.to_datetime`` cannot unify without + ``utc=True`` — which would shift non-US (positive-offset) markets to the + previous day. Keeping each bar's own local date avoids both. + """ + return pd.to_datetime(pd.Series(dates).map(_local_midnight)) + + def _clean_dataframe(data: pd.DataFrame) -> pd.DataFrame: - """Normalize a stock DataFrame for stockstats: parse dates, drop invalid rows, fill price gaps.""" + """Normalize a stock DataFrame for stockstats: parse/normalize dates and + coerce prices to numeric (NaN where invalid). Dropping incomplete rows and + filling gaps is left to ``_fill_price_gaps`` so the caller can first inspect + the latest in-range bar (#1201).""" data = _ensure_date_column(data) - data["Date"] = pd.to_datetime(data["Date"], errors="coerce") + data["Date"] = _normalize_dates(data["Date"]) data = data.dropna(subset=["Date"]) price_cols = [c for c in ["Open", "High", "Low", "Close", "Volume"] if c in data.columns] data[price_cols] = data[price_cols].apply(pd.to_numeric, errors="coerce") - data = data.dropna(subset=["Close"]) - data[price_cols] = data[price_cols].ffill().bfill() + return data + +def _fill_price_gaps(data: pd.DataFrame) -> pd.DataFrame: + """Drop rows with no close and forward/back-fill remaining price gaps so + indicators compute on a continuous series.""" + price_cols = [c for c in ["Open", "High", "Low", "Close", "Volume"] if c in data.columns] + # copy() so a filtered (sliced) input is written to safely, not via a view. + data = data.dropna(subset=["Close"]).copy() + data[price_cols] = data[price_cols].ffill().bfill() return data @@ -159,7 +195,7 @@ def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame: safe_symbol = safe_ticker_component(canonical) config = get_config() - curr_date_dt = pd.to_datetime(curr_date) + curr_date_dt = pd.to_datetime(curr_date).normalize() # Cache uses a fixed window (5y to today) so one file per symbol. today_date = pd.Timestamp.today() @@ -211,9 +247,20 @@ def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame: data = _clean_dataframe(data) - # Filter to curr_date to prevent look-ahead bias in backtesting + # Filter to curr_date to prevent look-ahead bias in backtesting. data = data[data["Date"] <= curr_date_dt] + # Guard the latest in-range bar before dropping incomplete rows: a newest bar + # with no close is "not settled yet", not "does not exist". Silently dropping + # it would make the previous trading day look like the latest (#1201); raise + # instead so the router surfaces it rather than fabricating a fallback. + if not data.empty and pd.isna(data["Close"].iloc[-1]): + raise NoMarketDataError( + symbol, canonical, "latest in-range OHLCV bar has no closing price" + ) + + data = _fill_price_gaps(data) + # Reject a stale frame (latest row far older than curr_date) rather than # feeding year-old prices into indicators (#1021). _assert_ohlcv_not_stale(data, curr_date, symbol, canonical) diff --git a/tradingagents/dataflows/stocktwits.py b/tradingagents/dataflows/stocktwits.py index 1c2fd02bb..dbf1457bd 100644 --- a/tradingagents/dataflows/stocktwits.py +++ b/tradingagents/dataflows/stocktwits.py @@ -14,11 +14,14 @@ network call succeeded. from __future__ import annotations +import contextlib import http.client import json import logging +from datetime import datetime from urllib.request import Request, urlopen +from .date_window import in_window from .symbol_utils import crypto_base logger = logging.getLogger(__name__) @@ -27,6 +30,29 @@ _API = "https://api.stocktwits.com/api/2/streams/symbol/{ticker}.json" _UA = "tradingagents/0.2 (+https://github.com/TauricResearch/TradingAgents)" +def _within_window(messages, start_date, end_date): + """Keep only messages published in [start_date, end_date] (look-ahead safe). + + No window (both None) leaves the list untouched for live callers. A message + whose ``created_at`` (ISO 8601) is unparseable is dropped in a historical + window, since we can't prove it isn't from after the as-of date (#1220). + """ + if not (start_date and end_date): + return messages + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + kept = [] + for m in messages: + created = None + raw = m.get("created_at") + if raw: + with contextlib.suppress(ValueError, TypeError): + created = datetime.fromisoformat(str(raw).replace("Z", "+00:00")) + if in_window(created, start_dt, end_dt): + kept.append(m) + return kept + + def _stocktwits_symbol(ticker: str) -> str: """Map a crypto pair to StockTwits' ``.X`` convention. @@ -38,10 +64,21 @@ def _stocktwits_symbol(ticker: str) -> str: return f"{base}.X" if base else ticker.strip().upper() -def fetch_stocktwits_messages(ticker: str, limit: int = 30, timeout: float = 10.0) -> str: +def fetch_stocktwits_messages( + ticker: str, + limit: int = 30, + timeout: float = 10.0, + start_date: str | None = None, + end_date: str | None = None, +) -> str: """Fetch recent StockTwits messages for ``ticker`` and return them as a formatted plaintext block ready for prompt injection. + When ``start_date``/``end_date`` (yyyy-mm-dd) are given, messages are trimmed + to that window. The StockTwits public stream only serves recent messages, so + for a historical run they all fall after the window and a clear placeholder + is returned rather than leaking today's chatter into a backtest (#1220). + 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. @@ -58,7 +95,13 @@ def fetch_stocktwits_messages(ticker: str, limit: int = 30, timeout: float = 10. return f"" messages = data.get("messages", []) if isinstance(data, dict) else [] + messages = _within_window(messages, start_date, end_date) if not messages: + if start_date and end_date: + return ( + f"" + ) return f"" lines = [] diff --git a/tradingagents/dataflows/yfinance_news.py b/tradingagents/dataflows/yfinance_news.py index 44aa73d31..9891d7947 100644 --- a/tradingagents/dataflows/yfinance_news.py +++ b/tradingagents/dataflows/yfinance_news.py @@ -1,26 +1,17 @@ """yfinance-based news data fetching functions.""" import contextlib -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone import yfinance as yf from dateutil.relativedelta import relativedelta from .config import get_config +from .date_window import in_window from .stockstats_utils import yf_retry from .symbol_utils import normalize_symbol -def _as_utc(dt: datetime) -> datetime: - """Normalize a datetime to UTC-aware; a naive value is assumed to be UTC. - - Window bounds arrive naive (parsed from ``yyyy-mm-dd``) while article - timestamps may be offset-aware, so every operand is normalized before - comparison. Without this the filter depends on the host timezone (#1126). - """ - return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc) - - def _extract_article_data(article: dict) -> dict: """Extract article data from yfinance news format (handles nested 'content' structure).""" # Handle nested content structure @@ -69,21 +60,6 @@ def _extract_article_data(article: dict) -> dict: } -def _in_news_window(pub_date, start_dt, end_dt) -> bool: - """Whether an article belongs in the half-open window ``[start, end + 1 day)``. - - Every operand is normalized to UTC, and the upper bound is exclusive so an - article stamped exactly at midnight after ``end_dt`` cannot leak into a - historical run (#1126). An undated article is kept only when the window - reaches the present (live run) — in a historical/backtest window it's - excluded, since we can't prove it isn't future news (#992/#1007). - """ - end = _as_utc(end_dt) - if pub_date is not None: - return _as_utc(start_dt) <= _as_utc(pub_date) < end + timedelta(days=1) - return end >= datetime.now(timezone.utc) - timedelta(days=1) - - def get_news_yfinance( ticker: str, start_date: str, @@ -124,7 +100,7 @@ def get_news_yfinance( data = _extract_article_data(article) # Keep only articles within the requested window (look-ahead safe). - if not _in_news_window(data["pub_date"], start_dt, end_dt): + if not in_window(data["pub_date"], start_dt, end_dt): continue news_str += f"### {data['title']} (source: {data['publisher']})\n" @@ -211,7 +187,7 @@ def get_global_news_yfinance( # Extract uniformly (flat + nested) and apply the same look-ahead-safe # window filter, so flat articles can't leak future news (#1007). data = _extract_article_data(article) - if not _in_news_window(data["pub_date"], start_dt, curr_dt): + if not in_window(data["pub_date"], start_dt, curr_dt): continue news_str += f"### {data['title']} (source: {data['publisher']})\n" if data["summary"]: diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index ab75f7508..6a35f472e 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -19,6 +19,7 @@ _ENV_OVERRIDES = { "TRADINGAGENTS_BENCHMARK_TICKER": "benchmark_ticker", "TRADINGAGENTS_TEMPERATURE": "temperature", "TRADINGAGENTS_LLM_MAX_RETRIES": "llm_max_retries", + "TRADINGAGENTS_MAX_TOKENS": "max_tokens", # Provider-specific reasoning/thinking knobs (None = each provider's own # default). Settable here for non-interactive runs; the CLI also offers an # interactive choice, which is skipped when the matching var is set. @@ -79,8 +80,8 @@ DEFAULT_CONFIG = _apply_env_overrides({ "memory_log_max_entries": None, # LLM settings "llm_provider": "openai", - "deep_think_llm": "gpt-5.5", - "quick_think_llm": "gpt-5.4-mini", + "deep_think_llm": "gpt-5.6", + "quick_think_llm": "gpt-5.6-luna", # When None, each provider's client falls back to its own default endpoint # (api.openai.com for OpenAI, generativelanguage.googleapis.com for Gemini, ...). # The CLI overrides this per provider when the user picks one. Keeping a @@ -100,6 +101,11 @@ DEFAULT_CONFIG = _apply_env_overrides({ # provider/SDK at its own default (usually 2). Raise it to ride out bursty # 429 throttling on rate-limited deployments instead of aborting a run (#1091). "llm_max_retries": None, + # Cap on output tokens forwarded to every provider chat client. None leaves + # each provider at its own default. Set it to bound a model that emits + # unbounded reasoning/output and hangs or trips a gateway idle timeout + # (e.g. some deepseek-v4-flash deployments, #1204). + "max_tokens": None, # Checkpoint/resume: when True, LangGraph saves state after each node # so a crashed run can resume from the last successful step. "checkpoint_enabled": False, diff --git a/tradingagents/graph/signal_processing.py b/tradingagents/graph/signal_processing.py index 90fafd04b..57f72178c 100644 --- a/tradingagents/graph/signal_processing.py +++ b/tradingagents/graph/signal_processing.py @@ -14,18 +14,25 @@ from __future__ import annotations from typing import Any -from tradingagents.agents.utils.rating import parse_rating +from tradingagents.agents.utils.rating import RATING_REVIEW, extract_rating class SignalProcessor: """Read the 5-tier rating out of a Portfolio Manager decision.""" def __init__(self, quick_thinking_llm: Any = None): - # The LLM argument is accepted for backwards compatibility but no - # longer used: the PM's structured output guarantees the rating is - # parseable from the rendered markdown without a second LLM call. - self.quick_thinking_llm = quick_thinking_llm + # The LLM argument is accepted for backwards compatibility but ignored: + # the PM's structured output guarantees the rating is parseable from the + # rendered markdown without a second LLM call, so it is not stored. + pass def process_signal(self, full_signal: str) -> str: - """Return one of Buy / Overweight / Hold / Underweight / Sell.""" - return parse_rating(full_signal) + """Return one of Buy / Overweight / Hold / Underweight / Sell, or REVIEW. + + An unrecognizable decision yields ``REVIEW`` rather than a fabricated + ``Hold``, so a parsing failure is visible instead of masquerading as a + tradeable neutral signal (#1170). Consumers that map the result onto the + 5-tier enum should guard with :func:`~tradingagents.agents.utils.rating.is_review`. + """ + rating = extract_rating(full_signal) + return rating if rating is not None else RATING_REVIEW diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index a1902177d..bf6c02827 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -3,6 +3,7 @@ import json import logging import os +from contextlib import contextmanager from datetime import datetime, timedelta from pathlib import Path from typing import Any @@ -62,6 +63,19 @@ def _coerce_max_retries(value): return n +def _coerce_max_tokens(value): + """Validate a ``max_tokens`` value to a positive int (env vars are strings).""" + if isinstance(value, bool): + raise ValueError(f"max_tokens must be an integer, not a boolean: {value!r}") + try: + n = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"max_tokens must be an integer, got {value!r}") from exc + if n <= 0: + raise ValueError(f"max_tokens must be > 0, got {n}") + return n + + class TradingAgentsGraph: """Main class that orchestrates the trading agents framework.""" @@ -149,6 +163,7 @@ class TradingAgentsGraph: self.workflow = self.graph_setup.setup_graph(selected_analysts) self.graph = self.workflow.compile() self._checkpointer_ctx = None + self._resuming = False def _get_provider_kwargs(self) -> dict[str, Any]: """Get provider-specific kwargs for LLM client creation.""" @@ -183,6 +198,13 @@ class TradingAgentsGraph: if max_retries is not None and max_retries != "": kwargs["max_retries"] = _coerce_max_retries(max_retries) + # Output-token cap is cross-provider, but Gemini names it + # ``max_output_tokens``; forward under the right key when set (#1204). + max_tokens = self.config.get("max_tokens") + if max_tokens is not None and max_tokens != "": + key = "max_output_tokens" if provider == "google" else "max_tokens" + kwargs[key] = _coerce_max_tokens(max_tokens) + return kwargs def _create_tool_nodes(self) -> dict[str, ToolNode]: @@ -251,13 +273,16 @@ class TradingAgentsGraph: def _fetch_returns( self, ticker: str, trade_date: str, holding_days: int = 5, benchmark: str = "SPY", - ) -> tuple[float | None, float | None, int | None]: + ) -> tuple[float | None, float | None, int | None, str | None]: """Fetch raw and alpha return for ticker over holding_days from trade_date. ``benchmark`` is the index used as the alpha baseline (resolved by the caller via ``_resolve_benchmark``). Returns ``(raw_return, alpha_return, - actual_holding_days)`` or ``(None, None, None)`` if price data is - unavailable (too recent, delisted, or network error). + holding_days, resolution_date)`` — where ``resolution_date`` is the date + of the last price bar used, i.e. when the outcome became known (#1251) — + or ``(None, None, None, None)`` when the outcome cannot be settled yet: + the full holding window has not traded (#1169), or the symbol is delisted + or unreachable. """ from tradingagents.dataflows.symbol_utils import normalize_symbol @@ -272,26 +297,31 @@ class TradingAgentsGraph: stock = yf.Ticker(normalize_symbol(ticker)).history(start=trade_date, end=end_str) bench = yf.Ticker(benchmark).history(start=trade_date, end=end_str) - if len(stock) < 2 or len(bench) < 2: - return None, None, None + # Require the full holding window in both series. A rerun before it + # has traded leaves the entry pending to retry next run, rather than + # settling on a premature partial return (#1169). + if len(stock) <= holding_days or len(bench) <= holding_days: + return None, None, None, None - actual_days = min(holding_days, len(stock) - 1, len(bench) - 1) raw = float( - (stock["Close"].iloc[actual_days] - stock["Close"].iloc[0]) + (stock["Close"].iloc[holding_days] - stock["Close"].iloc[0]) / stock["Close"].iloc[0] ) bench_ret = float( - (bench["Close"].iloc[actual_days] - bench["Close"].iloc[0]) + (bench["Close"].iloc[holding_days] - bench["Close"].iloc[0]) / bench["Close"].iloc[0] ) alpha = raw - bench_ret - return raw, alpha, actual_days + # The date of the last price bar used is when this outcome became + # known — the point-in-time cutoff for injecting the lesson (#1251). + resolution_date = stock.index[holding_days].strftime("%Y-%m-%d") + return raw, alpha, holding_days, resolution_date except Exception as e: logger.warning( "Could not resolve outcome for %s on %s vs %s (will retry next run): %s", ticker, trade_date, benchmark, e, ) - return None, None, None + return None, None, None, None def _resolve_pending_entries(self, ticker: str) -> None: """Resolve pending log entries for ticker at the start of a new run. @@ -310,7 +340,7 @@ class TradingAgentsGraph: benchmark = self._resolve_benchmark(ticker) updates = [] for entry in pending: - raw, alpha, days = self._fetch_returns( + raw, alpha, days, resolution_date = self._fetch_returns( ticker, entry["date"], benchmark=benchmark, ) if raw is None: @@ -328,6 +358,7 @@ class TradingAgentsGraph: "alpha_return": alpha, "holding_days": days, "reflection": reflection, + "resolution_date": resolution_date, }) if updates: @@ -345,6 +376,17 @@ class TradingAgentsGraph: identity = resolve_instrument_identity(ticker) return build_instrument_context(ticker, asset_type, identity) + def _memory_as_of(self, trade_date) -> str | None: + """Point-in-time cutoff for past-context lessons (#1251). + + A historical/backtest run (trade date before today) filters lessons to + those already resolved by the trade date. A current-date run returns + None, disabling the filter so live behavior and pre-migration entries + (which have no stored resolution date) are unaffected. + """ + td = str(trade_date) + return td if td < datetime.now().strftime("%Y-%m-%d") else None + def _run_signature(self, asset_type: str) -> str: """Graph-shape inputs that must invalidate a checkpoint if changed. @@ -368,38 +410,86 @@ class TradingAgentsGraph: ``checkpoint_enabled`` is set in config, the graph is recompiled with a per-ticker SqliteSaver so a crashed run can resume from the last successful node on a subsequent invocation with the same ticker+date. + + Returns ``(final_state, signal)`` where ``signal`` is one of the 5-tier + ratings (Buy / Overweight / Hold / Underweight / Sell) or ``"REVIEW"`` + when the decision had no parseable rating (#1170); guard with + ``tradingagents.agents.utils.rating.is_review`` before mapping it to the + PortfolioRating enum. """ self.ticker = company_name # Resolve any pending memory-log entries for this ticker before the pipeline runs. self._resolve_pending_entries(company_name) - # Recompile with a checkpointer if the user opted in. - if self.config.get("checkpoint_enabled"): - self._checkpointer_ctx = get_checkpointer( - self.config["data_cache_dir"], company_name + with self.checkpoint_scope(company_name, trade_date, asset_type) as thread_id_value: + return self._run_graph( + company_name, trade_date, asset_type=asset_type, + checkpoint_thread_id=thread_id_value, ) - saver = self._checkpointer_ctx.__enter__() - self.graph = self.workflow.compile(checkpointer=saver) - step = checkpoint_step( + def begin_checkpoint(self, company_name, trade_date, asset_type: str = "stock") -> str | None: + """Recompile the graph with a per-ticker checkpointer and return the + ``thread_id`` to inject into the stream/invoke ``config`` (or ``None`` + when checkpointing is disabled). + + Pair every call with :meth:`end_checkpoint` in a ``finally``. Both + ``propagate`` (via :meth:`checkpoint_scope`) and the CLI stream path use + this so ``--checkpoint`` actually resumes (#1249); previously the setup + lived only inside ``propagate`` and the CLI streamed the checkpointer-less + graph, making the flag a no-op. + """ + self._resuming = False + if not self.config.get("checkpoint_enabled"): + return None + signature = self._run_signature(asset_type) + self._checkpointer_ctx = get_checkpointer(self.config["data_cache_dir"], company_name) + saver = self._checkpointer_ctx.__enter__() + self.graph = self.workflow.compile(checkpointer=saver) + + step = checkpoint_step( + self.config["data_cache_dir"], company_name, str(trade_date), signature + ) + self._resuming = step is not None + if step is not None: + logger.info("Resuming from step %d for %s on %s", step, company_name, trade_date) + else: + logger.info("Starting fresh for %s on %s", company_name, trade_date) + return thread_id(company_name, str(trade_date), signature) + + def checkpoint_input(self, init_state): + """The value to stream/invoke: ``None`` to resume an existing checkpoint, + else the initial state for a fresh run. + + LangGraph resumes an interrupted thread when invoked with ``None``; + re-passing the initial state instead appends it through the message + reducer, duplicating messages in the resumed state (#1249). + """ + return None if self._resuming else init_state + + def end_checkpoint(self): + """Restore the plain uncheckpointed graph after a checkpointed run.""" + if self._checkpointer_ctx is not None: + self._checkpointer_ctx.__exit__(None, None, None) + self._checkpointer_ctx = None + self.graph = self.workflow.compile() + self._resuming = False + + @contextmanager + def checkpoint_scope(self, company_name, trade_date, asset_type: str = "stock"): + """Context-manager form of begin/end_checkpoint for the propagate path.""" + try: + yield self.begin_checkpoint(company_name, trade_date, asset_type) + finally: + self.end_checkpoint() + + def clear_checkpoint_on_success(self, company_name, trade_date, asset_type: str = "stock"): + """Drop a completed run's checkpoint so a later run starts fresh (#1249).""" + if self.config.get("checkpoint_enabled"): + clear_checkpoint( self.config["data_cache_dir"], company_name, str(trade_date), self._run_signature(asset_type), ) - if step is not None: - logger.info( - "Resuming from step %d for %s on %s", step, company_name, trade_date - ) - else: - logger.info("Starting fresh for %s on %s", company_name, trade_date) - - try: - return self._run_graph(company_name, trade_date, asset_type=asset_type) - finally: - if self._checkpointer_ctx is not None: - self._checkpointer_ctx.__exit__(None, None, None) - self._checkpointer_ctx = None - self.graph = self.workflow.compile() def save_reports(self, final_state, ticker, save_path=None) -> Path: """Write the markdown report tree for a completed run, like the CLI does. @@ -416,11 +506,16 @@ class TradingAgentsGraph: ) return write_report_tree(final_state, ticker, save_path) - def _run_graph(self, company_name, trade_date, asset_type: str = "stock"): + def _run_graph(self, company_name, trade_date, asset_type: str = "stock", + checkpoint_thread_id: str | None = None): """Execute the graph and write the resulting state to disk and memory log.""" # Initialize state — inject memory log context for PM and the - # deterministically resolved instrument identity for all agents. - past_context = self.memory_log.get_past_context(company_name) + # deterministically resolved instrument identity for all agents. On a + # historical run, gate lessons to those whose outcome was known by the + # trade date so a backtest can't learn from the future (#1251). + past_context = self.memory_log.get_past_context( + company_name, as_of=self._memory_as_of(trade_date) + ) instrument_context = self.resolve_instrument_context(company_name, asset_type) init_agent_state = self.propagator.create_initial_state( company_name, @@ -431,16 +526,17 @@ class TradingAgentsGraph: ) args = self.propagator.get_graph_args() - # Inject thread_id so same ticker+date+graph-shape resumes; a different - # date or graph shape starts fresh (#1089). - if self.config.get("checkpoint_enabled"): - tid = thread_id(company_name, str(trade_date), self._run_signature(asset_type)) - args.setdefault("config", {}).setdefault("configurable", {})["thread_id"] = tid + # Inject the checkpoint thread_id (from checkpoint_scope) so the same + # ticker+date+graph-shape resumes; a different one starts fresh (#1089). + if checkpoint_thread_id is not None: + args.setdefault("config", {}).setdefault("configurable", {})["thread_id"] = checkpoint_thread_id + # None resumes an existing checkpoint; init_agent_state starts fresh (#1249). + graph_input = self.checkpoint_input(init_agent_state) if self.debug: trace = [] last_printed = None - for chunk in self.graph.stream(init_agent_state, **args): + for chunk in self.graph.stream(graph_input, **args): if chunk["messages"]: msg = chunk["messages"][-1] # Nodes after the trader don't append to messages, so the @@ -457,7 +553,7 @@ class TradingAgentsGraph: for chunk in trace: final_state.update(chunk) else: - final_state = self.graph.invoke(init_agent_state, **args) + final_state = self.graph.invoke(graph_input, **args) # Store current state for reflection. self.curr_state = final_state @@ -473,11 +569,7 @@ class TradingAgentsGraph: ) # Clear checkpoint on successful completion to avoid stale state. - if self.config.get("checkpoint_enabled"): - clear_checkpoint( - self.config["data_cache_dir"], company_name, str(trade_date), - self._run_signature(asset_type), - ) + self.clear_checkpoint_on_success(company_name, trade_date, asset_type) return final_state, self.process_signal(final_state["final_trade_decision"]) diff --git a/tradingagents/llm_clients/capabilities.py b/tradingagents/llm_clients/capabilities.py index d3e9c5782..a2df7100a 100644 --- a/tradingagents/llm_clients/capabilities.py +++ b/tradingagents/llm_clients/capabilities.py @@ -118,6 +118,15 @@ _BY_PATTERN: list[tuple[re.Pattern[str], ModelCapabilities]] = [ def get_capabilities(model_name: str) -> ModelCapabilities: """Resolve capabilities by exact ID, then pattern, then default.""" + # OpenRouter namespaces official DeepSeek models as ``deepseek/``, so + # strip that prefix to reuse the same quirks as the native provider — e.g. + # ``deepseek/deepseek-v4-flash`` must suppress tool_choice like + # ``deepseek-v4-flash`` does, not fall through to _DEFAULT (#1199). Only the + # official namespace is stripped; third-party finetunes on other publishers + # (e.g. ``tngtech/deepseek-...``) keep _DEFAULT, since their quirks are unknown. + if model_name.startswith("deepseek/"): + model_name = model_name.removeprefix("deepseek/") + if model_name in _BY_ID: return _BY_ID[model_name] for pattern, caps in _BY_PATTERN: diff --git a/tradingagents/llm_clients/google_client.py b/tradingagents/llm_clients/google_client.py index 93bb1d11a..19ae8a213 100644 --- a/tradingagents/llm_clients/google_client.py +++ b/tradingagents/llm_clients/google_client.py @@ -31,7 +31,8 @@ class GoogleClient(BaseLLMClient): if self.base_url: llm_kwargs["base_url"] = self.base_url - for key in ("timeout", "max_retries", "temperature", "callbacks", "http_client", "http_async_client"): + for key in ("timeout", "max_retries", "temperature", "max_output_tokens", + "callbacks", "http_client", "http_async_client"): if key in self.kwargs: llm_kwargs[key] = self.kwargs[key] diff --git a/tradingagents/llm_clients/model_catalog.py b/tradingagents/llm_clients/model_catalog.py index 3dd717151..b058fc302 100644 --- a/tradingagents/llm_clients/model_catalog.py +++ b/tradingagents/llm_clients/model_catalog.py @@ -18,15 +18,15 @@ _CUSTOM_ONLY: dict[str, list[ModelOption]] = { # All GLM 4.7+ entries support thinking mode via thinking={"type":"enabled"}. _GLM_MODELS: dict[str, list[ModelOption]] = { "quick": [ + ("GLM-5.3-Flash - Fast, cost-efficient, 1M ctx", "glm-5.3-flash"), ("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.2 - Latest flagship, 1M ctx", "glm-5.2"), + ("GLM-5.3 - Latest flagship, 1M ctx", "glm-5.3"), + ("GLM-5.2 - 744B, 1M ctx", "glm-5.2"), ("GLM-5.1 - 745B, 200K ctx", "glm-5.1"), - ("GLM-5 - Flagship, 204K ctx", "glm-5"), ("GLM-4.7 - Previous-gen flagship", "glm-4.7"), ("Custom model ID", "custom"), ], @@ -81,15 +81,15 @@ _MINIMAX_MODELS: dict[str, list[ModelOption]] = { MODEL_OPTIONS: ProviderModeOptions = { "openai": { "quick": [ + ("GPT-5.6 Luna - Fast, cost-efficient frontier", "gpt-5.6-luna"), + ("GPT-5.6 Terra - Balances intelligence and cost", "gpt-5.6-terra"), ("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.5 - Latest frontier, 1M context", "gpt-5.5"), ], "deep": [ - ("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.5 Pro - Most capable, expensive ($30/$180 per 1M tokens)", "gpt-5.5-pro"), + ("GPT-5.6 - Latest frontier reasoning (Sol)", "gpt-5.6"), + ("GPT-5.6 Terra - Balances intelligence and cost", "gpt-5.6-terra"), + ("GPT-5.5 - Previous-gen frontier, 1M context", "gpt-5.5"), + ("GPT-5.4 - Cost-effective, 1M context", "gpt-5.4"), ], }, "anthropic": { diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py index bf6e55457..5f78eb6e6 100644 --- a/tradingagents/llm_clients/openai_client.py +++ b/tradingagents/llm_clients/openai_client.py @@ -164,7 +164,7 @@ class MinimaxChatOpenAI(NormalizedChatOpenAI): # Kwargs forwarded from user config to ChatOpenAI _PASSTHROUGH_KWARGS = ( - "timeout", "max_retries", "reasoning_effort", "temperature", + "timeout", "max_retries", "reasoning_effort", "temperature", "max_tokens", "api_key", "callbacks", "http_client", "http_async_client", )