mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-09-19 11:15:24 +03:00
@@ -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
|
||||
|
||||
57
CHANGELOG.md
57
CHANGELOG.md
@@ -6,6 +6,63 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
Breaking changes within the 0.x line are called out explicitly.
|
||||
|
||||
## [0.4.0] — 2026-08-31
|
||||
|
||||
Look-ahead and point-in-time fixes across the data and memory layers, clearer
|
||||
decision signals, working CLI checkpoint resume, and the GPT-5.6 / GLM-5.3 models.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **FRED macro look-ahead.** Historical macro requests were served from today's
|
||||
data vintage, leaking later revisions into a backtest; both the observations
|
||||
and metadata requests now pin the vintage to the as-of date. (#1275)
|
||||
- **Social sentiment look-ahead.** StockTwits and Reddit were fetched with no
|
||||
date, so a historical run showed today's chatter as if it were from the as-of
|
||||
date; the social path is now trimmed to the analysis window, via one shared
|
||||
UTC half-open window rule (`dataflows/date_window`) used by news too. (#1220)
|
||||
- **Memory point-in-time guard.** `get_past_context` returned every resolved
|
||||
lesson regardless of the run date; each resolved entry now records the date
|
||||
its outcome became known, and a historical run only sees lessons resolved by
|
||||
the trade date. (#1251)
|
||||
- **Premature reflection.** A decision was settled on a partial return if a rerun
|
||||
happened before its holding window fully traded; resolution now waits for the
|
||||
full window. (#1169)
|
||||
- **Latest OHLCV bar dropped.** The newest bar with a NaN close was silently
|
||||
dropped before the date cutoff, making the previous trading day look like the
|
||||
latest; dates are normalized per element (DST- and non-US-market safe) and a
|
||||
missing latest close raises rather than falling back. (#1201)
|
||||
- **Debate opening fabrication.** The first speaker in each debate round rebutted
|
||||
an empty opponent response, fabricating the other side; all five debators now
|
||||
open with their own case when no opponent has spoken. (#1176)
|
||||
- **Silent Hold.** An unparseable Portfolio Manager rating (including a fullwidth
|
||||
colon) was coerced to a tradeable Hold; it now surfaces a `REVIEW` sentinel,
|
||||
with `parse_rating` keeping its silent default for compatibility callers. (#1170)
|
||||
- **`--checkpoint` was a no-op on the CLI.** Checkpoint setup lived only in
|
||||
`propagate()`; the CLI streamed the checkpointer-less graph. The lifecycle is
|
||||
now shared, and a resume feeds `None` so LangGraph continues the interrupted
|
||||
run instead of duplicating messages. (#1249)
|
||||
- **DeepSeek via OpenRouter.** `deepseek/<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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
207
cli/main.py
207
cli/main.py
@@ -1127,109 +1127,130 @@ def run_analysis(checkpoint: bool | None = None):
|
||||
# (LLM tracking is handled separately via LLM constructor)
|
||||
args = graph.propagator.get_graph_args(callbacks=[stats_handler])
|
||||
|
||||
# Stream the analysis
|
||||
# Recompile with a checkpointer and inject the thread_id so --checkpoint
|
||||
# actually saves and resumes on the CLI path (#1249); a no-op when
|
||||
# checkpointing is disabled. Torn down in the finally below.
|
||||
checkpoint_tid = graph.begin_checkpoint(
|
||||
selections["ticker"], selections["analysis_date"], selections["asset_type"]
|
||||
)
|
||||
if checkpoint_tid is not None:
|
||||
args.setdefault("config", {}).setdefault("configurable", {})["thread_id"] = checkpoint_tid
|
||||
|
||||
# Stream the analysis. On resume, feed None so LangGraph continues the
|
||||
# interrupted run instead of re-appending the initial state (#1249); the
|
||||
# try/finally tears the checkpointer down even if the stream raises.
|
||||
trace = []
|
||||
for chunk in graph.graph.stream(init_agent_state, **args):
|
||||
# Process all messages in chunk, deduplicating by message ID
|
||||
for message in chunk.get("messages", []):
|
||||
msg_id = getattr(message, "id", None)
|
||||
if msg_id is not None:
|
||||
if msg_id in message_buffer._processed_message_ids:
|
||||
continue
|
||||
message_buffer._processed_message_ids.add(msg_id)
|
||||
try:
|
||||
for chunk in graph.graph.stream(graph.checkpoint_input(init_agent_state), **args):
|
||||
# Process all messages in chunk, deduplicating by message ID
|
||||
for message in chunk.get("messages", []):
|
||||
msg_id = getattr(message, "id", None)
|
||||
if msg_id is not None:
|
||||
if msg_id in message_buffer._processed_message_ids:
|
||||
continue
|
||||
message_buffer._processed_message_ids.add(msg_id)
|
||||
|
||||
msg_type, content = classify_message_type(message)
|
||||
if content and content.strip():
|
||||
message_buffer.add_message(msg_type, content)
|
||||
msg_type, content = classify_message_type(message)
|
||||
if content and content.strip():
|
||||
message_buffer.add_message(msg_type, content)
|
||||
|
||||
if hasattr(message, "tool_calls") and message.tool_calls:
|
||||
for tool_call in message.tool_calls:
|
||||
if isinstance(tool_call, dict):
|
||||
message_buffer.add_tool_call(tool_call["name"], tool_call["args"])
|
||||
else:
|
||||
message_buffer.add_tool_call(tool_call.name, tool_call.args)
|
||||
if hasattr(message, "tool_calls") and message.tool_calls:
|
||||
for tool_call in message.tool_calls:
|
||||
if isinstance(tool_call, dict):
|
||||
message_buffer.add_tool_call(tool_call["name"], tool_call["args"])
|
||||
else:
|
||||
message_buffer.add_tool_call(tool_call.name, tool_call.args)
|
||||
|
||||
# Update analyst statuses based on report state (runs on every chunk)
|
||||
update_analyst_statuses(
|
||||
message_buffer,
|
||||
chunk,
|
||||
wall_time_tracker=analyst_wall_time_tracker,
|
||||
)
|
||||
|
||||
# Research Team - Handle Investment Debate State
|
||||
if chunk.get("investment_debate_state"):
|
||||
debate_state = chunk["investment_debate_state"]
|
||||
bull_hist = debate_state.get("bull_history", "").strip()
|
||||
bear_hist = debate_state.get("bear_history", "").strip()
|
||||
judge = debate_state.get("judge_decision", "").strip()
|
||||
|
||||
# Only update status when there's actual content
|
||||
if bull_hist or bear_hist:
|
||||
update_research_team_status("in_progress")
|
||||
if bull_hist:
|
||||
message_buffer.update_report_section(
|
||||
"investment_plan", f"### Bull Researcher Analysis\n{bull_hist}"
|
||||
)
|
||||
if bear_hist:
|
||||
message_buffer.update_report_section(
|
||||
"investment_plan", f"### Bear Researcher Analysis\n{bear_hist}"
|
||||
)
|
||||
if judge:
|
||||
message_buffer.update_report_section(
|
||||
"investment_plan", f"### Research Manager Decision\n{judge}"
|
||||
)
|
||||
update_research_team_status("completed")
|
||||
message_buffer.update_agent_status("Trader", "in_progress")
|
||||
|
||||
# Trading Team
|
||||
if chunk.get("trader_investment_plan"):
|
||||
message_buffer.update_report_section(
|
||||
"trader_investment_plan", chunk["trader_investment_plan"]
|
||||
# Update analyst statuses based on report state (runs on every chunk)
|
||||
update_analyst_statuses(
|
||||
message_buffer,
|
||||
chunk,
|
||||
wall_time_tracker=analyst_wall_time_tracker,
|
||||
)
|
||||
if message_buffer.agent_status.get("Trader") != "completed":
|
||||
message_buffer.update_agent_status("Trader", "completed")
|
||||
message_buffer.update_agent_status("Aggressive Analyst", "in_progress")
|
||||
|
||||
# Risk Management Team - Handle Risk Debate State
|
||||
if chunk.get("risk_debate_state"):
|
||||
risk_state = chunk["risk_debate_state"]
|
||||
agg_hist = risk_state.get("aggressive_history", "").strip()
|
||||
con_hist = risk_state.get("conservative_history", "").strip()
|
||||
neu_hist = risk_state.get("neutral_history", "").strip()
|
||||
judge = risk_state.get("judge_decision", "").strip()
|
||||
# Research Team - Handle Investment Debate State
|
||||
if chunk.get("investment_debate_state"):
|
||||
debate_state = chunk["investment_debate_state"]
|
||||
bull_hist = debate_state.get("bull_history", "").strip()
|
||||
bear_hist = debate_state.get("bear_history", "").strip()
|
||||
judge = debate_state.get("judge_decision", "").strip()
|
||||
|
||||
if agg_hist:
|
||||
if message_buffer.agent_status.get("Aggressive Analyst") != "completed":
|
||||
# Only update status when there's actual content
|
||||
if bull_hist or bear_hist:
|
||||
update_research_team_status("in_progress")
|
||||
if bull_hist:
|
||||
message_buffer.update_report_section(
|
||||
"investment_plan", f"### Bull Researcher Analysis\n{bull_hist}"
|
||||
)
|
||||
if bear_hist:
|
||||
message_buffer.update_report_section(
|
||||
"investment_plan", f"### Bear Researcher Analysis\n{bear_hist}"
|
||||
)
|
||||
if judge:
|
||||
message_buffer.update_report_section(
|
||||
"investment_plan", f"### Research Manager Decision\n{judge}"
|
||||
)
|
||||
update_research_team_status("completed")
|
||||
message_buffer.update_agent_status("Trader", "in_progress")
|
||||
|
||||
# Trading Team
|
||||
if chunk.get("trader_investment_plan"):
|
||||
message_buffer.update_report_section(
|
||||
"trader_investment_plan", chunk["trader_investment_plan"]
|
||||
)
|
||||
if message_buffer.agent_status.get("Trader") != "completed":
|
||||
message_buffer.update_agent_status("Trader", "completed")
|
||||
message_buffer.update_agent_status("Aggressive Analyst", "in_progress")
|
||||
message_buffer.update_report_section(
|
||||
"final_trade_decision", f"### Aggressive Analyst Analysis\n{agg_hist}"
|
||||
)
|
||||
if con_hist:
|
||||
if message_buffer.agent_status.get("Conservative Analyst") != "completed":
|
||||
message_buffer.update_agent_status("Conservative Analyst", "in_progress")
|
||||
message_buffer.update_report_section(
|
||||
"final_trade_decision", f"### Conservative Analyst Analysis\n{con_hist}"
|
||||
)
|
||||
if neu_hist:
|
||||
if message_buffer.agent_status.get("Neutral Analyst") != "completed":
|
||||
message_buffer.update_agent_status("Neutral Analyst", "in_progress")
|
||||
message_buffer.update_report_section(
|
||||
"final_trade_decision", f"### Neutral Analyst Analysis\n{neu_hist}"
|
||||
)
|
||||
if judge and message_buffer.agent_status.get("Portfolio Manager") != "completed":
|
||||
message_buffer.update_agent_status("Portfolio Manager", "in_progress")
|
||||
message_buffer.update_report_section(
|
||||
"final_trade_decision", f"### Portfolio Manager Decision\n{judge}"
|
||||
)
|
||||
message_buffer.update_agent_status("Aggressive Analyst", "completed")
|
||||
message_buffer.update_agent_status("Conservative Analyst", "completed")
|
||||
message_buffer.update_agent_status("Neutral Analyst", "completed")
|
||||
message_buffer.update_agent_status("Portfolio Manager", "completed")
|
||||
|
||||
# Update the display
|
||||
update_display(layout, stats_handler=stats_handler, start_time=start_time)
|
||||
# Risk Management Team - Handle Risk Debate State
|
||||
if chunk.get("risk_debate_state"):
|
||||
risk_state = chunk["risk_debate_state"]
|
||||
agg_hist = risk_state.get("aggressive_history", "").strip()
|
||||
con_hist = risk_state.get("conservative_history", "").strip()
|
||||
neu_hist = risk_state.get("neutral_history", "").strip()
|
||||
judge = risk_state.get("judge_decision", "").strip()
|
||||
|
||||
trace.append(chunk)
|
||||
if agg_hist:
|
||||
if message_buffer.agent_status.get("Aggressive Analyst") != "completed":
|
||||
message_buffer.update_agent_status("Aggressive Analyst", "in_progress")
|
||||
message_buffer.update_report_section(
|
||||
"final_trade_decision", f"### Aggressive Analyst Analysis\n{agg_hist}"
|
||||
)
|
||||
if con_hist:
|
||||
if message_buffer.agent_status.get("Conservative Analyst") != "completed":
|
||||
message_buffer.update_agent_status("Conservative Analyst", "in_progress")
|
||||
message_buffer.update_report_section(
|
||||
"final_trade_decision", f"### Conservative Analyst Analysis\n{con_hist}"
|
||||
)
|
||||
if neu_hist:
|
||||
if message_buffer.agent_status.get("Neutral Analyst") != "completed":
|
||||
message_buffer.update_agent_status("Neutral Analyst", "in_progress")
|
||||
message_buffer.update_report_section(
|
||||
"final_trade_decision", f"### Neutral Analyst Analysis\n{neu_hist}"
|
||||
)
|
||||
if judge and message_buffer.agent_status.get("Portfolio Manager") != "completed":
|
||||
message_buffer.update_agent_status("Portfolio Manager", "in_progress")
|
||||
message_buffer.update_report_section(
|
||||
"final_trade_decision", f"### Portfolio Manager Decision\n{judge}"
|
||||
)
|
||||
message_buffer.update_agent_status("Aggressive Analyst", "completed")
|
||||
message_buffer.update_agent_status("Conservative Analyst", "completed")
|
||||
message_buffer.update_agent_status("Neutral Analyst", "completed")
|
||||
message_buffer.update_agent_status("Portfolio Manager", "completed")
|
||||
|
||||
# Update the display
|
||||
update_display(layout, stats_handler=stats_handler, start_time=start_time)
|
||||
|
||||
trace.append(chunk)
|
||||
|
||||
# Clean run: drop this run's checkpoint so a later run starts fresh.
|
||||
# A mid-stream failure skips this, keeping the checkpoint for resume.
|
||||
graph.clear_checkpoint_on_success(
|
||||
selections["ticker"], selections["analysis_date"], selections["asset_type"]
|
||||
)
|
||||
finally:
|
||||
# Always restore the plain uncheckpointed graph, even on failure.
|
||||
graph.end_checkpoint()
|
||||
|
||||
# Streamed chunks are per-node deltas, not full state. Merge them
|
||||
# so every report field populated across the run is present.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
155
tests/test_checkpoint_lifecycle.py
Normal file
155
tests/test_checkpoint_lifecycle.py
Normal 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
|
||||
111
tests/test_debate_opening.py
Normal file
111
tests/test_debate_opening.py
Normal 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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
122
tests/test_llm_max_tokens.py
Normal file
122
tests/test_llm_max_tokens.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
95
tests/test_memory_pointintime.py
Normal file
95
tests/test_memory_pointintime.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
136
tests/test_ohlcv_latest_bar.py
Normal file
136
tests/test_ohlcv_latest_bar.py
Normal 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")
|
||||
@@ -10,7 +10,13 @@ to it.
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.agents.utils.rating import RATINGS_5_TIER, parse_rating
|
||||
from tradingagents.agents.utils.rating import (
|
||||
RATING_REVIEW,
|
||||
RATINGS_5_TIER,
|
||||
extract_rating,
|
||||
is_review,
|
||||
parse_rating,
|
||||
)
|
||||
from tradingagents.graph.signal_processing import SignalProcessor
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -84,6 +90,51 @@ class TestSignalProcessor:
|
||||
llm.invoke.assert_not_called()
|
||||
llm.with_structured_output.assert_not_called()
|
||||
|
||||
def test_default_when_no_rating_present(self):
|
||||
def test_unparseable_signal_is_review_not_silent_hold(self):
|
||||
# #1170: an unrecognizable decision must surface REVIEW, not a fabricated
|
||||
# tradeable Hold.
|
||||
sp = SignalProcessor()
|
||||
assert sp.process_signal("Plain prose without a recommendation.") == "Hold"
|
||||
signal = sp.process_signal("Plain prose without a recommendation.")
|
||||
assert signal == RATING_REVIEW
|
||||
assert is_review(signal)
|
||||
assert signal not in RATINGS_5_TIER
|
||||
|
||||
def test_fullwidth_colon_is_parsed_not_reviewed(self):
|
||||
# #1170: `Rating:Overweight` (fullwidth colon) used to defeat the regex
|
||||
# and silently become Hold; NFKC normalization now parses it.
|
||||
sp = SignalProcessor()
|
||||
assert sp.process_signal("Rating:Overweight\n理由はこちら。") == "Overweight"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestExtractRating:
|
||||
def test_returns_none_when_absent(self):
|
||||
assert extract_rating("No directional call here.") is None
|
||||
assert extract_rating("") is None
|
||||
|
||||
def test_whole_word_only(self):
|
||||
# substrings inside larger words must not match
|
||||
assert extract_rating("The buyer was holding shares.") is None
|
||||
|
||||
def test_parse_rating_keeps_silent_default_for_compat(self):
|
||||
# parse_rating (used by the memory log) intentionally keeps Hold default.
|
||||
assert parse_rating("No rating here.") == "Hold"
|
||||
assert parse_rating("No rating here.", default="Underweight") == "Underweight"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGraphSignalContract:
|
||||
"""The graph-facing signal (TradingAgentsGraph.process_signal) honors the
|
||||
documented "5-tier or REVIEW" contract, not just the parser in isolation."""
|
||||
|
||||
def _bare_graph(self):
|
||||
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||
g = object.__new__(TradingAgentsGraph)
|
||||
g.signal_processor = SignalProcessor()
|
||||
return g
|
||||
|
||||
def test_graph_surfaces_review(self):
|
||||
assert self._bare_graph().process_signal("no rating in here") == RATING_REVIEW
|
||||
|
||||
def test_graph_returns_rating(self):
|
||||
assert self._bare_graph().process_signal("**Rating**: Sell") == "Sell"
|
||||
|
||||
121
tests/test_social_lookahead.py
Normal file
121
tests/test_social_lookahead.py
Normal 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
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -7,42 +7,77 @@ The same five-tier scale (Buy, Overweight, Hold, Underweight, Sell) is used by:
|
||||
- The memory log (rating tag stored alongside each decision entry)
|
||||
|
||||
Centralising it here avoids drift between those call sites.
|
||||
|
||||
``extract_rating`` returns ``None`` when no rating can be found, so the graph can
|
||||
surface an explicit ``REVIEW`` signal instead of a fabricated ``Hold`` (#1170).
|
||||
``parse_rating`` keeps the legacy silent-default behaviour for callers (e.g. the
|
||||
memory log) that need a rating string regardless.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
# Canonical, ordered 5-tier scale (most bullish to most bearish).
|
||||
RATINGS_5_TIER: tuple[str, ...] = (
|
||||
"Buy", "Overweight", "Hold", "Underweight", "Sell",
|
||||
)
|
||||
|
||||
# Signal emitted when the model's decision has no recognizable rating. It is not
|
||||
# a tradeable position: it flags output that needs a human/re-run rather than
|
||||
# silently degrading to Hold. Callers that map the signal onto the 5-tier enum
|
||||
# (e.g. ``PortfolioRating(signal)``) should guard with ``is_review`` first.
|
||||
RATING_REVIEW = "REVIEW"
|
||||
|
||||
_RATING_SET = {r.lower() for r in RATINGS_5_TIER}
|
||||
|
||||
# Matches "Rating: X" / "rating - X" / "Rating: **X**" — tolerates markdown
|
||||
# bold wrappers and either a colon or hyphen separator.
|
||||
_RATING_LABEL_RE = re.compile(r"rating.*?[:\-][\s*]*(\w+)", re.IGNORECASE)
|
||||
|
||||
# Standalone 5-tier word anywhere (word boundaries so "Buyer"/"Holding" don't match).
|
||||
_RATING_WORD_RE = re.compile(
|
||||
r"\b(" + "|".join(RATINGS_5_TIER) + r")\b", re.IGNORECASE
|
||||
)
|
||||
|
||||
def parse_rating(text: str, default: str = "Hold") -> str:
|
||||
"""Heuristically extract a 5-tier rating from prose text.
|
||||
|
||||
Two-pass strategy:
|
||||
1. Look for an explicit "Rating: X" label (tolerant of markdown bold).
|
||||
2. Fall back to the first 5-tier rating word found anywhere in the text.
|
||||
def extract_rating(text: str) -> str | None:
|
||||
"""Extract a 5-tier rating from prose, or ``None`` if none is present.
|
||||
|
||||
Returns a Title-cased rating string, or ``default`` if no rating word appears.
|
||||
Two-pass strategy on the NFKC-normalized text (so fullwidth punctuation like
|
||||
``Rating:Overweight`` is matched the same as ASCII):
|
||||
1. An explicit "Rating: X" label (tolerant of markdown bold).
|
||||
2. The first standalone 5-tier rating word found anywhere.
|
||||
"""
|
||||
for line in text.splitlines():
|
||||
if not text:
|
||||
return None
|
||||
norm = unicodedata.normalize("NFKC", text)
|
||||
|
||||
for line in norm.splitlines():
|
||||
m = _RATING_LABEL_RE.search(line)
|
||||
if m and m.group(1).lower() in _RATING_SET:
|
||||
return m.group(1).capitalize()
|
||||
|
||||
for line in text.splitlines():
|
||||
for word in line.lower().split():
|
||||
clean = word.strip("*:.,")
|
||||
if clean in _RATING_SET:
|
||||
return clean.capitalize()
|
||||
m = _RATING_WORD_RE.search(norm)
|
||||
if m:
|
||||
return m.group(1).capitalize()
|
||||
|
||||
return default
|
||||
return None
|
||||
|
||||
|
||||
def parse_rating(text: str, default: str = "Hold") -> str:
|
||||
"""Extract a 5-tier rating, falling back to ``default`` when none is found.
|
||||
|
||||
Legacy convenience wrapper: it always returns a rating string, so an
|
||||
unparseable decision silently becomes ``default`` (``Hold``). Callers that
|
||||
must distinguish "no rating" from a real Hold should use
|
||||
:func:`extract_rating` (or the graph's REVIEW-surfacing signal) instead.
|
||||
"""
|
||||
rating = extract_rating(text)
|
||||
return rating if rating is not None else default
|
||||
|
||||
|
||||
def is_review(signal: str) -> bool:
|
||||
"""Whether a signal is the non-tradeable REVIEW sentinel (#1170)."""
|
||||
return signal == RATING_REVIEW
|
||||
|
||||
30
tradingagents/dataflows/date_window.py
Normal file
30
tradingagents/dataflows/date_window.py
Normal 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)
|
||||
@@ -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", [])
|
||||
|
||||
|
||||
@@ -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>")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user