Merge pull request #1280 from TauricResearch/v0.4.0

Release v0.4.0
This commit is contained in:
Yijia Xiao
2026-08-30 22:07:21 -05:00
committed by GitHub
43 changed files with 1642 additions and 276 deletions

View File

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

View File

@@ -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/<id>` 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,

View File

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

View File

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

View File

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

View File

@@ -116,6 +116,37 @@ class TestDefault:
assert caps.supports_tool_choice is True
@pytest.mark.unit
class TestOpenRouterDeepSeekNamespace:
"""OpenRouter namespaces DeepSeek as ``deepseek/<id>``; 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."""

View File

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

View File

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

View File

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

View File

@@ -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):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: `RatingOverweight` (fullwidth colon) used to defeat the regex
# and silently become Hold; NFKC normalization now parses it.
sp = SignalProcessor()
assert sp.process_signal("RatingOverweight\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"

View File

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

View File

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

View File

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

View File

@@ -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):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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."
),
},
]

View File

@@ -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):

View File

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

View File

@@ -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
``RatingOverweight`` 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

View File

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

View File

@@ -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", [])

View File

@@ -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}: <no posts found mentioning {ticker.upper()} in the past 7 days>")

View File

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

View File

@@ -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' ``<BASE>.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"<stocktwits unavailable: {type(exc).__name__}>"
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"<no StockTwits messages for ${ticker.upper()} within "
f"{start_date}..{end_date} (public stream serves only recent messages)>"
)
return f"<no StockTwits messages found for ${ticker.upper()}>"
lines = []

View File

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

View File

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

View File

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

View File

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

View File

@@ -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/<id>``, 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:

View File

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

View File

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

View File

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