21 Commits

Author SHA1 Message Date
Yijia-Xiao
c95f83dfaf chore: release v0.4.0
- look-ahead / point-in-time fixes across FRED macro, social sentiment, and the
  decision-log memory; clearer decision signals; CLI checkpoint resume; Trader
  price grounding
- GPT-5.6 / GLM-5.3 models
2026-08-31 03:02:39 +00:00
Yijia-Xiao
ecbe3e3a21 feat(llm): add the GPT-5.6 family and GLM-5.3
- GPT-5.6 (sol/terra/luna) is GA and OpenAI's recommended default; add it and
  make gpt-5.6 (deep) / gpt-5.6-luna (quick) the defaults
- add GLM-5.3 and GLM-5.3-Flash, Zhipu's current flagship line
2026-08-31 02:55:21 +00:00
Yijia-Xiao
e93c5c53c2 fix(agents): ground the Trader in the technical market report
- the Trader received only the digested investment plan, so its entry / stop /
  sizing levels were not anchored to real price structure (ATR, support and
  resistance, current price)
- inject the market report and instruct the Trader to take concrete price levels
  from it and direction/strategy from the plan; when the market analyst was not
  selected the report is empty, so the section and grounding note are omitted #1167
2026-08-31 02:35:50 +00:00
Yijia-Xiao
45c1744b86 fix(llm): apply DeepSeek capabilities to OpenRouter-namespaced models
- OpenRouter exposes DeepSeek as deepseek/<id>, which matched neither the exact
  IDs nor the patterns, so a thinking model like deepseek/deepseek-v4-flash fell
  through to _DEFAULT and had object-form tool_choice forced on it
- strip the official deepseek/ namespace before lookup so it reuses the native
  quirks; deepseek/deepseek-chat still keeps tool_choice, and third-party
  finetunes on other publishers stay on _DEFAULT #1199
2026-08-31 02:15:32 +00:00
Yijia-Xiao
63be7fe7f1 fix(dataflows): don't silently drop the latest OHLCV bar
- the latest in-range bar with a NaN close was dropped before the curr_date
  cutoff, so the previous trading day looked like the latest; dates were also
  compared without timezone normalization
- normalize bar dates and curr_date to naive midnight (per element, so 5-year
  ranges spanning DST and non-US positive-offset markets keep their local date),
  then raise NoMarketDataError on a missing latest close rather than falling back
- split the fill step (_fill_price_gaps) from date/price normalization so the
  latest bar can be inspected before incomplete rows are dropped #1201
2026-08-31 02:08:31 +00:00
Yijia-Xiao
30d42abd5d fix(memory): don't settle a decision before its holding window trades
- _fetch_returns settled on min(holding_days, available), so a rerun a day or
  two after a decision reflected on a 1-2 day partial return as if final
- require the full holding window in both the stock and benchmark series before
  resolving; otherwise leave the entry pending to retry next run
- this also makes the #1251 resolution date the full-window date, not a partial
  bar's #1169
2026-08-31 01:55:31 +00:00
Yijia-Xiao
a2f51da917 chore: remove dead code found in the v0.4.0 review
- inline _in_news_window, a trivial passthrough left from extracting
  dataflows.date_window.in_window; call in_window directly
- drop SignalProcessor's orphaned quick_thinking_llm attribute (unused since
  rating extraction became a deterministic parse)
2026-08-31 01:29:14 +00:00
Yijia-Xiao
b43bc31479 fix(cli): resume a checkpoint without duplicating messages or leaking the saver
- on resume, the CLI and propagate re-passed the initial state to a thread with
  an existing checkpoint; nodes do not re-run, but the message reducer appended
  the initial messages again, duplicating them in the resumed state
- feed None on resume (checkpoint_input) so LangGraph continues the interrupted
  run, and wrap the CLI stream in try/finally so the checkpointer tears down even
  if the stream raises
- correct the _fetch_returns docstring to the 4-tuple return #1249
2026-08-31 01:29:14 +00:00
Yijia-Xiao
8db41f6bca fix(memory): gate past-context lessons to point-in-time in backtests
- get_past_context returned every resolved lesson regardless of the run date, so
  a historical run could learn from an outcome that had not happened yet
- record each resolved entry's resolution date (the last price bar used) and
  filter get_past_context(as_of=trade_date) on it for a historical run; a
  current-date run passes None so live behavior and pre-migration entries (no
  stored resolution date, conservatively excluded from backtests) are unaffected #1251
2026-08-30 07:03:06 +00:00
Yijia-Xiao
51a245dbe1 fix(cli): make --checkpoint actually resume on the CLI path
- checkpoint setup lived only inside propagate(); the CLI streamed the
  checkpointer-less graph with no thread_id, so --checkpoint neither saved nor
  resumed a run
- extract the lifecycle into reusable begin_checkpoint / end_checkpoint /
  clear_checkpoint_on_success (checkpoint_scope wraps them for propagate) and use
  them around the CLI stream #1249
2026-08-30 06:47:00 +00:00
Yijia-Xiao
43fc275b36 fix(rating): surface an unparseable rating as REVIEW, not a silent Hold
- an unrecognizable Portfolio Manager decision was coerced to Hold, emitting a
  tradeable neutral signal that masked a parsing failure; a fullwidth colon
  (Rating:X) defeated the label regex and hit the same path
- add extract_rating() -> str | None with NFKC normalization and whole-word
  matching; the graph signal now yields a REVIEW sentinel (with an is_review
  guard) when no rating is found
- parse_rating keeps its silent default for compat callers (e.g. the memory log) #1170
2026-08-30 06:38:15 +00:00
Yijia-Xiao
0ef56e6a33 feat(llm): add a configurable output-token cap
- some model/gateway combinations emit unbounded reasoning/output and hang or
  trip an idle timeout (e.g. some deepseek-v4-flash deployments)
- add an opt-in max_tokens config knob + TRADINGAGENTS_MAX_TOKENS, forwarded to
  every provider when set (Gemini takes it as max_output_tokens); int-coerced,
  rejects non-positive/boolean values #1204
2026-08-30 06:26:57 +00:00
Yijia-Xiao
539eae8fd6 fix(agents): stop debate openers from rebutting a nonexistent argument
- the first speaker in each debate round received an empty opponent response,
  yet the prompt demanded a rebuttal, so models fabricated the other side
- substitute an explicit opening marker when an opponent has not spoken, across
  all five debators (bull, bear, and the three risk analysts) #1176
2026-08-30 06:18:59 +00:00
Yijia-Xiao
9b98f09613 fix(dataflows): trim social sentiment sources to the analysis window
- 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
- pass the analysis window to both fetchers, filter to it, and emit a clear
  placeholder when nothing qualifies
- centralize the UTC half-open window rule in dataflows/date_window so news,
  StockTwits, and Reddit share one look-ahead-safe filter #1220
2026-08-30 06:18:59 +00:00
Yijia-Xiao
8b7ece8a3e fix(dataflows): pin the FRED data vintage to the as-of date
- FRED defaults both realtime bounds to today, so historical macro requests
  served the latest revision and leaked future information into backtests
- set realtime_start=realtime_end=curr_date on both the metadata and
  observations requests #1275
2026-08-30 06:18:59 +00:00
Yijia-Xiao
a33fd4c0f1 docs: streamline README header 2026-07-18 15:55:04 +00:00
Yijia-Xiao
7bbe33ab1d docs: add trending badge 2026-07-18 15:23:59 +00:00
Yijia-Xiao
030b434585 fix(agents): stop priming tool calls in schema-only structured agents
- with_structured_output binds a single tool (the schema), so a primed model emitted
  an unknown web_search call and the attempt was discarded for a free-text retry,
  costing an extra round trip and the typed output
- drop the tool-range wording from the no-tool sentiment analyst and state the
  constraint once via a shared NO_EXTERNAL_TOOLS #1130
2026-07-18 06:28:38 +00:00
Yijia-Xiao
3f6c082695 fix(cli): report an unusable terminal instead of a prompt_toolkit traceback
- Windows terminals without a console buffer raised NoConsoleScreenBufferError
  before the first prompt, surfacing a raw traceback with no guidance
- gate the Windows-only import on sys.platform so a broken prompt_toolkit still
  surfaces there, and the handler stays inert on other platforms #1138
2026-07-18 06:28:38 +00:00
Yijia-Xiao
d78c698d0e fix(dataflows): refresh the same-day OHLCV cache
- the per-day cache was reused unconditionally, so a run started before the day's
  bar was final served that snapshot to every later run, feeding a stale close
  into technical analysis
- a present row is not sufficient either, since Yahoo publishes a partial intraday
  candle; a TTL now governs every current-day cache while historical caches stay
  immutable #1150
2026-07-18 06:28:38 +00:00
Yijia-Xiao
40774ca042 fix(dataflows): make the Yahoo news window UTC and end-exclusive
- the upper bound was inclusive, so an article stamped exactly midnight after
  end_date leaked into a historical run
- flat epoch timestamps were parsed in host-local time and offset-aware stamps had
  tzinfo stripped without converting, making filtering machine-dependent
- normalize every operand to UTC and use a half-open [start, end + 1 day) #1126
2026-07-18 06:28:38 +00:00
49 changed files with 2078 additions and 292 deletions

View File

@@ -61,6 +61,10 @@ NVIDIA_API_KEY=
# own default (usually 2). Raise it to ride out bursty 429 rate-limit throttling # 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. # on rate-limited deployments (e.g. Azure OpenAI) instead of aborting the run.
#TRADINGAGENTS_LLM_MAX_RETRIES=6 #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 # Provider-specific reasoning/thinking depth (optional; unset = provider
# default). Setting one also skips the matching interactive prompt. # default). Setting one also skips the matching interactive prompt.
#TRADINGAGENTS_OPENAI_REASONING_EFFORT=medium #TRADINGAGENTS_OPENAI_REASONING_EFFORT=medium

View File

@@ -6,6 +6,63 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 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. 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 ## [0.3.1] — 2026-07-05
Correctness and stability patch: data look-ahead, graph-router crash-safety, Correctness and stability patch: data look-ahead, graph-router crash-safety,

View File

@@ -5,12 +5,14 @@
<div align="center" style="line-height: 1;"> <div align="center" style="line-height: 1;">
<a href="https://arxiv.org/abs/2412.20138" target="_blank"><img alt="arXiv" src="https://img.shields.io/badge/arXiv-2412.20138-B31B1B?logo=arxiv"/></a> <a href="https://arxiv.org/abs/2412.20138" target="_blank"><img alt="arXiv" src="https://img.shields.io/badge/arXiv-2412.20138-B31B1B?logo=arxiv"/></a>
<a href="https://discord.com/invite/hk9PGKShPK" target="_blank"><img alt="Discord" src="https://img.shields.io/badge/Discord-TradingResearch-7289da?logo=discord&logoColor=white&color=7289da"/></a> <a href="https://discord.com/invite/hk9PGKShPK" target="_blank"><img alt="Discord" src="https://img.shields.io/badge/Discord-TradingResearch-7289da?logo=discord&logoColor=white&color=7289da"/></a>
<a href="./assets/wechat.png" target="_blank"><img alt="WeChat" src="https://img.shields.io/badge/WeChat-TauricResearch-brightgreen?logo=wechat&logoColor=white"/></a>
<a href="https://x.com/TauricResearch" target="_blank"><img alt="X Follow" src="https://img.shields.io/badge/X-TauricResearch-white?logo=x&logoColor=white"/></a> <a href="https://x.com/TauricResearch" target="_blank"><img alt="X Follow" src="https://img.shields.io/badge/X-TauricResearch-white?logo=x&logoColor=white"/></a>
<br> <a href="https://github.com/TauricResearch/" target="_blank"><img alt="Community" src="https://img.shields.io/badge/GitHub_Community-TauricResearch-14C290?logo=discourse"/></a>
<a href="https://github.com/TauricResearch/" target="_blank"><img alt="Community" src="https://img.shields.io/badge/Join_GitHub_Community-TauricResearch-14C290?logo=discourse"/></a>
</div> </div>
<br>
<div align="center">
<a href="https://github.com/TauricResearch" target="_blank"><img alt="TradingAgents #1 Repository of the Day" src="https://trendshift.io/api/badge/repositories/16192" width="250" height="55"/></a>
</div>
<br>
<div align="center"> <div align="center">
<!-- Keep these links. Translations will automatically update with the README. --> <!-- Keep these links. Translations will automatically update with the README. -->
<a href="https://www.readme-i18n.com/TauricResearch/TradingAgents?lang=de">Deutsch</a> | <a href="https://www.readme-i18n.com/TauricResearch/TradingAgents?lang=de">Deutsch</a> |
@@ -28,7 +30,8 @@
# TradingAgents: Multi-Agents LLM Financial Trading Framework # TradingAgents: Multi-Agents LLM Financial Trading Framework
## News ## 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-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-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. - [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.
@@ -38,25 +41,15 @@
- [2026-01] **Trading-R1** [Technical Report](https://arxiv.org/abs/2509.11420) released, with [Terminal](https://github.com/TauricResearch/Trading-R1) expected to land soon. - [2026-01] **Trading-R1** [Technical Report](https://arxiv.org/abs/2509.11420) released, with [Terminal](https://github.com/TauricResearch/Trading-R1) expected to land soon.
<div align="center"> <div align="center">
<a href="https://www.star-history.com/#TauricResearch/TradingAgents&Date">
<picture> 🚀 [TradingAgents](#tradingagents-framework) | ⚡ [Installation & CLI](#installation-and-cli) | 🎬 [Demo](https://www.youtube.com/watch?v=90gr5lwjIho) | 📦 [Package Usage](#tradingagents-package) | 🤝 [Contributing](#contributing) | 📄 [Citation](#citation)
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=TauricResearch/TradingAgents&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=TauricResearch/TradingAgents&type=Date" />
<img alt="TradingAgents Star History" src="https://api.star-history.com/svg?repos=TauricResearch/TradingAgents&type=Date" style="width: 80%; height: auto;" />
</picture>
</a>
</div> </div>
> 🎉 **TradingAgents** officially released! We have received numerous inquiries about the work, and we would like to express our thanks for the enthusiasm in our community. > 🎉 **TradingAgents** officially released! We have received numerous inquiries about the work, and we would like to express our thanks for the enthusiasm in our community.
> >
> So we decided to fully open-source the framework. Looking forward to building impactful projects with you! > So we decided to fully open-source the framework. Looking forward to building impactful projects with you!
<div align="center">
🚀 [TradingAgents](#tradingagents-framework) | ⚡ [Installation & CLI](#installation-and-cli) | 🎬 [Demo](https://www.youtube.com/watch?v=90gr5lwjIho) | 📦 [Package Usage](#tradingagents-package) | 🤝 [Contributing](#contributing) | 📄 [Citation](#citation)
</div>
## TradingAgents Framework ## TradingAgents Framework
TradingAgents is a multi-agent trading framework that mirrors the dynamics of real-world trading firms. By deploying specialized LLM-powered agents: from fundamental analysts, sentiment experts, and technical analysts, to trader, risk management team, the platform collaboratively evaluates market conditions and informs trading decisions. Moreover, these agents engage in dynamic discussions to pinpoint the optimal strategy. TradingAgents is a multi-agent trading framework that mirrors the dynamics of real-world trading firms. By deploying specialized LLM-powered agents: from fundamental analysts, sentiment experts, and technical analysts, to trader, risk management team, the platform collaboratively evaluates market conditions and informs trading decisions. Moreover, these agents engage in dynamic discussions to pinpoint the optimal strategy.
@@ -230,8 +223,8 @@ from tradingagents.default_config import DEFAULT_CONFIG
config = DEFAULT_CONFIG.copy() 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["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["deep_think_llm"] = "gpt-5.6" # Model for complex reasoning
config["quick_think_llm"] = "gpt-5.4-mini" # Model for quick tasks config["quick_think_llm"] = "gpt-5.6-luna" # Model for quick tasks
config["max_debate_rounds"] = 2 config["max_debate_rounds"] = 2
ta = TradingAgentsGraph(debug=True, config=config) ta = TradingAgentsGraph(debug=True, config=config)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

View File

@@ -1,5 +1,6 @@
import datetime import datetime
import os import os
import sys
import time import time
from collections import deque from collections import deque
from functools import wraps from functools import wraps
@@ -52,6 +53,18 @@ from tradingagents.reporting import write_report_tree
console = Console() console = Console()
# prompt_toolkit's win32 output module is importable only on Windows (it asserts
# the platform at import time), so gate on the platform rather than catching the
# failure — that way a genuinely broken prompt_toolkit on Windows still surfaces
# instead of silently disabling the handler below. Off Windows this stays an
# empty tuple, which `except` accepts and never matches (#1138).
if sys.platform == "win32": # pragma: no cover - platform dependent
from prompt_toolkit.output.win32 import NoConsoleScreenBufferError
_NO_CONSOLE_ERRORS: tuple[type[BaseException], ...] = (NoConsoleScreenBufferError,)
else:
_NO_CONSOLE_ERRORS = ()
app = typer.Typer( app = typer.Typer(
name="TradingAgents", name="TradingAgents",
help="TradingAgents CLI: Multi-Agents LLM Financial Trading Framework", help="TradingAgents CLI: Multi-Agents LLM Financial Trading Framework",
@@ -1114,9 +1127,21 @@ def run_analysis(checkpoint: bool | None = None):
# (LLM tracking is handled separately via LLM constructor) # (LLM tracking is handled separately via LLM constructor)
args = graph.propagator.get_graph_args(callbacks=[stats_handler]) 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 = [] trace = []
for chunk in graph.graph.stream(init_agent_state, **args): try:
for chunk in graph.graph.stream(graph.checkpoint_input(init_agent_state), **args):
# Process all messages in chunk, deduplicating by message ID # Process all messages in chunk, deduplicating by message ID
for message in chunk.get("messages", []): for message in chunk.get("messages", []):
msg_id = getattr(message, "id", None) msg_id = getattr(message, "id", None)
@@ -1218,6 +1243,15 @@ def run_analysis(checkpoint: bool | None = None):
trace.append(chunk) 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 # Streamed chunks are per-node deltas, not full state. Merge them
# so every report field populated across the run is present. # so every report field populated across the run is present.
final_state = {} final_state = {}
@@ -1285,7 +1319,19 @@ def analyze(
from tradingagents.graph.checkpointer import clear_all_checkpoints from tradingagents.graph.checkpointer import clear_all_checkpoints
n = clear_all_checkpoints(DEFAULT_CONFIG["data_cache_dir"]) n = clear_all_checkpoints(DEFAULT_CONFIG["data_cache_dir"])
console.print(f"[yellow]Cleared {n} checkpoint(s).[/yellow]") console.print(f"[yellow]Cleared {n} checkpoint(s).[/yellow]")
try:
run_analysis(checkpoint=checkpoint) run_analysis(checkpoint=checkpoint)
except _NO_CONSOLE_ERRORS:
# A terminal with no console buffer cannot host the interactive prompts.
# Emit one actionable line on stderr instead of a prompt_toolkit
# traceback; plain text, since rich may not render here either (#1138).
typer.echo(
"Error: no Windows console available. The interactive CLI needs a real "
"console buffer — run it from Windows Terminal, PowerShell, or cmd.exe "
"rather than a piped or embedded terminal.",
err=True,
)
raise typer.Exit(code=1) from None
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tradingagents" name = "tradingagents"
version = "0.3.1" version = "0.4.0"
description = "TradingAgents: Multi-Agents LLM Financial Trading Framework" description = "TradingAgents: Multi-Agents LLM Financial Trading Framework"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View File

@@ -116,6 +116,37 @@ class TestDefault:
assert caps.supports_tool_choice is True 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 @pytest.mark.unit
def test_capabilities_dataclass_is_frozen(): def test_capabilities_dataclass_is_frozen():
"""Capability rows are immutable so they can be safely shared.""" """Capability rows are immutable so they can be safely shared."""

View File

@@ -0,0 +1,155 @@
"""The checkpoint lifecycle is reusable so --checkpoint works on the CLI path (#1249).
Checkpoint setup previously lived only inside ``propagate``; the CLI streamed the
checkpointer-less graph, so ``--checkpoint`` neither saved nor resumed. The
lifecycle is now ``begin_checkpoint`` / ``end_checkpoint`` /
``clear_checkpoint_on_success`` on TradingAgentsGraph, used by both paths. These
tests drive that lifecycle exactly as the CLI does (begin -> stream self.graph ->
clear/end) and prove state is saved and resumed.
"""
from __future__ import annotations
import tempfile
from typing import TypedDict
import pytest
from langgraph.graph import END, StateGraph
from tradingagents.graph.checkpointer import checkpoint_step
from tradingagents.graph.trading_graph import TradingAgentsGraph
_should_crash = False
class _State(TypedDict):
count: int
def _node_a(state: _State) -> dict:
return {"count": state["count"] + 1}
def _node_b(state: _State) -> dict:
if _should_crash:
raise RuntimeError("simulated mid-stream crash")
return {"count": state["count"] + 10}
def _workflow() -> StateGraph:
b = StateGraph(_State)
b.add_node("analyst", _node_a)
b.add_node("trader", _node_b)
b.set_entry_point("analyst")
b.add_edge("analyst", "trader")
b.add_edge("trader", END)
return b
def _bare_graph(tmpdir, *, enabled=True):
g = object.__new__(TradingAgentsGraph)
g.config = {
"checkpoint_enabled": enabled, "data_cache_dir": tmpdir,
"max_debate_rounds": 1, "max_risk_discuss_rounds": 1,
}
g.selected_analysts = ("market",)
g.workflow = _workflow()
g.graph = g.workflow.compile()
g._checkpointer_ctx = None
return g
@pytest.mark.unit
def test_disabled_is_a_noop():
with tempfile.TemporaryDirectory() as tmp:
g = _bare_graph(tmp, enabled=False)
plain = g.graph
assert g.begin_checkpoint("AAPL", "2026-05-08", "stock") is None
assert g.graph is plain # graph not recompiled
g.end_checkpoint() # safe no-op
@pytest.mark.unit
def test_begin_returns_thread_id_and_recompiles():
with tempfile.TemporaryDirectory() as tmp:
g = _bare_graph(tmp)
plain = g.graph
tid = g.begin_checkpoint("AAPL", "2026-05-08", "stock")
try:
assert tid # a real thread_id
assert g.graph is not plain # recompiled with a checkpointer
finally:
g.end_checkpoint()
assert g._checkpointer_ctx is None # restored
@pytest.mark.unit
def test_checkpoint_input_is_none_only_when_resuming():
global _should_crash
with tempfile.TemporaryDirectory() as tmp:
init = {"count": 0}
args = ("AAPL", "2026-05-08", "stock")
# Fresh run: no checkpoint yet -> stream the initial state, then crash.
g1 = _bare_graph(tmp)
tid = g1.begin_checkpoint(*args)
try:
assert g1._resuming is False
assert g1.checkpoint_input(init) is init # not resuming -> initial state
_should_crash = True
with pytest.raises(RuntimeError):
for _ in g1.graph.stream(init, config={"configurable": {"thread_id": tid}}):
pass
finally:
g1.end_checkpoint()
assert g1.checkpoint_input(init) is init # reset after teardown
# A later run finds the checkpoint -> resume by feeding None, not the
# initial state (re-passing it would duplicate messages, #1249).
_should_crash = False
g2 = _bare_graph(tmp)
g2.begin_checkpoint(*args)
try:
assert g2._resuming is True
assert g2.checkpoint_input(init) is None
finally:
g2.end_checkpoint()
@pytest.mark.unit
def test_cli_style_usage_saves_then_resumes():
global _should_crash
with tempfile.TemporaryDirectory() as tmp:
cfg_args = ("AAPL", "2026-05-08", "stock")
# Run 1 (the CLI path): begin -> stream self.graph -> crash at 'trader'.
_should_crash = True
g1 = _bare_graph(tmp)
tid = g1.begin_checkpoint(*cfg_args)
args = {"config": {"configurable": {"thread_id": tid}}}
try:
with pytest.raises(RuntimeError):
for _ in g1.graph.stream({"count": 0}, **args):
pass
finally:
g1.end_checkpoint()
# A checkpoint was saved for this run signature (so --checkpoint works).
sig = g1._run_signature("stock")
assert checkpoint_step(tmp, "AAPL", "2026-05-08", sig) is not None
# Run 2 (fresh graph, as a new CLI invocation): resume and finish.
_should_crash = False
g2 = _bare_graph(tmp)
tid2 = g2.begin_checkpoint(*cfg_args)
assert tid2 == tid # stable id -> same thread resumes
try:
result = g2.graph.invoke(None, config={"configurable": {"thread_id": tid2}})
assert result["count"] == 11 # analyst(+1) resumed into trader(+10)
g2.clear_checkpoint_on_success(*cfg_args)
finally:
g2.end_checkpoint()
# Cleared on success -> a later run starts fresh.
assert checkpoint_step(tmp, "AAPL", "2026-05-08", sig) is None

View File

@@ -0,0 +1,57 @@
"""A terminal without a console buffer must fail with one actionable line (#1138).
prompt_toolkit raises NoConsoleScreenBufferError before the first prompt in
non-interactive Windows terminals; the CLI should not surface that traceback.
The Windows-only exception import must also stay inert on other platforms.
"""
from __future__ import annotations
import sys
from typer.testing import CliRunner
import cli.main as m
def test_no_console_error_tuple_matches_platform():
# Off Windows the win32 module is never imported (it asserts the platform),
# so the tuple is empty — which `except` accepts and never matches. On
# Windows it holds the real exception type, and a broken prompt_toolkit
# would raise at import rather than silently disabling the handler.
assert isinstance(m._NO_CONSOLE_ERRORS, tuple)
assert all(issubclass(e, BaseException) for e in m._NO_CONSOLE_ERRORS)
if sys.platform == "win32":
assert m._NO_CONSOLE_ERRORS, "Windows must resolve the console error type"
else:
assert m._NO_CONSOLE_ERRORS == ()
def test_missing_console_prints_actionable_message(monkeypatch):
class _NoConsole(Exception):
pass
# Simulate the Windows failure on any platform by registering a stand-in.
monkeypatch.setattr(m, "_NO_CONSOLE_ERRORS", (_NoConsole,))
def _boom(*a, **k):
raise _NoConsole("No Windows console found. Are you running cmd.exe?")
monkeypatch.setattr(m, "run_analysis", _boom)
result = CliRunner().invoke(m.app, [])
assert result.exit_code == 1
assert "no Windows console available" in result.output
# The raw prompt_toolkit traceback must not reach the user.
assert "Traceback" not in result.output
def test_unrelated_errors_still_propagate(monkeypatch):
# The handler must stay narrow: only the console error is translated.
monkeypatch.setattr(m, "_NO_CONSOLE_ERRORS", (RuntimeError,))
def _boom(*a, **k):
raise ValueError("unrelated")
monkeypatch.setattr(m, "run_analysis", _boom)
result = CliRunner().invoke(m.app, [])
assert isinstance(result.exception, ValueError)

View File

@@ -0,0 +1,111 @@
"""The first speaker in each debate must not rebut a nonexistent argument (#1176).
Each debate round's opening speaker receives an empty opponent response; the
prompt used to interpolate it into a "refute the opponent" instruction, so models
fabricated the other side's position. All five debators (bull, bear, and the
three risk analysts) now substitute an explicit opening marker when the opponent
has not spoken, and pass a real argument through unchanged.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from tradingagents.agents.researchers.bear_researcher import create_bear_researcher
from tradingagents.agents.researchers.bull_researcher import create_bull_researcher
from tradingagents.agents.risk_mgmt.aggressive_debator import create_aggressive_debator
from tradingagents.agents.risk_mgmt.conservative_debator import create_conservative_debator
from tradingagents.agents.risk_mgmt.neutral_debator import create_neutral_debator
from tradingagents.agents.utils.agent_utils import opponent_argument_or_opening
_REPORTS = {
"company_of_interest": "AAPL", "asset_type": "stock",
"market_report": "m", "sentiment_report": "s",
"news_report": "n", "fundamentals_report": "f",
}
def _capturing_llm(captured: dict):
llm = MagicMock()
llm.invoke.side_effect = lambda prompt: (
captured.__setitem__("prompt", prompt) or MagicMock(content="argument")
)
return llm
def _investment_state(current_response):
return {
**_REPORTS,
"count": 0,
"investment_debate_state": {
"history": "", "bull_history": "", "bear_history": "",
"current_response": current_response, "count": 0,
},
}
def _risk_state(**responses):
base = {
"current_aggressive_response": "", "current_conservative_response": "",
"current_neutral_response": "", "history": "", "aggressive_history": "",
"conservative_history": "", "neutral_history": "", "count": 0,
}
base.update(responses)
return {**_REPORTS, "trader_investment_plan": "plan", "risk_debate_state": base}
# --- shared helper ----------------------------------------------------------
@pytest.mark.unit
def test_helper_marks_empty_and_passes_through():
assert "has not spoken yet" in opponent_argument_or_opening("", "bear analyst")
assert opponent_argument_or_opening(" real point ", "bear") == "real point"
# --- researchers ------------------------------------------------------------
@pytest.mark.unit
@pytest.mark.parametrize(
"factory,opponent",
[(create_bull_researcher, "bear"), (create_bear_researcher, "bull")],
)
def test_researcher_opening_has_no_phantom_opponent(factory, opponent):
captured = {}
factory(_capturing_llm(captured))(_investment_state(""))
assert "has not spoken yet" in captured["prompt"]
@pytest.mark.unit
def test_researcher_passes_real_opponent_argument():
captured = {}
state = _investment_state("Bear Analyst: valuation is stretched")
create_bull_researcher(_capturing_llm(captured))(state)
assert "valuation is stretched" in captured["prompt"]
assert "has not spoken yet" not in captured["prompt"]
# --- risk debators ----------------------------------------------------------
@pytest.mark.unit
@pytest.mark.parametrize(
"factory", [create_aggressive_debator, create_conservative_debator, create_neutral_debator]
)
def test_risk_opening_has_no_phantom_opponent(factory):
captured = {}
factory(_capturing_llm(captured))(_risk_state())
# Both opponent slots were empty -> two opening markers, no fabricated args.
assert captured["prompt"].count("has not spoken yet") == 2
@pytest.mark.unit
def test_risk_passes_real_opponent_arguments():
captured = {}
state = _risk_state(
current_conservative_response="Conservative Analyst: trim risk",
current_neutral_response="Neutral Analyst: hold steady",
)
create_aggressive_debator(_capturing_llm(captured))(state)
assert "trim risk" in captured["prompt"]
assert "hold steady" in captured["prompt"]
assert "has not spoken yet" not in captured["prompt"]

View File

@@ -21,8 +21,8 @@ def _reload_with_env(monkeypatch, **overrides):
def test_no_env_uses_built_in_defaults(monkeypatch): def test_no_env_uses_built_in_defaults(monkeypatch):
dc = _reload_with_env(monkeypatch) dc = _reload_with_env(monkeypatch)
assert dc.DEFAULT_CONFIG["llm_provider"] == "openai" assert dc.DEFAULT_CONFIG["llm_provider"] == "openai"
assert dc.DEFAULT_CONFIG["deep_think_llm"] == "gpt-5.5" assert dc.DEFAULT_CONFIG["deep_think_llm"] == "gpt-5.6"
assert dc.DEFAULT_CONFIG["quick_think_llm"] == "gpt-5.4-mini" assert dc.DEFAULT_CONFIG["quick_think_llm"] == "gpt-5.6-luna"
assert dc.DEFAULT_CONFIG["backend_url"] is None assert dc.DEFAULT_CONFIG["backend_url"] is None
assert dc.DEFAULT_CONFIG["max_debate_rounds"] == 1 assert dc.DEFAULT_CONFIG["max_debate_rounds"] == 1
assert dc.DEFAULT_CONFIG["checkpoint_enabled"] is False assert dc.DEFAULT_CONFIG["checkpoint_enabled"] is False

View File

@@ -150,6 +150,23 @@ class FredFormattingTests(unittest.TestCase):
self.assertEqual(obs_params["observation_end"], "2025-09-30") self.assertEqual(obs_params["observation_end"], "2025-09-30")
self.assertEqual(obs_params["observation_start"], "2025-07-02") # 90d back 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 @pytest.mark.unit
class FredRoutingTests(unittest.TestCase): class FredRoutingTests(unittest.TestCase):

View File

@@ -0,0 +1,122 @@
"""Configurable output-token cap (#1204).
Some model/gateway combinations (e.g. deepseek-v4-flash deployments) emit
unbounded reasoning/output and hang or trip an idle timeout. An opt-in
``max_tokens`` config knob is forwarded to every provider so a run can bound it;
Gemini names the parameter ``max_output_tokens``, so it is forwarded under the
right key per provider.
"""
from __future__ import annotations
import importlib
import pytest
import tradingagents.default_config as default_config_module
from tradingagents.graph.trading_graph import TradingAgentsGraph, _coerce_max_tokens
# --- coercion / validation -------------------------------------------------
@pytest.mark.unit
@pytest.mark.parametrize("value,expected", [(1, 1), (8192, 8192), ("4096", 4096)])
def test_coerce_accepts_positive_ints_and_numeric_strings(value, expected):
assert _coerce_max_tokens(value) == expected
@pytest.mark.unit
@pytest.mark.parametrize("bad", [0, -1, "0", "-5"])
def test_coerce_rejects_non_positive(bad):
with pytest.raises(ValueError, match="> 0"):
_coerce_max_tokens(bad)
@pytest.mark.unit
@pytest.mark.parametrize("bad", [True, False])
def test_coerce_rejects_booleans(bad):
with pytest.raises(ValueError, match="boolean"):
_coerce_max_tokens(bad)
@pytest.mark.unit
@pytest.mark.parametrize("bad", ["abc", "1.5", None])
def test_coerce_rejects_non_integers(bad):
with pytest.raises(ValueError, match="integer"):
_coerce_max_tokens(bad)
# --- forwarding into provider kwargs (right key per provider) --------------
def _bare_graph(config):
g = object.__new__(TradingAgentsGraph)
g.config = config
return g
@pytest.mark.unit
def test_not_forwarded_when_unset():
kwargs = _bare_graph({"llm_provider": "openai", "max_tokens": None})._get_provider_kwargs()
assert "max_tokens" not in kwargs
assert "max_output_tokens" not in kwargs
@pytest.mark.unit
@pytest.mark.parametrize("provider", ["openai", "anthropic", "deepseek", "openai_compatible"])
def test_forwarded_as_max_tokens_for_non_google(provider):
kwargs = _bare_graph({"llm_provider": provider, "max_tokens": 8192})._get_provider_kwargs()
assert kwargs["max_tokens"] == 8192
assert "max_output_tokens" not in kwargs
@pytest.mark.unit
def test_forwarded_as_max_output_tokens_for_google():
# Gemini's kwarg name differs; forwarding plain max_tokens would be rejected.
kwargs = _bare_graph({"llm_provider": "google", "max_tokens": 8192})._get_provider_kwargs()
assert kwargs["max_output_tokens"] == 8192
assert "max_tokens" not in kwargs
@pytest.mark.unit
def test_env_string_is_coerced():
kwargs = _bare_graph({"llm_provider": "openai", "max_tokens": "4096"})._get_provider_kwargs()
assert kwargs["max_tokens"] == 4096
@pytest.mark.unit
def test_invalid_value_fails_loudly():
with pytest.raises(ValueError):
_bare_graph({"llm_provider": "openai", "max_tokens": 0})._get_provider_kwargs()
# --- client-side allowlists carry the kwarg --------------------------------
@pytest.mark.unit
def test_openai_and_google_clients_accept_the_kwarg():
from tradingagents.llm_clients import openai_client
from tradingagents.llm_clients.google_client import GoogleClient # noqa: F401
assert "max_tokens" in openai_client._PASSTHROUGH_KWARGS
# Google client forwards max_output_tokens through construction.
llm = GoogleClient("gemini-3.5-flash", api_key="x", max_output_tokens=8192).get_llm()
assert getattr(llm, "max_output_tokens", None) == 8192
# --- env overlay -----------------------------------------------------------
def _reload_with_env(monkeypatch, **overrides):
for key in list(default_config_module._ENV_OVERRIDES):
monkeypatch.delenv(key, raising=False)
for key, val in overrides.items():
monkeypatch.setenv(key, val)
return importlib.reload(default_config_module)
@pytest.mark.unit
def test_default_is_none(monkeypatch):
dc = _reload_with_env(monkeypatch)
assert dc.DEFAULT_CONFIG["max_tokens"] is None
@pytest.mark.unit
def test_env_override_sets_config(monkeypatch):
dc = _reload_with_env(monkeypatch, TRADINGAGENTS_MAX_TOKENS="8192")
assert dc.DEFAULT_CONFIG["max_tokens"] == "8192"
assert _coerce_max_tokens(dc.DEFAULT_CONFIG["max_tokens"]) == 8192

View File

@@ -54,9 +54,14 @@ def _resolve_entry(log, ticker, date, decision, reflection="Good call."):
log.update_with_outcome(ticker, date, 0.05, 0.02, 5, reflection) log.update_with_outcome(ticker, date, 0.05, 0.02, 5, reflection)
def _price_df(prices): def _price_df(prices, start="2026-01-05"):
"""Minimal DataFrame matching yfinance .history() output shape.""" """Minimal DataFrame matching yfinance .history() output shape.
return pd.DataFrame({"Close": prices})
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=""): 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) m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices)
return m return m
mock_ticker_cls.side_effect = _make_ticker 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 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 isinstance(raw, float) and isinstance(alpha, float) and isinstance(days, int)
assert days == 5 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): 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) mock_graph = MagicMock(spec=TradingAgentsGraph)
with patch("yfinance.Ticker") as mock_ticker_cls: with patch("yfinance.Ticker") as mock_ticker_cls:
m = MagicMock() m = MagicMock()
m.history.return_value = _price_df([100.0]) m.history.return_value = _price_df([100.0])
mock_ticker_cls.return_value = m mock_ticker_cls.return_value = m
raw, alpha, days = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-04-19") raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "NVDA", "2026-04-19")
assert raw is None and alpha is None and days is None assert (raw, alpha, days, resolved) == (None, None, None, None)
def test_fetch_returns_delisted(self): 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) mock_graph = MagicMock(spec=TradingAgentsGraph)
with patch("yfinance.Ticker") as mock_ticker_cls: with patch("yfinance.Ticker") as mock_ticker_cls:
m = MagicMock() m = MagicMock()
m.history.return_value = pd.DataFrame({"Close": []}) m.history.return_value = pd.DataFrame({"Close": []})
mock_ticker_cls.return_value = m mock_ticker_cls.return_value = m
raw, alpha, days = TradingAgentsGraph._fetch_returns(mock_graph, "XXXXXFAKE", "2026-01-10") raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(mock_graph, "XXXXXFAKE", "2026-01-10")
assert raw is None and alpha is None and days is None assert (raw, alpha, days, resolved) == (None, None, None, None)
def test_fetch_returns_spy_shorter_than_stock(self): def test_fetch_returns_spy_shorter_than_stock(self):
"""SPY having fewer rows than the stock must not raise IndexError.""" """SPY having fewer rows than the stock (but still a full window) must
stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0] not raise IndexError."""
spy_prices = [400.0, 402.0, 403.0] 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) mock_graph = MagicMock(spec=TradingAgentsGraph)
with patch("yfinance.Ticker") as mock_ticker_cls: with patch("yfinance.Ticker") as mock_ticker_cls:
def _make_ticker(sym): def _make_ticker(sym):
@@ -532,9 +540,26 @@ class TestDeferredReflection:
m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices) m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices)
return m return m
mock_ticker_cls.side_effect = _make_ticker 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 raw is not None and alpha is not None
assert days == 2 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 # TradingAgentsGraph._resolve_benchmark — picks index for alpha calc
@@ -641,7 +666,7 @@ class TestDeferredReflection:
log.store_decision("AAPL", "2026-01-10", DECISION_BUY) log.store_decision("AAPL", "2026-01-10", DECISION_BUY)
mock_graph = MagicMock(spec=TradingAgentsGraph) mock_graph = MagicMock(spec=TradingAgentsGraph)
mock_graph.memory_log = log 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") TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA")
mock_graph._fetch_returns.assert_not_called() mock_graph._fetch_returns.assert_not_called()
assert len(log.get_pending_entries()) == 1 assert len(log.get_pending_entries()) == 1
@@ -655,7 +680,7 @@ class TestDeferredReflection:
mock_graph = MagicMock(spec=TradingAgentsGraph) mock_graph = MagicMock(spec=TradingAgentsGraph)
mock_graph.memory_log = log mock_graph.memory_log = log
mock_graph.reflector = mock_reflector 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") TradingAgentsGraph._resolve_pending_entries(mock_graph, "NVDA")
assert log.get_pending_entries() == [] assert log.get_pending_entries() == []
entries = log.load_entries() entries = log.load_entries()
@@ -665,6 +690,20 @@ class TestDeferredReflection:
assert "+5.0%" in entries[0]["raw"] assert "+5.0%" in entries[0]["raw"]
assert "+2.0%" in entries[0]["alpha"] 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 # Portfolio Manager injection: past_context in state and prompt

View File

@@ -0,0 +1,95 @@
"""Memory-log lessons must be point-in-time safe in a backtest (#1251).
get_past_context previously returned every resolved lesson regardless of the run
date, so a historical run could learn from an outcome that had not happened yet.
Resolved entries now record the date their outcome became known (``resolved:``),
and get_past_context(as_of=...) filters on it. Legacy entries without a
resolution date are excluded from a point-in-time query (conservative migration).
"""
from __future__ import annotations
import pytest
from tradingagents.agents.utils.memory import TradingMemoryLog
def _log(tmp_path):
return TradingMemoryLog({"memory_log_path": str(tmp_path / "mem.md")})
def _resolve(log, ticker, date, resolution_date, reflection):
log.store_decision(ticker, date, f"Rating: Buy\n{reflection}")
log.update_with_outcome(
ticker, date, 0.05, 0.02, 5, reflection, resolution_date=resolution_date,
)
@pytest.mark.unit
def test_resolution_date_is_stored_and_parsed(tmp_path):
log = _log(tmp_path)
_resolve(log, "NVDA", "2026-01-05", "2026-01-10", "outcome known 01-10")
entry = log.load_entries()[0]
assert entry["resolved"] == "2026-01-10"
assert "resolved:2026-01-10" in (tmp_path / "mem.md").read_text()
@pytest.mark.unit
def test_as_of_excludes_lessons_resolved_after_the_run_date(tmp_path):
log = _log(tmp_path)
# Decision on 01-05, outcome only known on 01-10.
_resolve(log, "NVDA", "2026-01-05", "2026-01-10", "great trade")
# A run as-of 01-07 must NOT see it (the outcome was still in the future).
assert log.get_past_context("NVDA", as_of="2026-01-07") == ""
# A run as-of 01-10 (and later) sees it.
assert "great trade" in log.get_past_context("NVDA", as_of="2026-01-10")
assert "great trade" in log.get_past_context("NVDA", as_of="2026-02-01")
@pytest.mark.unit
def test_no_as_of_is_unfiltered_live_behavior(tmp_path):
log = _log(tmp_path)
_resolve(log, "NVDA", "2026-01-05", "2026-01-10", "great trade")
# Live run (no as_of): unchanged behavior, lesson is shown.
assert "great trade" in log.get_past_context("NVDA")
@pytest.mark.unit
def test_legacy_entry_without_resolution_date_excluded_in_backtest(tmp_path):
log = _log(tmp_path)
# Simulate a pre-migration resolved entry: no resolution_date recorded.
log.store_decision("NVDA", "2026-01-05", "Rating: Buy\nlegacy lesson")
log.update_with_outcome("NVDA", "2026-01-05", 0.05, 0.02, 5, "legacy lesson")
entry = log.load_entries()[0]
assert entry["resolved"] is None
# Conservative: excluded from a point-in-time query (can't prove it was known)...
assert log.get_past_context("NVDA", as_of="2026-06-01") == ""
# ...but still available on a live (unfiltered) run.
assert "legacy lesson" in log.get_past_context("NVDA")
@pytest.mark.unit
def test_cross_ticker_lessons_are_also_gated(tmp_path):
log = _log(tmp_path)
_resolve(log, "AAPL", "2026-01-05", "2026-01-10", "cross lesson")
# Querying a different ticker as-of before resolution: no cross lesson leaks.
assert log.get_past_context("NVDA", as_of="2026-01-07") == ""
assert "cross lesson" in log.get_past_context("NVDA", as_of="2026-01-10")
@pytest.mark.unit
def test_memory_as_of_gates_historical_but_not_live():
# The graph filters only for a past trade date; a current-date run passes
# None so live behavior and legacy entries are unaffected (#1251).
from datetime import datetime, timedelta
from tradingagents.graph.trading_graph import TradingAgentsGraph
g = object.__new__(TradingAgentsGraph)
past = "2024-01-01"
today = datetime.now().strftime("%Y-%m-%d")
future = (datetime.now() + timedelta(days=30)).strftime("%Y-%m-%d")
assert g._memory_as_of(past) == past # backtest -> filter on the trade date
assert g._memory_as_of(today) is None # live -> no filter
assert g._memory_as_of(future) is None # future-dated run -> no filter

View File

@@ -2,28 +2,33 @@
into a historical window. into a historical window.
Regressions for #992 (flat articles bypassed the date filter), #1007 (global Regressions for #992 (flat articles bypassed the date filter), #1007 (global
news injected future articles), #993 (empty-after-filter returned a blank body). news injected future articles), #993 (empty-after-filter returned a blank body),
and #1126 (inclusive upper bound leaked the midnight-after article; host-local
timestamp parsing made filtering machine-dependent).
""" """
import time from datetime import datetime, timezone
from datetime import datetime
import pytest import pytest
import tradingagents.dataflows.yfinance_news as ynews import tradingagents.dataflows.yfinance_news as ynews
from tradingagents.dataflows.date_window import in_window
def _epoch(date_str): def _epoch(date_str):
return int(time.mktime(datetime.strptime(date_str, "%Y-%m-%d").timetuple())) """Epoch seconds for UTC midnight of ``date_str`` (host-timezone independent)."""
return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp())
@pytest.mark.unit @pytest.mark.unit
def test_flat_article_publish_time_is_parsed(): def test_flat_article_publish_time_is_parsed():
# #992: flat articles now carry a pub_date (was always None -> unfilterable). # #992: flat articles now carry a pub_date (was always None -> unfilterable).
# #1126: parsed as UTC-aware, so the date can't shift with the host timezone.
data = ynews._extract_article_data( data = ynews._extract_article_data(
{"title": "X", "publisher": "P", "link": "l", "providerPublishTime": _epoch("2025-05-09")} {"title": "X", "publisher": "P", "link": "l", "providerPublishTime": _epoch("2025-05-09")}
) )
assert data["pub_date"] is not None assert data["pub_date"] is not None
assert data["pub_date"].strftime("%Y-%m-%d") == "2025-05-09" assert data["pub_date"].tzinfo is not None
assert data["pub_date"] == datetime(2025, 5, 9, tzinfo=timezone.utc)
@pytest.mark.unit @pytest.mark.unit
@@ -32,17 +37,38 @@ def test_window_excludes_future_and_undated_in_backtest():
end = datetime(2025, 5, 9) # historical window (well in the past) end = datetime(2025, 5, 9) # historical window (well in the past)
inside = datetime(2025, 5, 5) inside = datetime(2025, 5, 5)
future = datetime(2025, 6, 1) future = datetime(2025, 6, 1)
assert ynews._in_news_window(inside, start, end) is True assert in_window(inside, start, end) is True
assert ynews._in_news_window(future, start, end) is False # look-ahead blocked assert in_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(None, start, end) is False # undated -> excluded in backtest
@pytest.mark.unit @pytest.mark.unit
def test_window_keeps_undated_in_live_window(): def test_window_keeps_undated_in_live_window():
# Live window (reaches today): undated articles can't be "future", so keep them. # Live window (reaches today): undated articles can't be "future", so keep them.
start = datetime.now() now = datetime.now(timezone.utc)
end = datetime.now() assert in_window(None, now, now) is True
assert ynews._in_news_window(None, start, end) is True
@pytest.mark.unit
def test_upper_bound_is_exclusive():
# #1126: an article stamped exactly midnight AFTER end_date leaked in under
# the old inclusive bound; the whole of end_date itself must still be kept.
start = datetime(2025, 5, 1)
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 in_window(midnight_after, start, end) is False
assert in_window(last_moment, start, end) is True
@pytest.mark.unit
def test_offset_aware_timestamp_is_converted_not_truncated():
# #1126: 2025-05-10T01:00+05:00 is really 2025-05-09T20:00Z -> inside the
# window. Stripping tzinfo (old behavior) misread it as 05-10 and dropped it.
start = datetime(2025, 5, 1)
end = datetime(2025, 5, 9)
aware = datetime.fromisoformat("2025-05-10T01:00:00+05:00")
assert in_window(aware, start, end) is True
@pytest.mark.unit @pytest.mark.unit

View File

@@ -0,0 +1,109 @@
"""Same-day OHLCV cache must not serve a stale snapshot all day (#1150).
The cache file is keyed per day, so a run started before the day's bar was final
would be reused by every later run, feeding a stale close into technical
analysis. Two cases matter for a current-day request: the bar may be missing, or
present but still in progress (Yahoo publishes a partial daily candle intraday).
Refresh is bounded by a TTL so repeated runs cannot hammer the vendor.
"""
from __future__ import annotations
import os
import time
import pandas as pd
import pytest
import tradingagents.dataflows.stockstats_utils as su
TODAY = pd.Timestamp("2026-07-18")
STALE = su.OHLCV_CACHE_TTL_SECONDS + 60
def _write(tmp_path, name="cache.csv", age_seconds=0.0, last_date="2026-07-17"):
f = tmp_path / name
pd.DataFrame({"Date": [last_date], "Close": [1.0]}).to_csv(f, index=False)
if age_seconds:
old = time.time() - age_seconds
os.utime(f, (old, old))
return str(f)
@pytest.mark.unit
def test_current_day_cache_past_ttl_is_refreshed(tmp_path):
# Bar missing (rows stop at yesterday) and file older than the TTL -> refetch.
assert su._needs_same_day_refresh(_write(tmp_path, age_seconds=STALE), TODAY, TODAY) is True
@pytest.mark.unit
def test_partial_current_day_bar_is_still_refreshed(tmp_path):
# Today's row is present but may be an in-progress candle whose Close is not
# the closing price. Row inspection can't distinguish it, so the TTL governs.
f = _write(tmp_path, age_seconds=STALE, last_date="2026-07-18")
assert su._needs_same_day_refresh(f, TODAY, TODAY) is True
@pytest.mark.unit
def test_recent_cache_is_not_refetched(tmp_path):
# Written moments ago: don't hammer the vendor (weekend/holiday guard).
assert su._needs_same_day_refresh(_write(tmp_path), TODAY, TODAY) is False
@pytest.mark.unit
def test_historical_request_always_uses_cache(tmp_path):
# Past dates are immutable: never refetch, however old the file is.
past = pd.Timestamp("2026-05-01")
f = _write(tmp_path, age_seconds=STALE, last_date="2026-04-30")
assert su._needs_same_day_refresh(f, past, TODAY) is False
@pytest.mark.unit
def test_load_ohlcv_refetches_stale_same_day_cache(tmp_path, monkeypatch):
"""End-to-end: the helper is actually wired into load_ohlcv's cache branch.
Without this, the unit tests above would still pass if the helper were never
called from the real code path.
"""
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: TODAY))
# Pre-seed the cache file load_ohlcv will look for, aged past the TTL.
start = (TODAY - pd.DateOffset(years=5)).strftime("%Y-%m-%d")
end = (TODAY + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
cache_file = tmp_path / f"AAPL-YFin-data-{start}-{end}.csv"
pd.DataFrame({"Date": ["2026-07-17"], "Close": [100.0]}).to_csv(cache_file, index=False)
old = time.time() - STALE
os.utime(cache_file, (old, old))
calls = []
def _fake_download(*a, **k):
calls.append(1)
return pd.DataFrame(
{"Date": pd.to_datetime(["2026-07-17", "2026-07-18"]), "Close": [100.0, 222.0]}
).set_index("Date")
monkeypatch.setattr(su.yf, "download", _fake_download)
out = su.load_ohlcv("AAPL", TODAY.strftime("%Y-%m-%d"))
assert calls, "stale same-day cache must trigger a refetch"
assert 222.0 in out["Close"].values, "refreshed close must reach the caller"
@pytest.mark.unit
def test_load_ohlcv_reuses_fresh_same_day_cache(tmp_path, monkeypatch):
# Mirror image: a fresh cache must NOT trigger a download.
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
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")
cache_file = tmp_path / f"AAPL-YFin-data-{start}-{end}.csv"
pd.DataFrame({"Date": ["2026-07-18"], "Close": [100.0]}).to_csv(cache_file, index=False)
def _fail_download(*a, **k):
raise AssertionError("fresh cache must not refetch")
monkeypatch.setattr(su.yf, "download", _fail_download)
su.load_ohlcv("AAPL", TODAY.strftime("%Y-%m-%d"))

View File

@@ -0,0 +1,136 @@
"""The latest trading day's bar must not silently vanish (#1201).
yfinance can return the newest in-range bar with a NaN close (an unsettled or
glitched session). The old path parsed dates without normalizing timezone and
dropped every NaN-close row before applying the curr_date cutoff, so the latest
bar disappeared and the previous trading day looked like the latest. Now dates
are normalized, and a latest in-range bar with no close raises rather than
silently falling back.
"""
from __future__ import annotations
import pandas as pd
import pytest
from tradingagents.dataflows import stockstats_utils as su
from tradingagents.dataflows.symbol_utils import NoMarketDataError
# --- date normalization -----------------------------------------------------
@pytest.mark.unit
def test_normalize_dates_strips_tz_and_normalizes_to_midnight():
aware = pd.Series(pd.to_datetime(
["2026-05-08 09:30:00-04:00", "2026-05-09 16:00:00-04:00"]
))
out = su._normalize_dates(aware)
assert out.dt.tz is None
assert list(out) == [pd.Timestamp("2026-05-08"), pd.Timestamp("2026-05-09")]
@pytest.mark.unit
def test_normalize_dates_leaves_naive_dates_at_midnight():
naive = pd.Series(pd.to_datetime(["2026-05-08 14:30:00", "2026-05-09 00:00:00"]))
out = su._normalize_dates(naive)
assert out.dt.tz is None
assert list(out) == [pd.Timestamp("2026-05-08"), pd.Timestamp("2026-05-09")]
@pytest.mark.unit
def test_normalize_dates_handles_mixed_dst_offsets():
# 5y of US bars span DST; via a cache CSV they arrive as mixed-offset
# strings, which pd.to_datetime can't unify. Each keeps its own local date.
mixed = pd.Series([
"2026-01-08 00:00:00-05:00", # EST
"2026-06-08 00:00:00-04:00", # EDT
"not-a-date", # -> NaT
])
out = su._normalize_dates(mixed)
assert out.iloc[0] == pd.Timestamp("2026-01-08")
assert out.iloc[1] == pd.Timestamp("2026-06-08")
assert pd.isna(out.iloc[2])
@pytest.mark.unit
def test_normalize_dates_keeps_positive_offset_local_date():
# A Tokyo bar at local midnight (+09:00) must stay on its own calendar day,
# not shift to the previous UTC day (which utc=True parsing would cause).
jst = pd.Series(["2026-05-08 00:00:00+09:00"])
assert su._normalize_dates(jst).iloc[0] == pd.Timestamp("2026-05-08")
# --- fill vs guard responsibilities ----------------------------------------
@pytest.mark.unit
def test_clean_dataframe_keeps_nan_close_for_the_caller_to_inspect():
# _clean_dataframe normalizes but no longer drops the NaN close itself.
df = pd.DataFrame({"Date": ["2026-05-08", "2026-05-09"], "Close": [100.0, float("nan")]})
cleaned = su._clean_dataframe(df)
assert len(cleaned) == 2
assert pd.isna(cleaned["Close"].iloc[-1])
@pytest.mark.unit
def test_fill_price_gaps_drops_nan_close_rows():
df = pd.DataFrame({"Date": pd.to_datetime(["2026-05-07", "2026-05-08"]),
"Close": [float("nan"), 100.0]})
filled = su._fill_price_gaps(df)
assert len(filled) == 1
assert filled["Close"].iloc[0] == 100.0
# --- load_ohlcv end-to-end (with a mocked cache read) -----------------------
def _run_load(monkeypatch, tmp_path, frame, curr_date):
"""Drive load_ohlcv against a pre-seeded cache frame (no network)."""
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
today = pd.Timestamp(curr_date)
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: today))
start = (today - pd.DateOffset(years=5)).strftime("%Y-%m-%d")
end = (today + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
(tmp_path / f"AAPL-YFin-data-{start}-{end}.csv").write_text(frame.to_csv(index=False))
def _fail_download(*a, **k):
raise AssertionError("should use the seeded cache, not download")
monkeypatch.setattr(su.yf, "download", _fail_download)
monkeypatch.setattr(su, "_assert_ohlcv_not_stale", lambda *a, **k: None)
return su.load_ohlcv("AAPL", curr_date)
@pytest.mark.unit
def test_latest_in_range_nan_close_raises_not_silent_fallback(monkeypatch, tmp_path):
# Newest bar (the curr_date) has no close -> raise, don't return Thursday.
frame = pd.DataFrame({
"Date": ["2026-05-07", "2026-05-08"],
"Open": [100.0, 101.0], "High": [101.0, 102.0], "Low": [99.0, 100.0],
"Close": [100.5, float("nan")], "Volume": [1_000_000, 1_000_000],
})
with pytest.raises(NoMarketDataError, match="no closing price"):
_run_load(monkeypatch, tmp_path, frame, "2026-05-08")
@pytest.mark.unit
def test_older_nan_close_row_is_still_dropped(monkeypatch, tmp_path):
# A stale gap mid-series is dropped; the valid latest bar is served.
frame = pd.DataFrame({
"Date": ["2026-05-06", "2026-05-07", "2026-05-08"],
"Open": [100.0, 101.0, 102.0], "High": [101.0, 102.0, 103.0],
"Low": [99.0, 100.0, 101.0],
"Close": [100.5, float("nan"), 102.5], "Volume": [1_000_000, 1_000_000, 1_000_000],
})
out = _run_load(monkeypatch, tmp_path, frame, "2026-05-08")
assert out["Close"].iloc[-1] == 102.5
assert (out["Date"] == pd.Timestamp("2026-05-07")).sum() == 0 # the NaN row is gone
@pytest.mark.unit
def test_tz_aware_latest_bar_is_kept_at_the_cutoff(monkeypatch, tmp_path):
# A tz-aware/intraday latest bar on the cutoff day must not be filtered out
# by a naive-vs-aware comparison.
frame = pd.DataFrame({
"Date": ["2026-05-07 09:30:00-04:00", "2026-05-08 09:30:00-04:00"],
"Open": [100.0, 101.0], "High": [101.0, 102.0], "Low": [99.0, 100.0],
"Close": [100.5, 101.5], "Volume": [1_000_000, 1_000_000],
})
out = _run_load(monkeypatch, tmp_path, frame, "2026-05-08")
assert out["Close"].iloc[-1] == 101.5
assert out["Date"].iloc[-1] == pd.Timestamp("2026-05-08")

View File

@@ -10,7 +10,13 @@ to it.
import pytest 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 from tradingagents.graph.signal_processing import SignalProcessor
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -84,6 +90,51 @@ class TestSignalProcessor:
llm.invoke.assert_not_called() llm.invoke.assert_not_called()
llm.with_structured_output.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() sp = SignalProcessor()
assert sp.process_signal("Plain prose without a recommendation.") == "Hold" signal = sp.process_signal("Plain prose without a recommendation.")
assert signal == RATING_REVIEW
assert is_review(signal)
assert signal not in RATINGS_5_TIER
def test_fullwidth_colon_is_parsed_not_reviewed(self):
# #1170: `RatingOverweight` (fullwidth colon) used to defeat the regex
# and silently become Hold; NFKC normalization now parses it.
sp = SignalProcessor()
assert sp.process_signal("RatingOverweight\n理由はこちら。") == "Overweight"
@pytest.mark.unit
class TestExtractRating:
def test_returns_none_when_absent(self):
assert extract_rating("No directional call here.") is None
assert extract_rating("") is None
def test_whole_word_only(self):
# substrings inside larger words must not match
assert extract_rating("The buyer was holding shares.") is None
def test_parse_rating_keeps_silent_default_for_compat(self):
# parse_rating (used by the memory log) intentionally keeps Hold default.
assert parse_rating("No rating here.") == "Hold"
assert parse_rating("No rating here.", default="Underweight") == "Underweight"
@pytest.mark.unit
class TestGraphSignalContract:
"""The graph-facing signal (TradingAgentsGraph.process_signal) honors the
documented "5-tier or REVIEW" contract, not just the parser in isolation."""
def _bare_graph(self):
from tradingagents.graph.trading_graph import TradingAgentsGraph
g = object.__new__(TradingAgentsGraph)
g.signal_processor = SignalProcessor()
return g
def test_graph_surfaces_review(self):
assert self._bare_graph().process_signal("no rating in here") == RATING_REVIEW
def test_graph_returns_rating(self):
assert self._bare_graph().process_signal("**Rating**: Sell") == "Sell"

View File

@@ -0,0 +1,121 @@
"""Historical social sentiment must not leak current data into a backtest (#1220).
StockTwits and Reddit fetchers pull only recent items, so for a historical run
they must be trimmed to the analysis window (and yield a clear placeholder when
nothing qualifies) rather than showing today's chatter as if it were from the
as-of date. All three sources share dataflows.date_window.in_window.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
import pytest
from tradingagents.dataflows import reddit, stocktwits
from tradingagents.dataflows.date_window import in_window
class _JsonResp:
"""Minimal urlopen() context-manager stub returning a JSON body."""
def __init__(self, payload):
self._body = json.dumps(payload).encode()
def __enter__(self):
return self
def __exit__(self, *a):
return False
def read(self):
return self._body
# --- shared window helper ---------------------------------------------------
@pytest.mark.unit
def test_in_window_bounds_and_exclusive_upper():
start = datetime(2026, 5, 1)
end = datetime(2026, 5, 9)
assert in_window(datetime(2026, 5, 5, tzinfo=timezone.utc), start, end) is True
assert in_window(datetime(2026, 5, 9, 23, 59, tzinfo=timezone.utc), start, end) is True
# exactly midnight after end -> excluded (no leak)
assert in_window(datetime(2026, 5, 10, 0, 0, tzinfo=timezone.utc), start, end) is False
# offset-aware converted, not truncated: 05-10T01:00+05:00 == 05-09T20:00Z
assert in_window(datetime.fromisoformat("2026-05-10T01:00:00+05:00"), start, end) is True
@pytest.mark.unit
def test_in_window_undated_excluded_in_backtest_kept_live():
old = datetime(2026, 5, 9)
assert in_window(None, datetime(2026, 5, 1), old) is False # historical
now = datetime.now(timezone.utc)
assert in_window(None, now, now) is True # live
# --- StockTwits -------------------------------------------------------------
def _msg(created_iso, sentiment=None):
return {
"created_at": created_iso,
"user": {"username": "u"},
"entities": {"sentiment": {"basic": sentiment}},
"body": "text",
}
@pytest.mark.unit
def test_stocktwits_historical_window_excludes_recent(monkeypatch):
# All messages are "today"; a run as-of a past week must show none of them.
recent = [_msg("2026-08-30T12:00:00Z", "Bullish"), _msg("2026-08-29T09:00:00Z")]
monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": recent}))
out = stocktwits.fetch_stocktwits_messages("AAPL", start_date="2026-05-01", end_date="2026-05-08")
assert "no StockTwits messages" in out
assert "2026-05-01..2026-05-08" in out
assert "Bullish: 1" not in out # the recent bullish message did not leak
@pytest.mark.unit
def test_stocktwits_live_window_keeps_in_range(monkeypatch):
msgs = [_msg("2026-05-05T12:00:00Z", "Bullish"), _msg("2026-05-07T09:00:00Z", "Bearish")]
monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": msgs}))
out = stocktwits.fetch_stocktwits_messages("AAPL", start_date="2026-05-01", end_date="2026-05-08")
assert "Total: 2" in out
@pytest.mark.unit
def test_stocktwits_no_window_is_unfiltered(monkeypatch):
msgs = [_msg("2026-08-30T12:00:00Z", "Bullish")]
monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": msgs}))
out = stocktwits.fetch_stocktwits_messages("AAPL") # live caller, no dates
assert "Total: 1" in out
# --- Reddit -----------------------------------------------------------------
def _epoch(date_str):
return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp())
@pytest.mark.unit
def test_reddit_historical_window_excludes_recent(monkeypatch):
posts = [{"title": "NOW", "created_utc": _epoch("2026-08-30"), "source": "rss"}]
monkeypatch.setattr(reddit, "_fetch_subreddit", lambda *a, **k: posts)
out = reddit.fetch_reddit_posts(
"AAPL", subreddits=("stocks",), inter_request_delay=0,
start_date="2026-05-01", end_date="2026-05-08",
)
assert "NOW" not in out
assert "no posts" in out.lower() or "no reddit posts" in out.lower()
@pytest.mark.unit
def test_reddit_live_window_keeps_in_range(monkeypatch):
posts = [{"title": "INRANGE", "created_utc": _epoch("2026-05-05"), "source": "rss"}]
monkeypatch.setattr(reddit, "_fetch_subreddit", lambda *a, **k: posts)
out = reddit.fetch_reddit_posts(
"AAPL", subreddits=("stocks",), inter_request_delay=0,
start_date="2026-05-01", end_date="2026-05-08",
)
assert "INRANGE" in out

View File

@@ -0,0 +1,148 @@
"""Agents on the schema-only structured-output path must not invite tool calls (#1130).
`with_structured_output` binds exactly one tool (the schema). A prompt that
primes tool use makes models emit an unknown `web_search` call, which discards
the structured attempt and forces a free-text retry — an extra LLM round trip
and the loss of typed output.
These assert the constraint reaches the *rendered* prompt each agent actually
sends, not merely that the constant is referenced in the module.
"""
from __future__ import annotations
import inspect
from unittest.mock import MagicMock
import pytest
import tradingagents.agents.analysts.sentiment_analyst as sentiment
from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager
from tradingagents.agents.managers.research_manager import create_research_manager
from tradingagents.agents.trader.trader import create_trader
from tradingagents.agents.utils.structured import NO_EXTERNAL_TOOLS
def _capturing_llm(captured: dict, result):
"""LLM whose structured binding records the prompt it was handed."""
structured = MagicMock()
structured.invoke.side_effect = lambda prompt: (
captured.__setitem__("prompt", prompt) or result
)
llm = MagicMock()
llm.with_structured_output.return_value = structured
return llm
def _prompt_text(prompt) -> str:
"""Flatten a captured prompt (str, message list, or objects) to text."""
if isinstance(prompt, str):
return prompt
parts = []
for m in prompt:
parts.append(m.get("content", "") if isinstance(m, dict) else getattr(m, "content", ""))
return "\n".join(str(p) for p in parts)
@pytest.mark.unit
def test_trader_prompt_states_constraint():
from tradingagents.agents.schemas import TraderAction, TraderProposal
captured = {}
llm = _capturing_llm(captured, TraderProposal(action=TraderAction.BUY, reasoning="x"))
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"])
@pytest.mark.unit
def test_research_manager_prompt_states_constraint():
from tradingagents.agents.schemas import PortfolioRating, ResearchPlan
captured = {}
llm = _capturing_llm(
captured,
ResearchPlan(
recommendation=PortfolioRating.BUY, rationale="x", strategic_actions="y"
),
)
create_research_manager(llm)({
"company_of_interest": "NVDA",
"investment_debate_state": {
"history": "h", "bull_history": "b", "bear_history": "r",
"current_response": "", "judge_decision": "", "count": 1,
},
})
assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"])
@pytest.mark.unit
def test_portfolio_manager_prompt_states_constraint():
from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating
captured = {}
llm = _capturing_llm(
captured,
PortfolioDecision(
rating=PortfolioRating.HOLD,
executive_summary="x",
investment_thesis="y",
),
)
risk = {
"history": "h", "aggressive_history": "a", "conservative_history": "c",
"neutral_history": "n", "current_aggressive_response": "",
"current_conservative_response": "", "current_neutral_response": "",
"latest_speaker": "Neutral", "count": 1,
}
create_portfolio_manager(llm)({
"company_of_interest": "NVDA",
"risk_debate_state": risk,
"investment_plan": "plan",
"trader_investment_plan": "trader plan",
})
assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"])
@pytest.mark.unit
def test_sentiment_prompt_states_constraint(monkeypatch):
from tradingagents.agents.schemas import SentimentBand, SentimentReport
# Pre-fetched sources are stubbed so the prompt builds without network I/O.
monkeypatch.setattr(sentiment, "fetch_stocktwits_messages", lambda *a, **k: "st")
monkeypatch.setattr(sentiment, "fetch_reddit_posts", lambda *a, **k: "rd")
monkeypatch.setattr(sentiment.get_news, "func", lambda *a, **k: "news", raising=False)
captured = {}
llm = _capturing_llm(captured, SentimentReport(
overall_band=SentimentBand.BULLISH, overall_score=7.5,
confidence="high", narrative="n",
))
sentiment.create_sentiment_analyst(llm)({
"company_of_interest": "NVDA", "trade_date": "2026-01-15",
"asset_type": "stock", "messages": [],
})
text = _prompt_text(captured["prompt"])
assert NO_EXTERNAL_TOOLS in text
# This agent binds no tools, so tool-range wording must not reappear.
assert "tool-call date ranges" not in text
@pytest.mark.unit
def test_tool_using_analysts_keep_their_date_guidance():
# The analysts that really do call tools keep the wording that anchors their
# tool date ranges (#836) — this fix is scoped to no-tool agents.
import tradingagents.agents.analysts.market_analyst as market
import tradingagents.agents.analysts.news_analyst as news
for module in (market, news):
assert "tool-call date ranges" in inspect.getsource(module)
@pytest.mark.unit
def test_constraint_text_is_unambiguous():
assert "do not call external tools" in NO_EXTERNAL_TOOLS.lower()
# No template braces: it is embedded in ChatPromptTemplate strings, where
# braces would be parsed as input variables.
assert "{" not in NO_EXTERNAL_TOOLS and "}" not in NO_EXTERNAL_TOOLS

View File

@@ -131,6 +131,7 @@ def _make_trader_state():
return { return {
"company_of_interest": "NVDA", "company_of_interest": "NVDA",
"investment_plan": "**Recommendation**: Buy\n**Rationale**: ...\n**Strategic Actions**: ...", "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"] prompt = captured["prompt"]
assert any("Proposed Investment Plan" in m["content"] for m in 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): def test_falls_back_to_freetext_when_structured_unavailable(self):
plain_response = ( plain_response = (
"**Action**: Sell\n\nGuidance cut hits margins.\n\n" "**Action**: Sell\n\nGuidance cut hits margins.\n\n"

View File

@@ -41,18 +41,21 @@ def test_fetch_returns_normalizes_symbol(monkeypatch):
queried.append(symbol) queried.append(symbol)
def history(self, *args, **kwargs): 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) monkeypatch.setattr(tg.yf, "Ticker", FakeTicker)
# _fetch_returns does not use ``self``; call unbound to avoid building the graph. # _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" None, "XAUUSD", "2025-01-02", holding_days=5, benchmark="SPY"
) )
assert queried[0] == "GC=F" # stock symbol normalized (#984) assert queried[0] == "GC=F" # stock symbol normalized (#984)
assert queried[1] == "SPY" # benchmark left as the canonical symbol assert queried[1] == "SPY" # benchmark left as the canonical symbol
assert raw is not None and days is not None 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): def test_news_lookup_normalizes_symbol(monkeypatch):

View File

@@ -36,6 +36,7 @@ from tradingagents.agents.utils.agent_utils import (
get_news, get_news,
) )
from tradingagents.agents.utils.structured import ( from tradingagents.agents.utils.structured import (
NO_EXTERNAL_TOOLS,
bind_structured, bind_structured,
invoke_structured_or_freetext, invoke_structured_or_freetext,
) )
@@ -67,8 +68,12 @@ def create_sentiment_analyst(llm):
# returns a string (no exceptions surface from here), so the LLM # returns a string (no exceptions surface from here), so the LLM
# always sees something — either real data or a clear placeholder. # always sees something — either real data or a clear placeholder.
news_block = get_news.func(ticker, start_date, end_date) news_block = get_news.func(ticker, start_date, end_date)
stocktwits_block = fetch_stocktwits_messages(ticker, limit=30) # Pass the analysis window so a historical run trims social posts to it
reddit_block = fetch_reddit_posts(ticker) # 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( system_message = _build_system_message(
ticker=ticker, ticker=ticker,
@@ -86,7 +91,11 @@ def create_sentiment_analyst(llm):
"You are a helpful AI assistant, collaborating with other assistants." "You are a helpful AI assistant, collaborating with other assistants."
" If you or any other assistant has the FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** or deliverable," " If you or any other assistant has the FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** or deliverable,"
" prefix your response with FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** so the team knows to stop." " prefix your response with FINAL TRANSACTION PROPOSAL: **BUY/HOLD/SELL** so the team knows to stop."
" Today's date is {current_date}; treat it as 'now' for all analysis and tool-call date ranges. {instrument_context}" # No tool-calling here: the data is pre-fetched into the
# prompt, so tool-range wording would only invite a
# hallucinated tool call (#1130).
" Today's date is {current_date}; treat it as 'now' for all analysis. {instrument_context}"
" " + NO_EXTERNAL_TOOLS +
"\n{system_message}", "\n{system_message}",
), ),
MessagesPlaceholder(variable_name="messages"), MessagesPlaceholder(variable_name="messages"),

View File

@@ -16,6 +16,7 @@ from tradingagents.agents.utils.agent_utils import (
get_language_instruction, get_language_instruction,
) )
from tradingagents.agents.utils.structured import ( from tradingagents.agents.utils.structured import (
NO_EXTERNAL_TOOLS,
bind_structured, bind_structured,
invoke_structured_or_freetext, invoke_structured_or_freetext,
) )
@@ -61,7 +62,9 @@ def create_portfolio_manager(llm):
--- ---
Be decisive and ground every conclusion in specific evidence from the analysts.{get_language_instruction()}""" Be decisive and ground every conclusion in specific evidence from the analysts.
{NO_EXTERNAL_TOOLS}{get_language_instruction()}"""
final_trade_decision = invoke_structured_or_freetext( final_trade_decision = invoke_structured_or_freetext(
structured_llm, structured_llm,

View File

@@ -8,6 +8,7 @@ from tradingagents.agents.utils.agent_utils import (
get_language_instruction, get_language_instruction,
) )
from tradingagents.agents.utils.structured import ( from tradingagents.agents.utils.structured import (
NO_EXTERNAL_TOOLS,
bind_structured, bind_structured,
invoke_structured_or_freetext, invoke_structured_or_freetext,
) )
@@ -40,7 +41,9 @@ Commit to a clear stance whenever the debate's strongest arguments warrant one;
--- ---
**Debate History:** **Debate History:**
{history}""" + get_language_instruction() {history}
{NO_EXTERNAL_TOOLS}""" + get_language_instruction()
investment_plan = invoke_structured_or_freetext( investment_plan = invoke_structured_or_freetext(
structured_llm, structured_llm,

View File

@@ -1,6 +1,7 @@
from tradingagents.agents.utils.agent_utils import ( from tradingagents.agents.utils.agent_utils import (
get_instrument_context_from_state, get_instrument_context_from_state,
get_language_instruction, get_language_instruction,
opponent_argument_or_opening,
) )
@@ -10,7 +11,9 @@ def create_bear_researcher(llm):
history = investment_debate_state.get("history", "") history = investment_debate_state.get("history", "")
bear_history = investment_debate_state.get("bear_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"] market_research_report = state["market_report"]
sentiment_report = state["sentiment_report"] sentiment_report = state["sentiment_report"]
news_report = state["news_report"] news_report = state["news_report"]

View File

@@ -1,6 +1,7 @@
from tradingagents.agents.utils.agent_utils import ( from tradingagents.agents.utils.agent_utils import (
get_instrument_context_from_state, get_instrument_context_from_state,
get_language_instruction, get_language_instruction,
opponent_argument_or_opening,
) )
@@ -10,7 +11,9 @@ def create_bull_researcher(llm):
history = investment_debate_state.get("history", "") history = investment_debate_state.get("history", "")
bull_history = investment_debate_state.get("bull_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"] market_research_report = state["market_report"]
sentiment_report = state["sentiment_report"] sentiment_report = state["sentiment_report"]
news_report = state["news_report"] news_report = state["news_report"]

View File

@@ -1,6 +1,7 @@
from tradingagents.agents.utils.agent_utils import ( from tradingagents.agents.utils.agent_utils import (
get_instrument_context_from_state, get_instrument_context_from_state,
get_language_instruction, get_language_instruction,
opponent_argument_or_opening,
) )
@@ -10,8 +11,12 @@ def create_aggressive_debator(llm):
history = risk_debate_state.get("history", "") history = risk_debate_state.get("history", "")
aggressive_history = risk_debate_state.get("aggressive_history", "") aggressive_history = risk_debate_state.get("aggressive_history", "")
current_conservative_response = risk_debate_state.get("current_conservative_response", "") current_conservative_response = opponent_argument_or_opening(
current_neutral_response = risk_debate_state.get("current_neutral_response", "") 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"] market_research_report = state["market_report"]
sentiment_report = state["sentiment_report"] sentiment_report = state["sentiment_report"]

View File

@@ -1,6 +1,7 @@
from tradingagents.agents.utils.agent_utils import ( from tradingagents.agents.utils.agent_utils import (
get_instrument_context_from_state, get_instrument_context_from_state,
get_language_instruction, get_language_instruction,
opponent_argument_or_opening,
) )
@@ -10,8 +11,12 @@ def create_conservative_debator(llm):
history = risk_debate_state.get("history", "") history = risk_debate_state.get("history", "")
conservative_history = risk_debate_state.get("conservative_history", "") conservative_history = risk_debate_state.get("conservative_history", "")
current_aggressive_response = risk_debate_state.get("current_aggressive_response", "") current_aggressive_response = opponent_argument_or_opening(
current_neutral_response = risk_debate_state.get("current_neutral_response", "") 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"] market_research_report = state["market_report"]
sentiment_report = state["sentiment_report"] sentiment_report = state["sentiment_report"]

View File

@@ -1,6 +1,7 @@
from tradingagents.agents.utils.agent_utils import ( from tradingagents.agents.utils.agent_utils import (
get_instrument_context_from_state, get_instrument_context_from_state,
get_language_instruction, get_language_instruction,
opponent_argument_or_opening,
) )
@@ -10,8 +11,12 @@ def create_neutral_debator(llm):
history = risk_debate_state.get("history", "") history = risk_debate_state.get("history", "")
neutral_history = risk_debate_state.get("neutral_history", "") neutral_history = risk_debate_state.get("neutral_history", "")
current_aggressive_response = risk_debate_state.get("current_aggressive_response", "") current_aggressive_response = opponent_argument_or_opening(
current_conservative_response = risk_debate_state.get("current_conservative_response", "") 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"] market_research_report = state["market_report"]
sentiment_report = state["sentiment_report"] sentiment_report = state["sentiment_report"]

View File

@@ -12,6 +12,7 @@ from tradingagents.agents.utils.agent_utils import (
get_language_instruction, get_language_instruction,
) )
from tradingagents.agents.utils.structured import ( from tradingagents.agents.utils.structured import (
NO_EXTERNAL_TOOLS,
bind_structured, bind_structured,
invoke_structured_or_freetext, invoke_structured_or_freetext,
) )
@@ -24,6 +25,23 @@ def create_trader(llm):
company_name = state["company_of_interest"] company_name = state["company_of_interest"]
instrument_context = get_instrument_context_from_state(state) instrument_context = get_instrument_context_from_state(state)
investment_plan = state["investment_plan"] 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 = [ messages = [
{ {
@@ -31,19 +49,19 @@ def create_trader(llm):
"content": ( "content": (
"You are a trading agent analyzing market data to make investment decisions. " "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. " "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() + get_language_instruction()
), ),
}, },
{ {
"role": "user", "role": "user",
"content": ( "content": (
f"Based on a comprehensive analysis by a team of analysts, here is an investment " f"Here is the research team's investment plan for {company_name}. "
f"plan tailored for {company_name}. {instrument_context} This plan incorporates " f"{instrument_context}\n\n"
f"insights from current technical market trends, macroeconomic indicators, and " f"{report_section}"
f"social media sentiment. Use this plan as a foundation for evaluating your next " f"Proposed Investment Plan:\n{investment_plan}\n\n"
f"trading decision.\n\nProposed Investment Plan: {investment_plan}\n\n" f"Make an informed, strategic trading decision."
f"Leverage these insights to make an informed and strategic decision."
), ),
}, },
] ]

View File

@@ -65,6 +65,20 @@ def get_language_instruction() -> str:
return f" Write your entire response in {lang}." 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: def _clean_identity_value(value: Any) -> str | None:
"""Return a trimmed string, or None for empty / placeholder-ish values.""" """Return a trimmed string, or None for empty / placeholder-ish values."""
if not isinstance(value, str): if not isinstance(value, str):

View File

@@ -67,9 +67,21 @@ class TradingMemoryLog:
"""Return entries with outcome:pending (for Phase B).""" """Return entries with outcome:pending (for Phase B)."""
return [e for e in self.load_entries() if e.get("pending")] 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: def get_past_context(
"""Return formatted past context string for agent prompt injection.""" 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")] 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: if not entries:
return "" return ""
@@ -104,12 +116,14 @@ class TradingMemoryLog:
alpha_return: float, alpha_return: float,
holding_days: int, holding_days: int,
reflection: str, reflection: str,
resolution_date: str | None = None,
) -> None: ) -> None:
"""Replace pending tag and append REFLECTION section using atomic write. """Replace pending tag and append REFLECTION section using atomic write.
Finds the first pending entry matching (trade_date, ticker), updates Finds the first pending entry matching (trade_date, ticker), updates
its tag with return figures, and appends a REFLECTION section. Uses its tag with return figures (and the ``resolution_date`` the outcome
a temp-file + os.replace() so a crash mid-write never corrupts the log. 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(): if not self._log_path or not self._log_path.exists():
return return
@@ -140,9 +154,8 @@ class TradingMemoryLog:
# Parse rating from the existing pending tag # Parse rating from the existing pending tag
fields = [f.strip() for f in tag_line[1:-1].split("|")] fields = [f.strip() for f in tag_line[1:-1].split("|")]
rating = fields[2] rating = fields[2]
new_tag = ( new_tag = self._resolved_tag(
f"[{trade_date} | {ticker} | {rating}" trade_date, ticker, rating, raw_pct, alpha_pct, holding_days, resolution_date
f" | {raw_pct} | {alpha_pct} | {holding_days}d]"
) )
rest = "\n".join(lines[1:]) rest = "\n".join(lines[1:])
new_blocks.append( new_blocks.append(
@@ -194,9 +207,9 @@ class TradingMemoryLog:
rating = fields[2] rating = fields[2]
raw_pct = f"{upd['raw_return']:+.1%}" raw_pct = f"{upd['raw_return']:+.1%}"
alpha_pct = f"{upd['alpha_return']:+.1%}" alpha_pct = f"{upd['alpha_return']:+.1%}"
new_tag = ( new_tag = self._resolved_tag(
f"[{trade_date} | {ticker} | {rating}" trade_date, ticker, rating, raw_pct, alpha_pct,
f" | {raw_pct} | {alpha_pct} | {upd['holding_days']}d]" upd["holding_days"], upd.get("resolution_date"),
) )
rest = "\n".join(lines[1:]) rest = "\n".join(lines[1:])
new_blocks.append( new_blocks.append(
@@ -217,6 +230,21 @@ class TradingMemoryLog:
# --- Helpers --- # --- 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]: def _apply_rotation(self, blocks: list[str]) -> list[str]:
"""Drop oldest resolved blocks when their count exceeds max_entries. """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("|")] fields = [f.strip() for f in tag_line[1:-1].split("|")]
if len(fields) < 4: if len(fields) < 4:
return None 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 = { entry = {
"date": fields[0], "date": fields[0],
"ticker": fields[1], "ticker": fields[1],
@@ -272,6 +306,7 @@ class TradingMemoryLog:
"raw": fields[3] if fields[3] != "pending" else None, "raw": fields[3] if fields[3] != "pending" else None,
"alpha": fields[4] if len(fields) > 4 else None, "alpha": fields[4] if len(fields) > 4 else None,
"holding": fields[5] if len(fields) > 5 else None, "holding": fields[5] if len(fields) > 5 else None,
"resolved": resolved,
} }
body = "\n".join(lines[1:]).strip() body = "\n".join(lines[1:]).strip()
decision_match = self._DECISION_RE.search(body) decision_match = self._DECISION_RE.search(body)

View File

@@ -7,42 +7,77 @@ The same five-tier scale (Buy, Overweight, Hold, Underweight, Sell) is used by:
- The memory log (rating tag stored alongside each decision entry) - The memory log (rating tag stored alongside each decision entry)
Centralising it here avoids drift between those call sites. 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 from __future__ import annotations
import re import re
import unicodedata
# Canonical, ordered 5-tier scale (most bullish to most bearish). # Canonical, ordered 5-tier scale (most bullish to most bearish).
RATINGS_5_TIER: tuple[str, ...] = ( RATINGS_5_TIER: tuple[str, ...] = (
"Buy", "Overweight", "Hold", "Underweight", "Sell", "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} _RATING_SET = {r.lower() for r in RATINGS_5_TIER}
# Matches "Rating: X" / "rating - X" / "Rating: **X**" — tolerates markdown # Matches "Rating: X" / "rating - X" / "Rating: **X**" — tolerates markdown
# bold wrappers and either a colon or hyphen separator. # bold wrappers and either a colon or hyphen separator.
_RATING_LABEL_RE = re.compile(r"rating.*?[:\-][\s*]*(\w+)", re.IGNORECASE) _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: def extract_rating(text: str) -> str | None:
1. Look for an explicit "Rating: X" label (tolerant of markdown bold). """Extract a 5-tier rating from prose, or ``None`` if none is present.
2. Fall back to the first 5-tier rating word found anywhere in the text.
Returns a Title-cased rating string, or ``default`` if no rating word appears. Two-pass strategy on the NFKC-normalized text (so fullwidth punctuation like
``RatingOverweight`` is matched the same as ASCII):
1. An explicit "Rating: X" label (tolerant of markdown bold).
2. The first standalone 5-tier rating word found anywhere.
""" """
for line in text.splitlines(): if not text:
return None
norm = unicodedata.normalize("NFKC", text)
for line in norm.splitlines():
m = _RATING_LABEL_RE.search(line) m = _RATING_LABEL_RE.search(line)
if m and m.group(1).lower() in _RATING_SET: if m and m.group(1).lower() in _RATING_SET:
return m.group(1).capitalize() return m.group(1).capitalize()
for line in text.splitlines(): m = _RATING_WORD_RE.search(norm)
for word in line.lower().split(): if m:
clean = word.strip("*:.,") return m.group(1).capitalize()
if clean in _RATING_SET:
return clean.capitalize()
return default return None
def parse_rating(text: str, default: str = "Hold") -> str:
"""Extract a 5-tier rating, falling back to ``default`` when none is found.
Legacy convenience wrapper: it always returns a rating string, so an
unparseable decision silently becomes ``default`` (``Hold``). Callers that
must distinguish "no rating" from a real Hold should use
:func:`extract_rating` (or the graph's REVIEW-surfacing signal) instead.
"""
rating = extract_rating(text)
return rating if rating is not None else default
def is_review(signal: str) -> bool:
"""Whether a signal is the non-tradeable REVIEW sentinel (#1170)."""
return signal == RATING_REVIEW

View File

@@ -28,6 +28,16 @@ logger = logging.getLogger(__name__)
T = TypeVar("T", bound=BaseModel) T = TypeVar("T", bound=BaseModel)
# Schema-only structured output binds exactly one tool (the schema itself), so a
# model that reaches for a search tool emits an unknown tool call and the whole
# structured attempt is discarded for a free-text retry. Agents on this path
# state the constraint explicitly rather than relying on the binding alone
# (#1130).
NO_EXTERNAL_TOOLS = (
"Use only the evidence provided in this prompt. Do not call external tools "
"or search the web; if something is missing, say so explicitly."
)
def bind_structured(llm: Any, schema: type[T], agent_name: str) -> Any | None: def bind_structured(llm: Any, schema: type[T], agent_name: str) -> Any | None:
"""Return ``llm.with_structured_output(schema)`` or ``None`` if unsupported. """Return ``llm.with_structured_output(schema)`` or ``None`` if unsupported.

View File

@@ -0,0 +1,30 @@
"""Shared look-ahead-safe date-window filtering for dated content.
News, StockTwits, and Reddit all pull recent items that must be trimmed to the
analysis window so a historical/backtest run never sees content published after
its as-of date. Centralizing the rule keeps every source consistent (#1126,
#1220): every timestamp is normalized to UTC, the upper bound is exclusive at
midnight after ``end`` (so an item stamped exactly then can't leak), and an
undated item is kept only when the window reaches the present (a live run), since
in a backtest we can't prove it isn't future.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
def to_utc(dt: datetime) -> datetime:
"""Normalize a datetime to UTC-aware; a naive value is assumed to be UTC."""
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
def in_window(pub_dt: datetime | None, start_dt: datetime, end_dt: datetime) -> bool:
"""Whether an item belongs in the half-open window ``[start, end + 1 day)``.
``pub_dt`` None means undated: kept only when the window reaches the present.
"""
end = to_utc(end_dt)
if pub_dt is not None:
return to_utc(start_dt) <= to_utc(pub_dt) < end + timedelta(days=1)
return end >= datetime.now(timezone.utc) - timedelta(days=1)

View File

@@ -143,8 +143,12 @@ def get_macro_data(
Args: Args:
indicator: A friendly alias (e.g. "cpi", "unemployment", "10y_treasury") indicator: A friendly alias (e.g. "cpi", "unemployment", "10y_treasury")
or a raw FRED series ID (e.g. "CPIAUCSL", "DGS10"). or a raw FRED series ID (e.g. "CPIAUCSL", "DGS10").
curr_date: End of the window (yyyy-mm-dd); no later observations are curr_date: The as-of date (yyyy-mm-dd). It bounds the observation window
returned, so a past date never leaks future data. 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. look_back_days: Trailing window length; ``None`` uses DEFAULT_LOOKBACK_DAYS.
Returns: Returns:
@@ -157,6 +161,12 @@ def get_macro_data(
end_dt = datetime.strptime(curr_date, "%Y-%m-%d") end_dt = datetime.strptime(curr_date, "%Y-%m-%d")
start_date = (end_dt - timedelta(days=look_back_days)).strftime("%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 # Invalid LLM-supplied indicator: return guidance rather than raising, so a
# bad argument doesn't abort the run (the routing layer also degrades macro # bad argument doesn't abort the run (the routing layer also degrades macro
# data, but a specific message is more useful to the analyst). # data, but a specific message is more useful to the analyst).
@@ -165,7 +175,7 @@ def get_macro_data(
except ValueError as e: except ValueError as e:
return f"FRED: {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: if not meta:
return ( return (
f"FRED series '{series_id}' not found. Pass a known alias " f"FRED series '{series_id}' not found. Pass a known alias "
@@ -184,6 +194,7 @@ def get_macro_data(
"observation_start": start_date, "observation_start": start_date,
"observation_end": curr_date, "observation_end": curr_date,
"sort_order": "asc", "sort_order": "asc",
**realtime,
}, },
).get("observations", []) ).get("observations", [])

View File

@@ -25,15 +25,35 @@ import re
import time import time
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from collections.abc import Iterable from collections.abc import Iterable
from datetime import datetime from datetime import datetime, timezone
from urllib.error import HTTPError from urllib.error import HTTPError
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from .date_window import in_window
from .symbol_utils import crypto_base from .symbol_utils import crypto_base
logger = logging.getLogger(__name__) 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}" _API = "https://www.reddit.com/r/{sub}/search.json?{qs}"
_RSS = "https://www.reddit.com/r/{sub}/search.rss?{qs}" _RSS = "https://www.reddit.com/r/{sub}/search.rss?{qs}"
# A descriptive, identified User-Agent (per Reddit's API etiquette). Reddit # A descriptive, identified User-Agent (per Reddit's API etiquette). Reddit
@@ -194,6 +214,8 @@ def fetch_reddit_posts(
limit_per_sub: int = 5, limit_per_sub: int = 5,
timeout: float = 10.0, timeout: float = 10.0,
inter_request_delay: float = 1.0, inter_request_delay: float = 1.0,
start_date: str | None = None,
end_date: str | None = None,
) -> str: ) -> str:
"""Fetch recent Reddit posts mentioning ``ticker`` across finance """Fetch recent Reddit posts mentioning ``ticker`` across finance
subreddits and return them as a formatted plaintext block. 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 ``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 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. 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 # 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. # ("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): for i, sub in enumerate(subreddits):
if i > 0: if i > 0:
time.sleep(inter_request_delay) 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) total_posts += len(posts)
if not posts: if not posts:
blocks.append(f"r/{sub}: <no posts found mentioning {ticker.upper()} in the past 7 days>") blocks.append(f"r/{sub}: <no posts found mentioning {ticker.upper()} in the past 7 days>")

View File

@@ -19,6 +19,12 @@ logger = logging.getLogger(__name__)
# enough to catch the year-old frames yfinance occasionally returns (#1021). # enough to catch the year-old frames yfinance occasionally returns (#1021).
MAX_OHLCV_STALE_DAYS = 10 MAX_OHLCV_STALE_DAYS = 10
# How long a same-day cache that does not yet reach the requested day may be
# reused before it is refetched (#1150). Short enough that an intraday run picks
# up today's close soon after it publishes, long enough that a day with no bar
# at all (weekend, holiday) cannot trigger a download on every call.
OHLCV_CACHE_TTL_SECONDS = 900
def yf_retry(func, max_retries=3, base_delay=2.0): def yf_retry(func, max_retries=3, base_delay=2.0):
"""Execute a yfinance call with exponential backoff on rate limits. """Execute a yfinance call with exponential backoff on rate limits.
@@ -54,17 +60,53 @@ def _ensure_date_column(data: pd.DataFrame) -> pd.DataFrame:
return data 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: 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 = _ensure_date_column(data)
data["Date"] = pd.to_datetime(data["Date"], errors="coerce") data["Date"] = _normalize_dates(data["Date"])
data = data.dropna(subset=["Date"]) data = data.dropna(subset=["Date"])
price_cols = [c for c in ["Open", "High", "Low", "Close", "Volume"] if c in data.columns] 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[price_cols] = data[price_cols].apply(pd.to_numeric, errors="coerce")
data = data.dropna(subset=["Close"]) return data
data[price_cols] = data[price_cols].ffill().bfill()
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 return data
@@ -122,6 +164,23 @@ def _assert_ohlcv_not_stale(
) )
def _needs_same_day_refresh(data_file, curr_date_dt, today_date) -> bool:
"""Whether a cached frame must be refetched to reflect the requested day.
The cache file is keyed per day, so without this a run started before the
day's bar was final keeps serving that snapshot to every later run (#1150).
Two distinct staleness cases exist for a current-day request: the bar may be
missing entirely, or present but still in progress — Yahoo publishes a
partial daily candle during market hours, whose ``Close`` is not the closing
price. Row inspection cannot tell a partial bar from a final one, so the TTL
governs every current-day cache. Historical requests always reuse the cache,
since those rows are immutable.
"""
if curr_date_dt.date() < today_date.date():
return False
return time.time() - os.path.getmtime(data_file) > OHLCV_CACHE_TTL_SECONDS
def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame: def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
"""Fetch OHLCV data with caching, filtered to prevent look-ahead bias. """Fetch OHLCV data with caching, filtered to prevent look-ahead bias.
@@ -136,7 +195,7 @@ def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
safe_symbol = safe_ticker_component(canonical) safe_symbol = safe_ticker_component(canonical)
config = get_config() 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. # Cache uses a fixed window (5y to today) so one file per symbol.
today_date = pd.Timestamp.today() today_date = pd.Timestamp.today()
@@ -159,7 +218,13 @@ def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
data = None data = None
if os.path.exists(data_file): if os.path.exists(data_file):
cached = pd.read_csv(data_file, on_bad_lines="skip", encoding="utf-8") cached = pd.read_csv(data_file, on_bad_lines="skip", encoding="utf-8")
if not cached.empty and "Close" in cached.columns: # Serve the cache only when it is usable and not a stale snapshot of the
# day being requested (#1150); otherwise fall through and refetch.
if (
not cached.empty
and "Close" in cached.columns
and not _needs_same_day_refresh(data_file, curr_date_dt, today_date)
):
data = cached data = cached
if data is None: if data is None:
@@ -182,9 +247,20 @@ def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
data = _clean_dataframe(data) 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] 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 # Reject a stale frame (latest row far older than curr_date) rather than
# feeding year-old prices into indicators (#1021). # feeding year-old prices into indicators (#1021).
_assert_ohlcv_not_stale(data, curr_date, symbol, canonical) _assert_ohlcv_not_stale(data, curr_date, symbol, canonical)

View File

@@ -14,11 +14,14 @@ network call succeeded.
from __future__ import annotations from __future__ import annotations
import contextlib
import http.client import http.client
import json import json
import logging import logging
from datetime import datetime
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from .date_window import in_window
from .symbol_utils import crypto_base from .symbol_utils import crypto_base
logger = logging.getLogger(__name__) 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)" _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: def _stocktwits_symbol(ticker: str) -> str:
"""Map a crypto pair to StockTwits' ``<BASE>.X`` convention. """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() 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 """Fetch recent StockTwits messages for ``ticker`` and return them as a
formatted plaintext block ready for prompt injection. 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 Returns a placeholder string when the endpoint is unreachable, the
symbol has no messages, or the response shape is unexpected — the symbol has no messages, or the response shape is unexpected — the
caller never has to special-case None or exceptions. 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__}>" return f"<stocktwits unavailable: {type(exc).__name__}>"
messages = data.get("messages", []) if isinstance(data, dict) else [] messages = data.get("messages", []) if isinstance(data, dict) else []
messages = _within_window(messages, start_date, end_date)
if not messages: 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()}>" return f"<no StockTwits messages found for ${ticker.upper()}>"
lines = [] lines = []

View File

@@ -1,12 +1,13 @@
"""yfinance-based news data fetching functions.""" """yfinance-based news data fetching functions."""
import contextlib import contextlib
from datetime import datetime from datetime import datetime, timezone
import yfinance as yf import yfinance as yf
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from .config import get_config from .config import get_config
from .date_window import in_window
from .stockstats_utils import yf_retry from .stockstats_utils import yf_retry
from .symbol_utils import normalize_symbol from .symbol_utils import normalize_symbol
@@ -46,8 +47,10 @@ def _extract_article_data(article: dict) -> dict:
pub_date = None pub_date = None
ts = article.get("providerPublishTime") ts = article.get("providerPublishTime")
if ts: if ts:
# Epoch seconds are UTC; parse them as UTC-aware so filtering does
# not shift with the host timezone (#1126).
with contextlib.suppress(ValueError, OSError, TypeError): with contextlib.suppress(ValueError, OSError, TypeError):
pub_date = datetime.fromtimestamp(ts) pub_date = datetime.fromtimestamp(ts, tz=timezone.utc)
return { return {
"title": article.get("title", "No title"), "title": article.get("title", "No title"),
"summary": article.get("summary", ""), "summary": article.get("summary", ""),
@@ -57,20 +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 [start_dt, end_dt] window.
Dated articles are kept only if they fall in the window. 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 (look-ahead safety, #992/#1007).
"""
if pub_date is not None:
naive = pub_date.replace(tzinfo=None) if hasattr(pub_date, "replace") else pub_date
return start_dt <= naive <= end_dt + relativedelta(days=1)
return end_dt >= datetime.now() - relativedelta(days=1)
def get_news_yfinance( def get_news_yfinance(
ticker: str, ticker: str,
start_date: str, start_date: str,
@@ -111,7 +100,7 @@ def get_news_yfinance(
data = _extract_article_data(article) data = _extract_article_data(article)
# Keep only articles within the requested window (look-ahead safe). # 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 continue
news_str += f"### {data['title']} (source: {data['publisher']})\n" news_str += f"### {data['title']} (source: {data['publisher']})\n"
@@ -198,7 +187,7 @@ def get_global_news_yfinance(
# Extract uniformly (flat + nested) and apply the same look-ahead-safe # Extract uniformly (flat + nested) and apply the same look-ahead-safe
# window filter, so flat articles can't leak future news (#1007). # window filter, so flat articles can't leak future news (#1007).
data = _extract_article_data(article) 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 continue
news_str += f"### {data['title']} (source: {data['publisher']})\n" news_str += f"### {data['title']} (source: {data['publisher']})\n"
if data["summary"]: if data["summary"]:

View File

@@ -19,6 +19,7 @@ _ENV_OVERRIDES = {
"TRADINGAGENTS_BENCHMARK_TICKER": "benchmark_ticker", "TRADINGAGENTS_BENCHMARK_TICKER": "benchmark_ticker",
"TRADINGAGENTS_TEMPERATURE": "temperature", "TRADINGAGENTS_TEMPERATURE": "temperature",
"TRADINGAGENTS_LLM_MAX_RETRIES": "llm_max_retries", "TRADINGAGENTS_LLM_MAX_RETRIES": "llm_max_retries",
"TRADINGAGENTS_MAX_TOKENS": "max_tokens",
# Provider-specific reasoning/thinking knobs (None = each provider's own # Provider-specific reasoning/thinking knobs (None = each provider's own
# default). Settable here for non-interactive runs; the CLI also offers an # default). Settable here for non-interactive runs; the CLI also offers an
# interactive choice, which is skipped when the matching var is set. # 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, "memory_log_max_entries": None,
# LLM settings # LLM settings
"llm_provider": "openai", "llm_provider": "openai",
"deep_think_llm": "gpt-5.5", "deep_think_llm": "gpt-5.6",
"quick_think_llm": "gpt-5.4-mini", "quick_think_llm": "gpt-5.6-luna",
# When None, each provider's client falls back to its own default endpoint # When None, each provider's client falls back to its own default endpoint
# (api.openai.com for OpenAI, generativelanguage.googleapis.com for Gemini, ...). # (api.openai.com for OpenAI, generativelanguage.googleapis.com for Gemini, ...).
# The CLI overrides this per provider when the user picks one. Keeping a # 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 # 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). # 429 throttling on rate-limited deployments instead of aborting a run (#1091).
"llm_max_retries": None, "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 # Checkpoint/resume: when True, LangGraph saves state after each node
# so a crashed run can resume from the last successful step. # so a crashed run can resume from the last successful step.
"checkpoint_enabled": False, "checkpoint_enabled": False,

View File

@@ -14,18 +14,25 @@ from __future__ import annotations
from typing import Any from typing import Any
from tradingagents.agents.utils.rating import parse_rating from tradingagents.agents.utils.rating import RATING_REVIEW, extract_rating
class SignalProcessor: class SignalProcessor:
"""Read the 5-tier rating out of a Portfolio Manager decision.""" """Read the 5-tier rating out of a Portfolio Manager decision."""
def __init__(self, quick_thinking_llm: Any = None): def __init__(self, quick_thinking_llm: Any = None):
# The LLM argument is accepted for backwards compatibility but no # The LLM argument is accepted for backwards compatibility but ignored:
# longer used: the PM's structured output guarantees the rating is # the PM's structured output guarantees the rating is parseable from the
# parseable from the rendered markdown without a second LLM call. # rendered markdown without a second LLM call, so it is not stored.
self.quick_thinking_llm = quick_thinking_llm pass
def process_signal(self, full_signal: str) -> str: def process_signal(self, full_signal: str) -> str:
"""Return one of Buy / Overweight / Hold / Underweight / Sell.""" """Return one of Buy / Overweight / Hold / Underweight / Sell, or REVIEW.
return parse_rating(full_signal)
An unrecognizable decision yields ``REVIEW`` rather than a fabricated
``Hold``, so a parsing failure is visible instead of masquerading as a
tradeable neutral signal (#1170). Consumers that map the result onto the
5-tier enum should guard with :func:`~tradingagents.agents.utils.rating.is_review`.
"""
rating = extract_rating(full_signal)
return rating if rating is not None else RATING_REVIEW

View File

@@ -3,6 +3,7 @@
import json import json
import logging import logging
import os import os
from contextlib import contextmanager
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -62,6 +63,19 @@ def _coerce_max_retries(value):
return n 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: class TradingAgentsGraph:
"""Main class that orchestrates the trading agents framework.""" """Main class that orchestrates the trading agents framework."""
@@ -149,6 +163,7 @@ class TradingAgentsGraph:
self.workflow = self.graph_setup.setup_graph(selected_analysts) self.workflow = self.graph_setup.setup_graph(selected_analysts)
self.graph = self.workflow.compile() self.graph = self.workflow.compile()
self._checkpointer_ctx = None self._checkpointer_ctx = None
self._resuming = False
def _get_provider_kwargs(self) -> dict[str, Any]: def _get_provider_kwargs(self) -> dict[str, Any]:
"""Get provider-specific kwargs for LLM client creation.""" """Get provider-specific kwargs for LLM client creation."""
@@ -183,6 +198,13 @@ class TradingAgentsGraph:
if max_retries is not None and max_retries != "": if max_retries is not None and max_retries != "":
kwargs["max_retries"] = _coerce_max_retries(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 return kwargs
def _create_tool_nodes(self) -> dict[str, ToolNode]: def _create_tool_nodes(self) -> dict[str, ToolNode]:
@@ -251,13 +273,16 @@ class TradingAgentsGraph:
def _fetch_returns( def _fetch_returns(
self, ticker: str, trade_date: str, holding_days: int = 5, self, ticker: str, trade_date: str, holding_days: int = 5,
benchmark: str = "SPY", 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. """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 ``benchmark`` is the index used as the alpha baseline (resolved by the
caller via ``_resolve_benchmark``). Returns ``(raw_return, alpha_return, caller via ``_resolve_benchmark``). Returns ``(raw_return, alpha_return,
actual_holding_days)`` or ``(None, None, None)`` if price data is holding_days, resolution_date)`` — where ``resolution_date`` is the date
unavailable (too recent, delisted, or network error). 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 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) stock = yf.Ticker(normalize_symbol(ticker)).history(start=trade_date, end=end_str)
bench = yf.Ticker(benchmark).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: # Require the full holding window in both series. A rerun before it
return None, None, None # 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( raw = float(
(stock["Close"].iloc[actual_days] - stock["Close"].iloc[0]) (stock["Close"].iloc[holding_days] - stock["Close"].iloc[0])
/ stock["Close"].iloc[0] / stock["Close"].iloc[0]
) )
bench_ret = float( 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] / bench["Close"].iloc[0]
) )
alpha = raw - bench_ret 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: except Exception as e:
logger.warning( logger.warning(
"Could not resolve outcome for %s on %s vs %s (will retry next run): %s", "Could not resolve outcome for %s on %s vs %s (will retry next run): %s",
ticker, trade_date, benchmark, e, ticker, trade_date, benchmark, e,
) )
return None, None, None return None, None, None, None
def _resolve_pending_entries(self, ticker: str) -> None: def _resolve_pending_entries(self, ticker: str) -> None:
"""Resolve pending log entries for ticker at the start of a new run. """Resolve pending log entries for ticker at the start of a new run.
@@ -310,7 +340,7 @@ class TradingAgentsGraph:
benchmark = self._resolve_benchmark(ticker) benchmark = self._resolve_benchmark(ticker)
updates = [] updates = []
for entry in pending: for entry in pending:
raw, alpha, days = self._fetch_returns( raw, alpha, days, resolution_date = self._fetch_returns(
ticker, entry["date"], benchmark=benchmark, ticker, entry["date"], benchmark=benchmark,
) )
if raw is None: if raw is None:
@@ -328,6 +358,7 @@ class TradingAgentsGraph:
"alpha_return": alpha, "alpha_return": alpha,
"holding_days": days, "holding_days": days,
"reflection": reflection, "reflection": reflection,
"resolution_date": resolution_date,
}) })
if updates: if updates:
@@ -345,6 +376,17 @@ class TradingAgentsGraph:
identity = resolve_instrument_identity(ticker) identity = resolve_instrument_identity(ticker)
return build_instrument_context(ticker, asset_type, identity) 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: def _run_signature(self, asset_type: str) -> str:
"""Graph-shape inputs that must invalidate a checkpoint if changed. """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 ``checkpoint_enabled`` is set in config, the graph is recompiled with
a per-ticker SqliteSaver so a crashed run can resume from the last a per-ticker SqliteSaver so a crashed run can resume from the last
successful node on a subsequent invocation with the same ticker+date. 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 self.ticker = company_name
# Resolve any pending memory-log entries for this ticker before the pipeline runs. # Resolve any pending memory-log entries for this ticker before the pipeline runs.
self._resolve_pending_entries(company_name) self._resolve_pending_entries(company_name)
# Recompile with a checkpointer if the user opted in. with self.checkpoint_scope(company_name, trade_date, asset_type) as thread_id_value:
if self.config.get("checkpoint_enabled"): return self._run_graph(
self._checkpointer_ctx = get_checkpointer( company_name, trade_date, asset_type=asset_type,
self.config["data_cache_dir"], company_name checkpoint_thread_id=thread_id_value,
) )
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__() saver = self._checkpointer_ctx.__enter__()
self.graph = self.workflow.compile(checkpointer=saver) self.graph = self.workflow.compile(checkpointer=saver)
step = checkpoint_step( step = checkpoint_step(
self.config["data_cache_dir"], company_name, str(trade_date), self.config["data_cache_dir"], company_name, str(trade_date), signature
self._run_signature(asset_type),
) )
self._resuming = step is not None
if step is not None: if step is not None:
logger.info( logger.info("Resuming from step %d for %s on %s", step, company_name, trade_date)
"Resuming from step %d for %s on %s", step, company_name, trade_date
)
else: else:
logger.info("Starting fresh for %s on %s", company_name, trade_date) logger.info("Starting fresh for %s on %s", company_name, trade_date)
return thread_id(company_name, str(trade_date), signature)
try: def checkpoint_input(self, init_state):
return self._run_graph(company_name, trade_date, asset_type=asset_type) """The value to stream/invoke: ``None`` to resume an existing checkpoint,
finally: 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: if self._checkpointer_ctx is not None:
self._checkpointer_ctx.__exit__(None, None, None) self._checkpointer_ctx.__exit__(None, None, None)
self._checkpointer_ctx = None self._checkpointer_ctx = None
self.graph = self.workflow.compile() 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),
)
def save_reports(self, final_state, ticker, save_path=None) -> Path: def save_reports(self, final_state, ticker, save_path=None) -> Path:
"""Write the markdown report tree for a completed run, like the CLI does. """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) 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.""" """Execute the graph and write the resulting state to disk and memory log."""
# Initialize state — inject memory log context for PM and the # Initialize state — inject memory log context for PM and the
# deterministically resolved instrument identity for all agents. # deterministically resolved instrument identity for all agents. On a
past_context = self.memory_log.get_past_context(company_name) # 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) instrument_context = self.resolve_instrument_context(company_name, asset_type)
init_agent_state = self.propagator.create_initial_state( init_agent_state = self.propagator.create_initial_state(
company_name, company_name,
@@ -431,16 +526,17 @@ class TradingAgentsGraph:
) )
args = self.propagator.get_graph_args() args = self.propagator.get_graph_args()
# Inject thread_id so same ticker+date+graph-shape resumes; a different # Inject the checkpoint thread_id (from checkpoint_scope) so the same
# date or graph shape starts fresh (#1089). # ticker+date+graph-shape resumes; a different one starts fresh (#1089).
if self.config.get("checkpoint_enabled"): if checkpoint_thread_id is not None:
tid = thread_id(company_name, str(trade_date), self._run_signature(asset_type)) args.setdefault("config", {}).setdefault("configurable", {})["thread_id"] = checkpoint_thread_id
args.setdefault("config", {}).setdefault("configurable", {})["thread_id"] = tid
# None resumes an existing checkpoint; init_agent_state starts fresh (#1249).
graph_input = self.checkpoint_input(init_agent_state)
if self.debug: if self.debug:
trace = [] trace = []
last_printed = None 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"]: if chunk["messages"]:
msg = chunk["messages"][-1] msg = chunk["messages"][-1]
# Nodes after the trader don't append to messages, so the # Nodes after the trader don't append to messages, so the
@@ -457,7 +553,7 @@ class TradingAgentsGraph:
for chunk in trace: for chunk in trace:
final_state.update(chunk) final_state.update(chunk)
else: else:
final_state = self.graph.invoke(init_agent_state, **args) final_state = self.graph.invoke(graph_input, **args)
# Store current state for reflection. # Store current state for reflection.
self.curr_state = final_state self.curr_state = final_state
@@ -473,11 +569,7 @@ class TradingAgentsGraph:
) )
# Clear checkpoint on successful completion to avoid stale state. # Clear checkpoint on successful completion to avoid stale state.
if self.config.get("checkpoint_enabled"): self.clear_checkpoint_on_success(company_name, trade_date, asset_type)
clear_checkpoint(
self.config["data_cache_dir"], company_name, str(trade_date),
self._run_signature(asset_type),
)
return final_state, self.process_signal(final_state["final_trade_decision"]) return final_state, self.process_signal(final_state["final_trade_decision"])

View File

@@ -118,6 +118,15 @@ _BY_PATTERN: list[tuple[re.Pattern[str], ModelCapabilities]] = [
def get_capabilities(model_name: str) -> ModelCapabilities: def get_capabilities(model_name: str) -> ModelCapabilities:
"""Resolve capabilities by exact ID, then pattern, then default.""" """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: if model_name in _BY_ID:
return _BY_ID[model_name] return _BY_ID[model_name]
for pattern, caps in _BY_PATTERN: for pattern, caps in _BY_PATTERN:

View File

@@ -31,7 +31,8 @@ class GoogleClient(BaseLLMClient):
if self.base_url: if self.base_url:
llm_kwargs["base_url"] = 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: if key in self.kwargs:
llm_kwargs[key] = self.kwargs[key] llm_kwargs[key] = self.kwargs[key]

View File

@@ -18,15 +18,15 @@ _CUSTOM_ONLY: dict[str, list[ModelOption]] = {
# All GLM 4.7+ entries support thinking mode via thinking={"type":"enabled"}. # All GLM 4.7+ entries support thinking mode via thinking={"type":"enabled"}.
_GLM_MODELS: dict[str, list[ModelOption]] = { _GLM_MODELS: dict[str, list[ModelOption]] = {
"quick": [ "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-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"), ("GLM-4.5-Air - Lightweight, cost-efficient", "glm-4.5-air"),
("Custom model ID", "custom"), ("Custom model ID", "custom"),
], ],
"deep": [ "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.1 - 745B, 200K ctx", "glm-5.1"),
("GLM-5 - Flagship, 204K ctx", "glm-5"),
("GLM-4.7 - Previous-gen flagship", "glm-4.7"), ("GLM-4.7 - Previous-gen flagship", "glm-4.7"),
("Custom model ID", "custom"), ("Custom model ID", "custom"),
], ],
@@ -81,15 +81,15 @@ _MINIMAX_MODELS: dict[str, list[ModelOption]] = {
MODEL_OPTIONS: ProviderModeOptions = { MODEL_OPTIONS: ProviderModeOptions = {
"openai": { "openai": {
"quick": [ "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 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": [ "deep": [
("GPT-5.5 - Latest frontier, 1M context", "gpt-5.5"), ("GPT-5.6 - Latest frontier reasoning (Sol)", "gpt-5.6"),
("GPT-5.4 - Previous-gen frontier, 1M context, cost-effective", "gpt-5.4"), ("GPT-5.6 Terra - Balances intelligence and cost", "gpt-5.6-terra"),
("GPT-5.2 - Strong reasoning, cost-effective", "gpt-5.2"), ("GPT-5.5 - Previous-gen frontier, 1M context", "gpt-5.5"),
("GPT-5.5 Pro - Most capable, expensive ($30/$180 per 1M tokens)", "gpt-5.5-pro"), ("GPT-5.4 - Cost-effective, 1M context", "gpt-5.4"),
], ],
}, },
"anthropic": { "anthropic": {

View File

@@ -164,7 +164,7 @@ class MinimaxChatOpenAI(NormalizedChatOpenAI):
# Kwargs forwarded from user config to ChatOpenAI # Kwargs forwarded from user config to ChatOpenAI
_PASSTHROUGH_KWARGS = ( _PASSTHROUGH_KWARGS = (
"timeout", "max_retries", "reasoning_effort", "temperature", "timeout", "max_retries", "reasoning_effort", "temperature", "max_tokens",
"api_key", "callbacks", "http_client", "http_async_client", "api_key", "callbacks", "http_client", "http_async_client",
) )