mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-09-19 19:25:24 +03:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a26ae17a1 | ||
|
|
a4acd8a174 | ||
|
|
2322dd9baa | ||
|
|
70b58c21dc | ||
|
|
2448d0a125 | ||
|
|
c95f83dfaf | ||
|
|
ecbe3e3a21 | ||
|
|
e93c5c53c2 | ||
|
|
45c1744b86 | ||
|
|
63be7fe7f1 | ||
|
|
30d42abd5d | ||
|
|
a2f51da917 | ||
|
|
b43bc31479 | ||
|
|
8db41f6bca | ||
|
|
51a245dbe1 | ||
|
|
43fc275b36 | ||
|
|
0ef56e6a33 | ||
|
|
539eae8fd6 | ||
|
|
9b98f09613 | ||
|
|
8b7ece8a3e | ||
|
|
a33fd4c0f1 | ||
|
|
7bbe33ab1d | ||
|
|
030b434585 | ||
|
|
3f6c082695 | ||
|
|
d78c698d0e | ||
|
|
40774ca042 |
@@ -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
|
||||||
|
|||||||
57
CHANGELOG.md
57
CHANGELOG.md
@@ -6,6 +6,63 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
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,
|
||||||
|
|||||||
33
README.md
33
README.md
@@ -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 |
234
cli/main.py
234
cli/main.py
@@ -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,109 +1127,130 @@ 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:
|
||||||
# Process all messages in chunk, deduplicating by message ID
|
for chunk in graph.graph.stream(graph.checkpoint_input(init_agent_state), **args):
|
||||||
for message in chunk.get("messages", []):
|
# Process all messages in chunk, deduplicating by message ID
|
||||||
msg_id = getattr(message, "id", None)
|
for message in chunk.get("messages", []):
|
||||||
if msg_id is not None:
|
msg_id = getattr(message, "id", None)
|
||||||
if msg_id in message_buffer._processed_message_ids:
|
if msg_id is not None:
|
||||||
continue
|
if msg_id in message_buffer._processed_message_ids:
|
||||||
message_buffer._processed_message_ids.add(msg_id)
|
continue
|
||||||
|
message_buffer._processed_message_ids.add(msg_id)
|
||||||
|
|
||||||
msg_type, content = classify_message_type(message)
|
msg_type, content = classify_message_type(message)
|
||||||
if content and content.strip():
|
if content and content.strip():
|
||||||
message_buffer.add_message(msg_type, content)
|
message_buffer.add_message(msg_type, content)
|
||||||
|
|
||||||
if hasattr(message, "tool_calls") and message.tool_calls:
|
if hasattr(message, "tool_calls") and message.tool_calls:
|
||||||
for tool_call in message.tool_calls:
|
for tool_call in message.tool_calls:
|
||||||
if isinstance(tool_call, dict):
|
if isinstance(tool_call, dict):
|
||||||
message_buffer.add_tool_call(tool_call["name"], tool_call["args"])
|
message_buffer.add_tool_call(tool_call["name"], tool_call["args"])
|
||||||
else:
|
else:
|
||||||
message_buffer.add_tool_call(tool_call.name, tool_call.args)
|
message_buffer.add_tool_call(tool_call.name, tool_call.args)
|
||||||
|
|
||||||
# Update analyst statuses based on report state (runs on every chunk)
|
# Update analyst statuses based on report state (runs on every chunk)
|
||||||
update_analyst_statuses(
|
update_analyst_statuses(
|
||||||
message_buffer,
|
message_buffer,
|
||||||
chunk,
|
chunk,
|
||||||
wall_time_tracker=analyst_wall_time_tracker,
|
wall_time_tracker=analyst_wall_time_tracker,
|
||||||
)
|
|
||||||
|
|
||||||
# Research Team - Handle Investment Debate State
|
|
||||||
if chunk.get("investment_debate_state"):
|
|
||||||
debate_state = chunk["investment_debate_state"]
|
|
||||||
bull_hist = debate_state.get("bull_history", "").strip()
|
|
||||||
bear_hist = debate_state.get("bear_history", "").strip()
|
|
||||||
judge = debate_state.get("judge_decision", "").strip()
|
|
||||||
|
|
||||||
# Only update status when there's actual content
|
|
||||||
if bull_hist or bear_hist:
|
|
||||||
update_research_team_status("in_progress")
|
|
||||||
if bull_hist:
|
|
||||||
message_buffer.update_report_section(
|
|
||||||
"investment_plan", f"### Bull Researcher Analysis\n{bull_hist}"
|
|
||||||
)
|
|
||||||
if bear_hist:
|
|
||||||
message_buffer.update_report_section(
|
|
||||||
"investment_plan", f"### Bear Researcher Analysis\n{bear_hist}"
|
|
||||||
)
|
|
||||||
if judge:
|
|
||||||
message_buffer.update_report_section(
|
|
||||||
"investment_plan", f"### Research Manager Decision\n{judge}"
|
|
||||||
)
|
|
||||||
update_research_team_status("completed")
|
|
||||||
message_buffer.update_agent_status("Trader", "in_progress")
|
|
||||||
|
|
||||||
# Trading Team
|
|
||||||
if chunk.get("trader_investment_plan"):
|
|
||||||
message_buffer.update_report_section(
|
|
||||||
"trader_investment_plan", chunk["trader_investment_plan"]
|
|
||||||
)
|
)
|
||||||
if message_buffer.agent_status.get("Trader") != "completed":
|
|
||||||
message_buffer.update_agent_status("Trader", "completed")
|
|
||||||
message_buffer.update_agent_status("Aggressive Analyst", "in_progress")
|
|
||||||
|
|
||||||
# Risk Management Team - Handle Risk Debate State
|
# Research Team - Handle Investment Debate State
|
||||||
if chunk.get("risk_debate_state"):
|
if chunk.get("investment_debate_state"):
|
||||||
risk_state = chunk["risk_debate_state"]
|
debate_state = chunk["investment_debate_state"]
|
||||||
agg_hist = risk_state.get("aggressive_history", "").strip()
|
bull_hist = debate_state.get("bull_history", "").strip()
|
||||||
con_hist = risk_state.get("conservative_history", "").strip()
|
bear_hist = debate_state.get("bear_history", "").strip()
|
||||||
neu_hist = risk_state.get("neutral_history", "").strip()
|
judge = debate_state.get("judge_decision", "").strip()
|
||||||
judge = risk_state.get("judge_decision", "").strip()
|
|
||||||
|
|
||||||
if agg_hist:
|
# Only update status when there's actual content
|
||||||
if message_buffer.agent_status.get("Aggressive Analyst") != "completed":
|
if bull_hist or bear_hist:
|
||||||
|
update_research_team_status("in_progress")
|
||||||
|
if bull_hist:
|
||||||
|
message_buffer.update_report_section(
|
||||||
|
"investment_plan", f"### Bull Researcher Analysis\n{bull_hist}"
|
||||||
|
)
|
||||||
|
if bear_hist:
|
||||||
|
message_buffer.update_report_section(
|
||||||
|
"investment_plan", f"### Bear Researcher Analysis\n{bear_hist}"
|
||||||
|
)
|
||||||
|
if judge:
|
||||||
|
message_buffer.update_report_section(
|
||||||
|
"investment_plan", f"### Research Manager Decision\n{judge}"
|
||||||
|
)
|
||||||
|
update_research_team_status("completed")
|
||||||
|
message_buffer.update_agent_status("Trader", "in_progress")
|
||||||
|
|
||||||
|
# Trading Team
|
||||||
|
if chunk.get("trader_investment_plan"):
|
||||||
|
message_buffer.update_report_section(
|
||||||
|
"trader_investment_plan", chunk["trader_investment_plan"]
|
||||||
|
)
|
||||||
|
if message_buffer.agent_status.get("Trader") != "completed":
|
||||||
|
message_buffer.update_agent_status("Trader", "completed")
|
||||||
message_buffer.update_agent_status("Aggressive Analyst", "in_progress")
|
message_buffer.update_agent_status("Aggressive Analyst", "in_progress")
|
||||||
message_buffer.update_report_section(
|
|
||||||
"final_trade_decision", f"### Aggressive Analyst Analysis\n{agg_hist}"
|
|
||||||
)
|
|
||||||
if con_hist:
|
|
||||||
if message_buffer.agent_status.get("Conservative Analyst") != "completed":
|
|
||||||
message_buffer.update_agent_status("Conservative Analyst", "in_progress")
|
|
||||||
message_buffer.update_report_section(
|
|
||||||
"final_trade_decision", f"### Conservative Analyst Analysis\n{con_hist}"
|
|
||||||
)
|
|
||||||
if neu_hist:
|
|
||||||
if message_buffer.agent_status.get("Neutral Analyst") != "completed":
|
|
||||||
message_buffer.update_agent_status("Neutral Analyst", "in_progress")
|
|
||||||
message_buffer.update_report_section(
|
|
||||||
"final_trade_decision", f"### Neutral Analyst Analysis\n{neu_hist}"
|
|
||||||
)
|
|
||||||
if judge and message_buffer.agent_status.get("Portfolio Manager") != "completed":
|
|
||||||
message_buffer.update_agent_status("Portfolio Manager", "in_progress")
|
|
||||||
message_buffer.update_report_section(
|
|
||||||
"final_trade_decision", f"### Portfolio Manager Decision\n{judge}"
|
|
||||||
)
|
|
||||||
message_buffer.update_agent_status("Aggressive Analyst", "completed")
|
|
||||||
message_buffer.update_agent_status("Conservative Analyst", "completed")
|
|
||||||
message_buffer.update_agent_status("Neutral Analyst", "completed")
|
|
||||||
message_buffer.update_agent_status("Portfolio Manager", "completed")
|
|
||||||
|
|
||||||
# Update the display
|
# Risk Management Team - Handle Risk Debate State
|
||||||
update_display(layout, stats_handler=stats_handler, start_time=start_time)
|
if chunk.get("risk_debate_state"):
|
||||||
|
risk_state = chunk["risk_debate_state"]
|
||||||
|
agg_hist = risk_state.get("aggressive_history", "").strip()
|
||||||
|
con_hist = risk_state.get("conservative_history", "").strip()
|
||||||
|
neu_hist = risk_state.get("neutral_history", "").strip()
|
||||||
|
judge = risk_state.get("judge_decision", "").strip()
|
||||||
|
|
||||||
trace.append(chunk)
|
if agg_hist:
|
||||||
|
if message_buffer.agent_status.get("Aggressive Analyst") != "completed":
|
||||||
|
message_buffer.update_agent_status("Aggressive Analyst", "in_progress")
|
||||||
|
message_buffer.update_report_section(
|
||||||
|
"final_trade_decision", f"### Aggressive Analyst Analysis\n{agg_hist}"
|
||||||
|
)
|
||||||
|
if con_hist:
|
||||||
|
if message_buffer.agent_status.get("Conservative Analyst") != "completed":
|
||||||
|
message_buffer.update_agent_status("Conservative Analyst", "in_progress")
|
||||||
|
message_buffer.update_report_section(
|
||||||
|
"final_trade_decision", f"### Conservative Analyst Analysis\n{con_hist}"
|
||||||
|
)
|
||||||
|
if neu_hist:
|
||||||
|
if message_buffer.agent_status.get("Neutral Analyst") != "completed":
|
||||||
|
message_buffer.update_agent_status("Neutral Analyst", "in_progress")
|
||||||
|
message_buffer.update_report_section(
|
||||||
|
"final_trade_decision", f"### Neutral Analyst Analysis\n{neu_hist}"
|
||||||
|
)
|
||||||
|
if judge and message_buffer.agent_status.get("Portfolio Manager") != "completed":
|
||||||
|
message_buffer.update_agent_status("Portfolio Manager", "in_progress")
|
||||||
|
message_buffer.update_report_section(
|
||||||
|
"final_trade_decision", f"### Portfolio Manager Decision\n{judge}"
|
||||||
|
)
|
||||||
|
message_buffer.update_agent_status("Aggressive Analyst", "completed")
|
||||||
|
message_buffer.update_agent_status("Conservative Analyst", "completed")
|
||||||
|
message_buffer.update_agent_status("Neutral Analyst", "completed")
|
||||||
|
message_buffer.update_agent_status("Portfolio Manager", "completed")
|
||||||
|
|
||||||
|
# Update the display
|
||||||
|
update_display(layout, stats_handler=stats_handler, start_time=start_time)
|
||||||
|
|
||||||
|
trace.append(chunk)
|
||||||
|
|
||||||
|
# Clean run: drop this run's checkpoint so a later run starts fresh.
|
||||||
|
# A mid-stream failure skips this, keeping the checkpoint for resume.
|
||||||
|
graph.clear_checkpoint_on_success(
|
||||||
|
selections["ticker"], selections["analysis_date"], selections["asset_type"]
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Always restore the plain uncheckpointed graph, even on failure.
|
||||||
|
graph.end_checkpoint()
|
||||||
|
|
||||||
# Streamed chunks are per-node deltas, not full state. Merge them
|
# 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.
|
||||||
@@ -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]")
|
||||||
run_analysis(checkpoint=checkpoint)
|
try:
|
||||||
|
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__":
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
155
tests/test_checkpoint_lifecycle.py
Normal file
155
tests/test_checkpoint_lifecycle.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"""The checkpoint lifecycle is reusable so --checkpoint works on the CLI path (#1249).
|
||||||
|
|
||||||
|
Checkpoint setup previously lived only inside ``propagate``; the CLI streamed the
|
||||||
|
checkpointer-less graph, so ``--checkpoint`` neither saved nor resumed. The
|
||||||
|
lifecycle is now ``begin_checkpoint`` / ``end_checkpoint`` /
|
||||||
|
``clear_checkpoint_on_success`` on TradingAgentsGraph, used by both paths. These
|
||||||
|
tests drive that lifecycle exactly as the CLI does (begin -> stream self.graph ->
|
||||||
|
clear/end) and prove state is saved and resumed.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from langgraph.graph import END, StateGraph
|
||||||
|
|
||||||
|
from tradingagents.graph.checkpointer import checkpoint_step
|
||||||
|
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||||
|
|
||||||
|
_should_crash = False
|
||||||
|
|
||||||
|
|
||||||
|
class _State(TypedDict):
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
def _node_a(state: _State) -> dict:
|
||||||
|
return {"count": state["count"] + 1}
|
||||||
|
|
||||||
|
|
||||||
|
def _node_b(state: _State) -> dict:
|
||||||
|
if _should_crash:
|
||||||
|
raise RuntimeError("simulated mid-stream crash")
|
||||||
|
return {"count": state["count"] + 10}
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow() -> StateGraph:
|
||||||
|
b = StateGraph(_State)
|
||||||
|
b.add_node("analyst", _node_a)
|
||||||
|
b.add_node("trader", _node_b)
|
||||||
|
b.set_entry_point("analyst")
|
||||||
|
b.add_edge("analyst", "trader")
|
||||||
|
b.add_edge("trader", END)
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
def _bare_graph(tmpdir, *, enabled=True):
|
||||||
|
g = object.__new__(TradingAgentsGraph)
|
||||||
|
g.config = {
|
||||||
|
"checkpoint_enabled": enabled, "data_cache_dir": tmpdir,
|
||||||
|
"max_debate_rounds": 1, "max_risk_discuss_rounds": 1,
|
||||||
|
}
|
||||||
|
g.selected_analysts = ("market",)
|
||||||
|
g.workflow = _workflow()
|
||||||
|
g.graph = g.workflow.compile()
|
||||||
|
g._checkpointer_ctx = None
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_disabled_is_a_noop():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
g = _bare_graph(tmp, enabled=False)
|
||||||
|
plain = g.graph
|
||||||
|
assert g.begin_checkpoint("AAPL", "2026-05-08", "stock") is None
|
||||||
|
assert g.graph is plain # graph not recompiled
|
||||||
|
g.end_checkpoint() # safe no-op
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_begin_returns_thread_id_and_recompiles():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
g = _bare_graph(tmp)
|
||||||
|
plain = g.graph
|
||||||
|
tid = g.begin_checkpoint("AAPL", "2026-05-08", "stock")
|
||||||
|
try:
|
||||||
|
assert tid # a real thread_id
|
||||||
|
assert g.graph is not plain # recompiled with a checkpointer
|
||||||
|
finally:
|
||||||
|
g.end_checkpoint()
|
||||||
|
assert g._checkpointer_ctx is None # restored
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_checkpoint_input_is_none_only_when_resuming():
|
||||||
|
global _should_crash
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
init = {"count": 0}
|
||||||
|
args = ("AAPL", "2026-05-08", "stock")
|
||||||
|
# Fresh run: no checkpoint yet -> stream the initial state, then crash.
|
||||||
|
g1 = _bare_graph(tmp)
|
||||||
|
tid = g1.begin_checkpoint(*args)
|
||||||
|
try:
|
||||||
|
assert g1._resuming is False
|
||||||
|
assert g1.checkpoint_input(init) is init # not resuming -> initial state
|
||||||
|
_should_crash = True
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
for _ in g1.graph.stream(init, config={"configurable": {"thread_id": tid}}):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
g1.end_checkpoint()
|
||||||
|
assert g1.checkpoint_input(init) is init # reset after teardown
|
||||||
|
|
||||||
|
# A later run finds the checkpoint -> resume by feeding None, not the
|
||||||
|
# initial state (re-passing it would duplicate messages, #1249).
|
||||||
|
_should_crash = False
|
||||||
|
g2 = _bare_graph(tmp)
|
||||||
|
g2.begin_checkpoint(*args)
|
||||||
|
try:
|
||||||
|
assert g2._resuming is True
|
||||||
|
assert g2.checkpoint_input(init) is None
|
||||||
|
finally:
|
||||||
|
g2.end_checkpoint()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_cli_style_usage_saves_then_resumes():
|
||||||
|
global _should_crash
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
cfg_args = ("AAPL", "2026-05-08", "stock")
|
||||||
|
|
||||||
|
# Run 1 (the CLI path): begin -> stream self.graph -> crash at 'trader'.
|
||||||
|
_should_crash = True
|
||||||
|
g1 = _bare_graph(tmp)
|
||||||
|
tid = g1.begin_checkpoint(*cfg_args)
|
||||||
|
args = {"config": {"configurable": {"thread_id": tid}}}
|
||||||
|
try:
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
for _ in g1.graph.stream({"count": 0}, **args):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
g1.end_checkpoint()
|
||||||
|
|
||||||
|
# A checkpoint was saved for this run signature (so --checkpoint works).
|
||||||
|
|
||||||
|
sig = g1._run_signature("stock")
|
||||||
|
assert checkpoint_step(tmp, "AAPL", "2026-05-08", sig) is not None
|
||||||
|
|
||||||
|
# Run 2 (fresh graph, as a new CLI invocation): resume and finish.
|
||||||
|
_should_crash = False
|
||||||
|
g2 = _bare_graph(tmp)
|
||||||
|
tid2 = g2.begin_checkpoint(*cfg_args)
|
||||||
|
assert tid2 == tid # stable id -> same thread resumes
|
||||||
|
try:
|
||||||
|
result = g2.graph.invoke(None, config={"configurable": {"thread_id": tid2}})
|
||||||
|
assert result["count"] == 11 # analyst(+1) resumed into trader(+10)
|
||||||
|
g2.clear_checkpoint_on_success(*cfg_args)
|
||||||
|
finally:
|
||||||
|
g2.end_checkpoint()
|
||||||
|
|
||||||
|
# Cleared on success -> a later run starts fresh.
|
||||||
|
assert checkpoint_step(tmp, "AAPL", "2026-05-08", sig) is None
|
||||||
57
tests/test_cli_no_console.py
Normal file
57
tests/test_cli_no_console.py
Normal 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)
|
||||||
111
tests/test_debate_opening.py
Normal file
111
tests/test_debate_opening.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""The first speaker in each debate must not rebut a nonexistent argument (#1176).
|
||||||
|
|
||||||
|
Each debate round's opening speaker receives an empty opponent response; the
|
||||||
|
prompt used to interpolate it into a "refute the opponent" instruction, so models
|
||||||
|
fabricated the other side's position. All five debators (bull, bear, and the
|
||||||
|
three risk analysts) now substitute an explicit opening marker when the opponent
|
||||||
|
has not spoken, and pass a real argument through unchanged.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tradingagents.agents.researchers.bear_researcher import create_bear_researcher
|
||||||
|
from tradingagents.agents.researchers.bull_researcher import create_bull_researcher
|
||||||
|
from tradingagents.agents.risk_mgmt.aggressive_debator import create_aggressive_debator
|
||||||
|
from tradingagents.agents.risk_mgmt.conservative_debator import create_conservative_debator
|
||||||
|
from tradingagents.agents.risk_mgmt.neutral_debator import create_neutral_debator
|
||||||
|
from tradingagents.agents.utils.agent_utils import opponent_argument_or_opening
|
||||||
|
|
||||||
|
_REPORTS = {
|
||||||
|
"company_of_interest": "AAPL", "asset_type": "stock",
|
||||||
|
"market_report": "m", "sentiment_report": "s",
|
||||||
|
"news_report": "n", "fundamentals_report": "f",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _capturing_llm(captured: dict):
|
||||||
|
llm = MagicMock()
|
||||||
|
llm.invoke.side_effect = lambda prompt: (
|
||||||
|
captured.__setitem__("prompt", prompt) or MagicMock(content="argument")
|
||||||
|
)
|
||||||
|
return llm
|
||||||
|
|
||||||
|
|
||||||
|
def _investment_state(current_response):
|
||||||
|
return {
|
||||||
|
**_REPORTS,
|
||||||
|
"count": 0,
|
||||||
|
"investment_debate_state": {
|
||||||
|
"history": "", "bull_history": "", "bear_history": "",
|
||||||
|
"current_response": current_response, "count": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _risk_state(**responses):
|
||||||
|
base = {
|
||||||
|
"current_aggressive_response": "", "current_conservative_response": "",
|
||||||
|
"current_neutral_response": "", "history": "", "aggressive_history": "",
|
||||||
|
"conservative_history": "", "neutral_history": "", "count": 0,
|
||||||
|
}
|
||||||
|
base.update(responses)
|
||||||
|
return {**_REPORTS, "trader_investment_plan": "plan", "risk_debate_state": base}
|
||||||
|
|
||||||
|
|
||||||
|
# --- shared helper ----------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_helper_marks_empty_and_passes_through():
|
||||||
|
assert "has not spoken yet" in opponent_argument_or_opening("", "bear analyst")
|
||||||
|
assert opponent_argument_or_opening(" real point ", "bear") == "real point"
|
||||||
|
|
||||||
|
|
||||||
|
# --- researchers ------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"factory,opponent",
|
||||||
|
[(create_bull_researcher, "bear"), (create_bear_researcher, "bull")],
|
||||||
|
)
|
||||||
|
def test_researcher_opening_has_no_phantom_opponent(factory, opponent):
|
||||||
|
captured = {}
|
||||||
|
factory(_capturing_llm(captured))(_investment_state(""))
|
||||||
|
assert "has not spoken yet" in captured["prompt"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_researcher_passes_real_opponent_argument():
|
||||||
|
captured = {}
|
||||||
|
state = _investment_state("Bear Analyst: valuation is stretched")
|
||||||
|
create_bull_researcher(_capturing_llm(captured))(state)
|
||||||
|
assert "valuation is stretched" in captured["prompt"]
|
||||||
|
assert "has not spoken yet" not in captured["prompt"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- risk debators ----------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"factory", [create_aggressive_debator, create_conservative_debator, create_neutral_debator]
|
||||||
|
)
|
||||||
|
def test_risk_opening_has_no_phantom_opponent(factory):
|
||||||
|
captured = {}
|
||||||
|
factory(_capturing_llm(captured))(_risk_state())
|
||||||
|
# Both opponent slots were empty -> two opening markers, no fabricated args.
|
||||||
|
assert captured["prompt"].count("has not spoken yet") == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_risk_passes_real_opponent_arguments():
|
||||||
|
captured = {}
|
||||||
|
state = _risk_state(
|
||||||
|
current_conservative_response="Conservative Analyst: trim risk",
|
||||||
|
current_neutral_response="Neutral Analyst: hold steady",
|
||||||
|
)
|
||||||
|
create_aggressive_debator(_capturing_llm(captured))(state)
|
||||||
|
assert "trim risk" in captured["prompt"]
|
||||||
|
assert "hold steady" in captured["prompt"]
|
||||||
|
assert "has not spoken yet" not in captured["prompt"]
|
||||||
@@ -21,8 +21,8 @@ def _reload_with_env(monkeypatch, **overrides):
|
|||||||
def test_no_env_uses_built_in_defaults(monkeypatch):
|
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
|
||||||
|
|||||||
@@ -150,6 +150,47 @@ 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 pin the vintage
|
||||||
|
# to curr_date (clamped to FRED's today), or FRED serves the latest
|
||||||
|
# revision and revision-prone series leak future information. A past
|
||||||
|
# curr_date sits below FRED's today, so it pins through unchanged.
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _capture(path, params):
|
||||||
|
captured[path] = params
|
||||||
|
return _META if path == "series" else _OBS
|
||||||
|
|
||||||
|
with mock.patch.object(fred, "_fred_today", return_value="2026-01-01"), \
|
||||||
|
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)
|
||||||
|
|
||||||
|
def test_future_curr_date_clamps_vintage_to_fred_today(self):
|
||||||
|
# #1275 regression: on a live run curr_date is the caller's LOCAL date,
|
||||||
|
# which can be a day ahead of FRED's US-Central clock. Pinning the vintage
|
||||||
|
# to that future date 400s, and the routing layer then drops macro data
|
||||||
|
# silently. The pin must clamp to FRED's today; the observation window
|
||||||
|
# (future bars can't exist yet) stays at curr_date.
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _capture(path, params):
|
||||||
|
captured[path] = params
|
||||||
|
return _META if path == "series" else _OBS
|
||||||
|
|
||||||
|
with mock.patch.object(fred, "_fred_today", return_value="2026-08-31"), \
|
||||||
|
mock.patch.object(fred, "_request", side_effect=_capture):
|
||||||
|
fred.get_macro_data("cpi", "2026-09-01", 90) # local a day ahead of Chicago
|
||||||
|
|
||||||
|
for path in ("series", "series/observations"):
|
||||||
|
self.assertEqual(captured[path]["realtime_start"], "2026-08-31", path)
|
||||||
|
self.assertEqual(captured[path]["realtime_end"], "2026-08-31", path)
|
||||||
|
# the observation window still tracks curr_date, not the clamped vintage
|
||||||
|
self.assertEqual(captured["series/observations"]["observation_end"], "2026-09-01")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class FredRoutingTests(unittest.TestCase):
|
class FredRoutingTests(unittest.TestCase):
|
||||||
|
|||||||
122
tests/test_llm_max_tokens.py
Normal file
122
tests/test_llm_max_tokens.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""Configurable output-token cap (#1204).
|
||||||
|
|
||||||
|
Some model/gateway combinations (e.g. deepseek-v4-flash deployments) emit
|
||||||
|
unbounded reasoning/output and hang or trip an idle timeout. An opt-in
|
||||||
|
``max_tokens`` config knob is forwarded to every provider so a run can bound it;
|
||||||
|
Gemini names the parameter ``max_output_tokens``, so it is forwarded under the
|
||||||
|
right key per provider.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import tradingagents.default_config as default_config_module
|
||||||
|
from tradingagents.graph.trading_graph import TradingAgentsGraph, _coerce_max_tokens
|
||||||
|
|
||||||
|
# --- coercion / validation -------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("value,expected", [(1, 1), (8192, 8192), ("4096", 4096)])
|
||||||
|
def test_coerce_accepts_positive_ints_and_numeric_strings(value, expected):
|
||||||
|
assert _coerce_max_tokens(value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("bad", [0, -1, "0", "-5"])
|
||||||
|
def test_coerce_rejects_non_positive(bad):
|
||||||
|
with pytest.raises(ValueError, match="> 0"):
|
||||||
|
_coerce_max_tokens(bad)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("bad", [True, False])
|
||||||
|
def test_coerce_rejects_booleans(bad):
|
||||||
|
with pytest.raises(ValueError, match="boolean"):
|
||||||
|
_coerce_max_tokens(bad)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("bad", ["abc", "1.5", None])
|
||||||
|
def test_coerce_rejects_non_integers(bad):
|
||||||
|
with pytest.raises(ValueError, match="integer"):
|
||||||
|
_coerce_max_tokens(bad)
|
||||||
|
|
||||||
|
|
||||||
|
# --- forwarding into provider kwargs (right key per provider) --------------
|
||||||
|
|
||||||
|
def _bare_graph(config):
|
||||||
|
g = object.__new__(TradingAgentsGraph)
|
||||||
|
g.config = config
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_not_forwarded_when_unset():
|
||||||
|
kwargs = _bare_graph({"llm_provider": "openai", "max_tokens": None})._get_provider_kwargs()
|
||||||
|
assert "max_tokens" not in kwargs
|
||||||
|
assert "max_output_tokens" not in kwargs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@pytest.mark.parametrize("provider", ["openai", "anthropic", "deepseek", "openai_compatible"])
|
||||||
|
def test_forwarded_as_max_tokens_for_non_google(provider):
|
||||||
|
kwargs = _bare_graph({"llm_provider": provider, "max_tokens": 8192})._get_provider_kwargs()
|
||||||
|
assert kwargs["max_tokens"] == 8192
|
||||||
|
assert "max_output_tokens" not in kwargs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_forwarded_as_max_output_tokens_for_google():
|
||||||
|
# Gemini's kwarg name differs; forwarding plain max_tokens would be rejected.
|
||||||
|
kwargs = _bare_graph({"llm_provider": "google", "max_tokens": 8192})._get_provider_kwargs()
|
||||||
|
assert kwargs["max_output_tokens"] == 8192
|
||||||
|
assert "max_tokens" not in kwargs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_env_string_is_coerced():
|
||||||
|
kwargs = _bare_graph({"llm_provider": "openai", "max_tokens": "4096"})._get_provider_kwargs()
|
||||||
|
assert kwargs["max_tokens"] == 4096
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_invalid_value_fails_loudly():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_bare_graph({"llm_provider": "openai", "max_tokens": 0})._get_provider_kwargs()
|
||||||
|
|
||||||
|
|
||||||
|
# --- client-side allowlists carry the kwarg --------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_openai_and_google_clients_accept_the_kwarg():
|
||||||
|
from tradingagents.llm_clients import openai_client
|
||||||
|
from tradingagents.llm_clients.google_client import GoogleClient # noqa: F401
|
||||||
|
assert "max_tokens" in openai_client._PASSTHROUGH_KWARGS
|
||||||
|
# Google client forwards max_output_tokens through construction.
|
||||||
|
llm = GoogleClient("gemini-3.5-flash", api_key="x", max_output_tokens=8192).get_llm()
|
||||||
|
assert getattr(llm, "max_output_tokens", None) == 8192
|
||||||
|
|
||||||
|
|
||||||
|
# --- env overlay -----------------------------------------------------------
|
||||||
|
|
||||||
|
def _reload_with_env(monkeypatch, **overrides):
|
||||||
|
for key in list(default_config_module._ENV_OVERRIDES):
|
||||||
|
monkeypatch.delenv(key, raising=False)
|
||||||
|
for key, val in overrides.items():
|
||||||
|
monkeypatch.setenv(key, val)
|
||||||
|
return importlib.reload(default_config_module)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_default_is_none(monkeypatch):
|
||||||
|
dc = _reload_with_env(monkeypatch)
|
||||||
|
assert dc.DEFAULT_CONFIG["max_tokens"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_env_override_sets_config(monkeypatch):
|
||||||
|
dc = _reload_with_env(monkeypatch, TRADINGAGENTS_MAX_TOKENS="8192")
|
||||||
|
assert dc.DEFAULT_CONFIG["max_tokens"] == "8192"
|
||||||
|
assert _coerce_max_tokens(dc.DEFAULT_CONFIG["max_tokens"]) == 8192
|
||||||
@@ -54,9 +54,14 @@ def _resolve_entry(log, ticker, date, decision, reflection="Good call."):
|
|||||||
log.update_with_outcome(ticker, date, 0.05, 0.02, 5, reflection)
|
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
|
||||||
|
|||||||
95
tests/test_memory_pointintime.py
Normal file
95
tests/test_memory_pointintime.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""Memory-log lessons must be point-in-time safe in a backtest (#1251).
|
||||||
|
|
||||||
|
get_past_context previously returned every resolved lesson regardless of the run
|
||||||
|
date, so a historical run could learn from an outcome that had not happened yet.
|
||||||
|
Resolved entries now record the date their outcome became known (``resolved:``),
|
||||||
|
and get_past_context(as_of=...) filters on it. Legacy entries without a
|
||||||
|
resolution date are excluded from a point-in-time query (conservative migration).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tradingagents.agents.utils.memory import TradingMemoryLog
|
||||||
|
|
||||||
|
|
||||||
|
def _log(tmp_path):
|
||||||
|
return TradingMemoryLog({"memory_log_path": str(tmp_path / "mem.md")})
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(log, ticker, date, resolution_date, reflection):
|
||||||
|
log.store_decision(ticker, date, f"Rating: Buy\n{reflection}")
|
||||||
|
log.update_with_outcome(
|
||||||
|
ticker, date, 0.05, 0.02, 5, reflection, resolution_date=resolution_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_resolution_date_is_stored_and_parsed(tmp_path):
|
||||||
|
log = _log(tmp_path)
|
||||||
|
_resolve(log, "NVDA", "2026-01-05", "2026-01-10", "outcome known 01-10")
|
||||||
|
entry = log.load_entries()[0]
|
||||||
|
assert entry["resolved"] == "2026-01-10"
|
||||||
|
assert "resolved:2026-01-10" in (tmp_path / "mem.md").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_as_of_excludes_lessons_resolved_after_the_run_date(tmp_path):
|
||||||
|
log = _log(tmp_path)
|
||||||
|
# Decision on 01-05, outcome only known on 01-10.
|
||||||
|
_resolve(log, "NVDA", "2026-01-05", "2026-01-10", "great trade")
|
||||||
|
|
||||||
|
# A run as-of 01-07 must NOT see it (the outcome was still in the future).
|
||||||
|
assert log.get_past_context("NVDA", as_of="2026-01-07") == ""
|
||||||
|
# A run as-of 01-10 (and later) sees it.
|
||||||
|
assert "great trade" in log.get_past_context("NVDA", as_of="2026-01-10")
|
||||||
|
assert "great trade" in log.get_past_context("NVDA", as_of="2026-02-01")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_no_as_of_is_unfiltered_live_behavior(tmp_path):
|
||||||
|
log = _log(tmp_path)
|
||||||
|
_resolve(log, "NVDA", "2026-01-05", "2026-01-10", "great trade")
|
||||||
|
# Live run (no as_of): unchanged behavior, lesson is shown.
|
||||||
|
assert "great trade" in log.get_past_context("NVDA")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_legacy_entry_without_resolution_date_excluded_in_backtest(tmp_path):
|
||||||
|
log = _log(tmp_path)
|
||||||
|
# Simulate a pre-migration resolved entry: no resolution_date recorded.
|
||||||
|
log.store_decision("NVDA", "2026-01-05", "Rating: Buy\nlegacy lesson")
|
||||||
|
log.update_with_outcome("NVDA", "2026-01-05", 0.05, 0.02, 5, "legacy lesson")
|
||||||
|
entry = log.load_entries()[0]
|
||||||
|
assert entry["resolved"] is None
|
||||||
|
|
||||||
|
# Conservative: excluded from a point-in-time query (can't prove it was known)...
|
||||||
|
assert log.get_past_context("NVDA", as_of="2026-06-01") == ""
|
||||||
|
# ...but still available on a live (unfiltered) run.
|
||||||
|
assert "legacy lesson" in log.get_past_context("NVDA")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_cross_ticker_lessons_are_also_gated(tmp_path):
|
||||||
|
log = _log(tmp_path)
|
||||||
|
_resolve(log, "AAPL", "2026-01-05", "2026-01-10", "cross lesson")
|
||||||
|
# Querying a different ticker as-of before resolution: no cross lesson leaks.
|
||||||
|
assert log.get_past_context("NVDA", as_of="2026-01-07") == ""
|
||||||
|
assert "cross lesson" in log.get_past_context("NVDA", as_of="2026-01-10")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_memory_as_of_gates_historical_but_not_live():
|
||||||
|
# The graph filters only for a past trade date; a current-date run passes
|
||||||
|
# None so live behavior and legacy entries are unaffected (#1251).
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||||
|
|
||||||
|
g = object.__new__(TradingAgentsGraph)
|
||||||
|
past = "2024-01-01"
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
future = (datetime.now() + timedelta(days=30)).strftime("%Y-%m-%d")
|
||||||
|
assert g._memory_as_of(past) == past # backtest -> filter on the trade date
|
||||||
|
assert g._memory_as_of(today) is None # live -> no filter
|
||||||
|
assert g._memory_as_of(future) is None # future-dated run -> no filter
|
||||||
@@ -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
|
||||||
|
|||||||
109
tests/test_ohlcv_cache_freshness.py
Normal file
109
tests/test_ohlcv_cache_freshness.py
Normal 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"))
|
||||||
136
tests/test_ohlcv_latest_bar.py
Normal file
136
tests/test_ohlcv_latest_bar.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""The latest trading day's bar must not silently vanish (#1201).
|
||||||
|
|
||||||
|
yfinance can return the newest in-range bar with a NaN close (an unsettled or
|
||||||
|
glitched session). The old path parsed dates without normalizing timezone and
|
||||||
|
dropped every NaN-close row before applying the curr_date cutoff, so the latest
|
||||||
|
bar disappeared and the previous trading day looked like the latest. Now dates
|
||||||
|
are normalized, and a latest in-range bar with no close raises rather than
|
||||||
|
silently falling back.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tradingagents.dataflows import stockstats_utils as su
|
||||||
|
from tradingagents.dataflows.symbol_utils import NoMarketDataError
|
||||||
|
|
||||||
|
# --- date normalization -----------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_normalize_dates_strips_tz_and_normalizes_to_midnight():
|
||||||
|
aware = pd.Series(pd.to_datetime(
|
||||||
|
["2026-05-08 09:30:00-04:00", "2026-05-09 16:00:00-04:00"]
|
||||||
|
))
|
||||||
|
out = su._normalize_dates(aware)
|
||||||
|
assert out.dt.tz is None
|
||||||
|
assert list(out) == [pd.Timestamp("2026-05-08"), pd.Timestamp("2026-05-09")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_normalize_dates_leaves_naive_dates_at_midnight():
|
||||||
|
naive = pd.Series(pd.to_datetime(["2026-05-08 14:30:00", "2026-05-09 00:00:00"]))
|
||||||
|
out = su._normalize_dates(naive)
|
||||||
|
assert out.dt.tz is None
|
||||||
|
assert list(out) == [pd.Timestamp("2026-05-08"), pd.Timestamp("2026-05-09")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_normalize_dates_handles_mixed_dst_offsets():
|
||||||
|
# 5y of US bars span DST; via a cache CSV they arrive as mixed-offset
|
||||||
|
# strings, which pd.to_datetime can't unify. Each keeps its own local date.
|
||||||
|
mixed = pd.Series([
|
||||||
|
"2026-01-08 00:00:00-05:00", # EST
|
||||||
|
"2026-06-08 00:00:00-04:00", # EDT
|
||||||
|
"not-a-date", # -> NaT
|
||||||
|
])
|
||||||
|
out = su._normalize_dates(mixed)
|
||||||
|
assert out.iloc[0] == pd.Timestamp("2026-01-08")
|
||||||
|
assert out.iloc[1] == pd.Timestamp("2026-06-08")
|
||||||
|
assert pd.isna(out.iloc[2])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_normalize_dates_keeps_positive_offset_local_date():
|
||||||
|
# A Tokyo bar at local midnight (+09:00) must stay on its own calendar day,
|
||||||
|
# not shift to the previous UTC day (which utc=True parsing would cause).
|
||||||
|
jst = pd.Series(["2026-05-08 00:00:00+09:00"])
|
||||||
|
assert su._normalize_dates(jst).iloc[0] == pd.Timestamp("2026-05-08")
|
||||||
|
|
||||||
|
|
||||||
|
# --- fill vs guard responsibilities ----------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_clean_dataframe_keeps_nan_close_for_the_caller_to_inspect():
|
||||||
|
# _clean_dataframe normalizes but no longer drops the NaN close itself.
|
||||||
|
df = pd.DataFrame({"Date": ["2026-05-08", "2026-05-09"], "Close": [100.0, float("nan")]})
|
||||||
|
cleaned = su._clean_dataframe(df)
|
||||||
|
assert len(cleaned) == 2
|
||||||
|
assert pd.isna(cleaned["Close"].iloc[-1])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_fill_price_gaps_drops_nan_close_rows():
|
||||||
|
df = pd.DataFrame({"Date": pd.to_datetime(["2026-05-07", "2026-05-08"]),
|
||||||
|
"Close": [float("nan"), 100.0]})
|
||||||
|
filled = su._fill_price_gaps(df)
|
||||||
|
assert len(filled) == 1
|
||||||
|
assert filled["Close"].iloc[0] == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
# --- load_ohlcv end-to-end (with a mocked cache read) -----------------------
|
||||||
|
|
||||||
|
def _run_load(monkeypatch, tmp_path, frame, curr_date):
|
||||||
|
"""Drive load_ohlcv against a pre-seeded cache frame (no network)."""
|
||||||
|
monkeypatch.setattr(su, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
|
||||||
|
today = pd.Timestamp(curr_date)
|
||||||
|
monkeypatch.setattr(su.pd.Timestamp, "today", staticmethod(lambda: today))
|
||||||
|
start = (today - pd.DateOffset(years=5)).strftime("%Y-%m-%d")
|
||||||
|
end = (today + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
(tmp_path / f"AAPL-YFin-data-{start}-{end}.csv").write_text(frame.to_csv(index=False))
|
||||||
|
|
||||||
|
def _fail_download(*a, **k):
|
||||||
|
raise AssertionError("should use the seeded cache, not download")
|
||||||
|
monkeypatch.setattr(su.yf, "download", _fail_download)
|
||||||
|
monkeypatch.setattr(su, "_assert_ohlcv_not_stale", lambda *a, **k: None)
|
||||||
|
return su.load_ohlcv("AAPL", curr_date)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_latest_in_range_nan_close_raises_not_silent_fallback(monkeypatch, tmp_path):
|
||||||
|
# Newest bar (the curr_date) has no close -> raise, don't return Thursday.
|
||||||
|
frame = pd.DataFrame({
|
||||||
|
"Date": ["2026-05-07", "2026-05-08"],
|
||||||
|
"Open": [100.0, 101.0], "High": [101.0, 102.0], "Low": [99.0, 100.0],
|
||||||
|
"Close": [100.5, float("nan")], "Volume": [1_000_000, 1_000_000],
|
||||||
|
})
|
||||||
|
with pytest.raises(NoMarketDataError, match="no closing price"):
|
||||||
|
_run_load(monkeypatch, tmp_path, frame, "2026-05-08")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_older_nan_close_row_is_still_dropped(monkeypatch, tmp_path):
|
||||||
|
# A stale gap mid-series is dropped; the valid latest bar is served.
|
||||||
|
frame = pd.DataFrame({
|
||||||
|
"Date": ["2026-05-06", "2026-05-07", "2026-05-08"],
|
||||||
|
"Open": [100.0, 101.0, 102.0], "High": [101.0, 102.0, 103.0],
|
||||||
|
"Low": [99.0, 100.0, 101.0],
|
||||||
|
"Close": [100.5, float("nan"), 102.5], "Volume": [1_000_000, 1_000_000, 1_000_000],
|
||||||
|
})
|
||||||
|
out = _run_load(monkeypatch, tmp_path, frame, "2026-05-08")
|
||||||
|
assert out["Close"].iloc[-1] == 102.5
|
||||||
|
assert (out["Date"] == pd.Timestamp("2026-05-07")).sum() == 0 # the NaN row is gone
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_tz_aware_latest_bar_is_kept_at_the_cutoff(monkeypatch, tmp_path):
|
||||||
|
# A tz-aware/intraday latest bar on the cutoff day must not be filtered out
|
||||||
|
# by a naive-vs-aware comparison.
|
||||||
|
frame = pd.DataFrame({
|
||||||
|
"Date": ["2026-05-07 09:30:00-04:00", "2026-05-08 09:30:00-04:00"],
|
||||||
|
"Open": [100.0, 101.0], "High": [101.0, 102.0], "Low": [99.0, 100.0],
|
||||||
|
"Close": [100.5, 101.5], "Volume": [1_000_000, 1_000_000],
|
||||||
|
})
|
||||||
|
out = _run_load(monkeypatch, tmp_path, frame, "2026-05-08")
|
||||||
|
assert out["Close"].iloc[-1] == 101.5
|
||||||
|
assert out["Date"].iloc[-1] == pd.Timestamp("2026-05-08")
|
||||||
@@ -36,8 +36,9 @@ def _resp(read_fn):
|
|||||||
def __exit__(self_inner, *a):
|
def __exit__(self_inner, *a):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def read(self_inner):
|
def read(self_inner, size=-1):
|
||||||
return read_fn()
|
data = read_fn()
|
||||||
|
return data if size is None or size < 0 else data[:size]
|
||||||
return _Resp()
|
return _Resp()
|
||||||
|
|
||||||
|
|
||||||
@@ -147,6 +148,26 @@ class TestRss429Backoff:
|
|||||||
reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
|
reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
|
||||||
slept.assert_called_once_with(12.0)
|
slept.assert_called_once_with(12.0)
|
||||||
|
|
||||||
|
def test_retry_after_zero_is_honoured_not_treated_as_absent(self):
|
||||||
|
# A valid "Retry-After: 0" means retry at once; it must not fall through
|
||||||
|
# to the fallback wait (the earlier `or 5.0` bug turned 0 into 5s).
|
||||||
|
err = HTTPError("url", 429, "Too Many Requests", {"Retry-After": "0"}, None)
|
||||||
|
with patch.object(reddit, "urlopen", side_effect=[err, _atom_resp()]), \
|
||||||
|
patch.object(reddit.time, "sleep") as slept:
|
||||||
|
reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
|
||||||
|
slept.assert_called_once_with(0.0)
|
||||||
|
|
||||||
|
def test_headerless_429_fallback_is_jittered(self):
|
||||||
|
# No Retry-After -> our own ~5s fallback, jittered so concurrent runs
|
||||||
|
# don't retry in lockstep (kept within a tight band).
|
||||||
|
err = HTTPError("url", 429, "Too Many Requests", {}, None)
|
||||||
|
with patch.object(reddit, "urlopen", side_effect=[err, _atom_resp()]), \
|
||||||
|
patch.object(reddit.time, "sleep") as slept:
|
||||||
|
reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0)
|
||||||
|
slept.assert_called_once()
|
||||||
|
(wait,), _ = slept.call_args
|
||||||
|
assert 4.0 <= wait <= 6.0 # 5s +/-20% jitter
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestChunkedTransferErrorsHandled:
|
class TestChunkedTransferErrorsHandled:
|
||||||
@@ -163,6 +184,14 @@ class TestChunkedTransferErrorsHandled:
|
|||||||
reddit._fetch_subreddit_json("NVDA", "stocks", 5, 5.0)
|
reddit._fetch_subreddit_json("NVDA", "stocks", 5, 5.0)
|
||||||
rss.assert_called_once()
|
rss.assert_called_once()
|
||||||
|
|
||||||
|
def test_oversized_rss_feed_is_refused_not_parsed(self):
|
||||||
|
# A hostile/misbehaving endpoint streaming an unbounded body must not be
|
||||||
|
# read into memory before parsing; overflow degrades to an empty feed.
|
||||||
|
big = _resp(lambda: b"x" * 100)
|
||||||
|
with patch.object(reddit, "_MAX_FEED_BYTES", 10), \
|
||||||
|
patch.object(reddit, "urlopen", return_value=big):
|
||||||
|
assert reddit._fetch_subreddit_rss("NVDA", "stocks", 5, 5.0) == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestFormatterHandlesRssPosts:
|
class TestFormatterHandlesRssPosts:
|
||||||
|
|||||||
@@ -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: `Rating:Overweight` (fullwidth colon) used to defeat the regex
|
||||||
|
# and silently become Hold; NFKC normalization now parses it.
|
||||||
|
sp = SignalProcessor()
|
||||||
|
assert sp.process_signal("Rating:Overweight\n理由はこちら。") == "Overweight"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestExtractRating:
|
||||||
|
def test_returns_none_when_absent(self):
|
||||||
|
assert extract_rating("No directional call here.") is None
|
||||||
|
assert extract_rating("") is None
|
||||||
|
|
||||||
|
def test_whole_word_only(self):
|
||||||
|
# substrings inside larger words must not match
|
||||||
|
assert extract_rating("The buyer was holding shares.") is None
|
||||||
|
|
||||||
|
def test_parse_rating_keeps_silent_default_for_compat(self):
|
||||||
|
# parse_rating (used by the memory log) intentionally keeps Hold default.
|
||||||
|
assert parse_rating("No rating here.") == "Hold"
|
||||||
|
assert parse_rating("No rating here.", default="Underweight") == "Underweight"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestGraphSignalContract:
|
||||||
|
"""The graph-facing signal (TradingAgentsGraph.process_signal) honors the
|
||||||
|
documented "5-tier or REVIEW" contract, not just the parser in isolation."""
|
||||||
|
|
||||||
|
def _bare_graph(self):
|
||||||
|
from tradingagents.graph.trading_graph import TradingAgentsGraph
|
||||||
|
g = object.__new__(TradingAgentsGraph)
|
||||||
|
g.signal_processor = SignalProcessor()
|
||||||
|
return g
|
||||||
|
|
||||||
|
def test_graph_surfaces_review(self):
|
||||||
|
assert self._bare_graph().process_signal("no rating in here") == RATING_REVIEW
|
||||||
|
|
||||||
|
def test_graph_returns_rating(self):
|
||||||
|
assert self._bare_graph().process_signal("**Rating**: Sell") == "Sell"
|
||||||
|
|||||||
121
tests/test_social_lookahead.py
Normal file
121
tests/test_social_lookahead.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Historical social sentiment must not leak current data into a backtest (#1220).
|
||||||
|
|
||||||
|
StockTwits and Reddit fetchers pull only recent items, so for a historical run
|
||||||
|
they must be trimmed to the analysis window (and yield a clear placeholder when
|
||||||
|
nothing qualifies) rather than showing today's chatter as if it were from the
|
||||||
|
as-of date. All three sources share dataflows.date_window.in_window.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tradingagents.dataflows import reddit, stocktwits
|
||||||
|
from tradingagents.dataflows.date_window import in_window
|
||||||
|
|
||||||
|
|
||||||
|
class _JsonResp:
|
||||||
|
"""Minimal urlopen() context-manager stub returning a JSON body."""
|
||||||
|
|
||||||
|
def __init__(self, payload):
|
||||||
|
self._body = json.dumps(payload).encode()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
# --- shared window helper ---------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_in_window_bounds_and_exclusive_upper():
|
||||||
|
start = datetime(2026, 5, 1)
|
||||||
|
end = datetime(2026, 5, 9)
|
||||||
|
assert in_window(datetime(2026, 5, 5, tzinfo=timezone.utc), start, end) is True
|
||||||
|
assert in_window(datetime(2026, 5, 9, 23, 59, tzinfo=timezone.utc), start, end) is True
|
||||||
|
# exactly midnight after end -> excluded (no leak)
|
||||||
|
assert in_window(datetime(2026, 5, 10, 0, 0, tzinfo=timezone.utc), start, end) is False
|
||||||
|
# offset-aware converted, not truncated: 05-10T01:00+05:00 == 05-09T20:00Z
|
||||||
|
assert in_window(datetime.fromisoformat("2026-05-10T01:00:00+05:00"), start, end) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_in_window_undated_excluded_in_backtest_kept_live():
|
||||||
|
old = datetime(2026, 5, 9)
|
||||||
|
assert in_window(None, datetime(2026, 5, 1), old) is False # historical
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
assert in_window(None, now, now) is True # live
|
||||||
|
|
||||||
|
|
||||||
|
# --- StockTwits -------------------------------------------------------------
|
||||||
|
|
||||||
|
def _msg(created_iso, sentiment=None):
|
||||||
|
return {
|
||||||
|
"created_at": created_iso,
|
||||||
|
"user": {"username": "u"},
|
||||||
|
"entities": {"sentiment": {"basic": sentiment}},
|
||||||
|
"body": "text",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_stocktwits_historical_window_excludes_recent(monkeypatch):
|
||||||
|
# All messages are "today"; a run as-of a past week must show none of them.
|
||||||
|
recent = [_msg("2026-08-30T12:00:00Z", "Bullish"), _msg("2026-08-29T09:00:00Z")]
|
||||||
|
monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": recent}))
|
||||||
|
out = stocktwits.fetch_stocktwits_messages("AAPL", start_date="2026-05-01", end_date="2026-05-08")
|
||||||
|
assert "no StockTwits messages" in out
|
||||||
|
assert "2026-05-01..2026-05-08" in out
|
||||||
|
assert "Bullish: 1" not in out # the recent bullish message did not leak
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_stocktwits_live_window_keeps_in_range(monkeypatch):
|
||||||
|
msgs = [_msg("2026-05-05T12:00:00Z", "Bullish"), _msg("2026-05-07T09:00:00Z", "Bearish")]
|
||||||
|
monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": msgs}))
|
||||||
|
out = stocktwits.fetch_stocktwits_messages("AAPL", start_date="2026-05-01", end_date="2026-05-08")
|
||||||
|
assert "Total: 2" in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_stocktwits_no_window_is_unfiltered(monkeypatch):
|
||||||
|
msgs = [_msg("2026-08-30T12:00:00Z", "Bullish")]
|
||||||
|
monkeypatch.setattr(stocktwits, "urlopen", lambda *a, **k: _JsonResp({"messages": msgs}))
|
||||||
|
out = stocktwits.fetch_stocktwits_messages("AAPL") # live caller, no dates
|
||||||
|
assert "Total: 1" in out
|
||||||
|
|
||||||
|
|
||||||
|
# --- Reddit -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def _epoch(date_str):
|
||||||
|
return int(datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_reddit_historical_window_excludes_recent(monkeypatch):
|
||||||
|
posts = [{"title": "NOW", "created_utc": _epoch("2026-08-30"), "source": "rss"}]
|
||||||
|
monkeypatch.setattr(reddit, "_fetch_subreddit", lambda *a, **k: posts)
|
||||||
|
out = reddit.fetch_reddit_posts(
|
||||||
|
"AAPL", subreddits=("stocks",), inter_request_delay=0,
|
||||||
|
start_date="2026-05-01", end_date="2026-05-08",
|
||||||
|
)
|
||||||
|
assert "NOW" not in out
|
||||||
|
assert "no posts" in out.lower() or "no reddit posts" in out.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_reddit_live_window_keeps_in_range(monkeypatch):
|
||||||
|
posts = [{"title": "INRANGE", "created_utc": _epoch("2026-05-05"), "source": "rss"}]
|
||||||
|
monkeypatch.setattr(reddit, "_fetch_subreddit", lambda *a, **k: posts)
|
||||||
|
out = reddit.fetch_reddit_posts(
|
||||||
|
"AAPL", subreddits=("stocks",), inter_request_delay=0,
|
||||||
|
start_date="2026-05-01", end_date="2026-05-08",
|
||||||
|
)
|
||||||
|
assert "INRANGE" in out
|
||||||
148
tests/test_structured_agent_prompts.py
Normal file
148
tests/test_structured_agent_prompts.py
Normal 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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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()}"""
|
Ground every conclusion in specific evidence from the analysts. Commit to a directional call only when the evidence clearly supports one; choose Hold when the case is balanced, materially conflicting, ambiguous, or insufficient to justify changing exposure, rather than forcing a direction to appear decisive. Weigh the analysts on their merits, independent of speaking order.
|
||||||
|
|
||||||
|
{NO_EXTERNAL_TOOLS}{get_language_instruction()}"""
|
||||||
|
|
||||||
final_trade_decision = invoke_structured_or_freetext(
|
final_trade_decision = invoke_structured_or_freetext(
|
||||||
structured_llm,
|
structured_llm,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -35,12 +36,14 @@ def create_research_manager(llm):
|
|||||||
- **Underweight**: Cautious view; recommend trimming exposure
|
- **Underweight**: Cautious view; recommend trimming exposure
|
||||||
- **Sell**: Strong conviction in the bear thesis; recommend exiting or avoiding the position
|
- **Sell**: Strong conviction in the bear thesis; recommend exiting or avoiding the position
|
||||||
|
|
||||||
Commit to a clear stance whenever the debate's strongest arguments warrant one; reserve Hold for situations where the evidence on both sides is genuinely balanced.
|
Commit to a directional stance only when the debate's strongest arguments clearly warrant one. Choose Hold when the evidence is balanced, materially conflicting, ambiguous, or insufficient to justify changing exposure; do not manufacture a direction merely to appear decisive. Weigh the bull and bear cases on their merits, independent of which side spoke first or last.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**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,
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -82,9 +82,11 @@ class ResearchPlan(BaseModel):
|
|||||||
recommendation: PortfolioRating = Field(
|
recommendation: PortfolioRating = Field(
|
||||||
description=(
|
description=(
|
||||||
"The investment recommendation. Exactly one of Buy / Overweight / "
|
"The investment recommendation. Exactly one of Buy / Overweight / "
|
||||||
"Hold / Underweight / Sell. Reserve Hold for situations where the "
|
"Hold / Underweight / Sell. Choose Hold when the evidence is "
|
||||||
"evidence on both sides is genuinely balanced; otherwise commit to "
|
"balanced, materially conflicting, ambiguous, or insufficient to "
|
||||||
"the side with the stronger arguments."
|
"justify changing exposure; otherwise commit to the side with the "
|
||||||
|
"clearly stronger arguments. Do not pick a direction merely to be "
|
||||||
|
"decisive."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
rationale: str = Field(
|
rationale: str = Field(
|
||||||
@@ -197,7 +199,10 @@ class PortfolioDecision(BaseModel):
|
|||||||
rating: PortfolioRating = Field(
|
rating: PortfolioRating = Field(
|
||||||
description=(
|
description=(
|
||||||
"The final position rating. Exactly one of Buy / Overweight / Hold / "
|
"The final position rating. Exactly one of Buy / Overweight / Hold / "
|
||||||
"Underweight / Sell, picked based on the analysts' debate."
|
"Underweight / Sell, picked based on the analysts' debate. Choose "
|
||||||
|
"Hold when the case is balanced, materially conflicting, ambiguous, "
|
||||||
|
"or insufficient to justify changing exposure, rather than forcing a "
|
||||||
|
"direction to appear decisive."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
executive_summary: str = Field(
|
executive_summary: str = Field(
|
||||||
|
|||||||
@@ -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."
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
``Rating:Overweight`` is matched the same as ASCII):
|
||||||
|
1. An explicit "Rating: X" label (tolerant of markdown bold).
|
||||||
|
2. The first standalone 5-tier rating word found anywhere.
|
||||||
"""
|
"""
|
||||||
for line in text.splitlines():
|
if not text:
|
||||||
|
return None
|
||||||
|
norm = unicodedata.normalize("NFKC", text)
|
||||||
|
|
||||||
|
for line in norm.splitlines():
|
||||||
m = _RATING_LABEL_RE.search(line)
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
30
tradingagents/dataflows/date_window.py
Normal file
30
tradingagents/dataflows/date_window.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""Shared look-ahead-safe date-window filtering for dated content.
|
||||||
|
|
||||||
|
News, StockTwits, and Reddit all pull recent items that must be trimmed to the
|
||||||
|
analysis window so a historical/backtest run never sees content published after
|
||||||
|
its as-of date. Centralizing the rule keeps every source consistent (#1126,
|
||||||
|
#1220): every timestamp is normalized to UTC, the upper bound is exclusive at
|
||||||
|
midnight after ``end`` (so an item stamped exactly then can't leak), and an
|
||||||
|
undated item is kept only when the window reaches the present (a live run), since
|
||||||
|
in a backtest we can't prove it isn't future.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def to_utc(dt: datetime) -> datetime:
|
||||||
|
"""Normalize a datetime to UTC-aware; a naive value is assumed to be UTC."""
|
||||||
|
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def in_window(pub_dt: datetime | None, start_dt: datetime, end_dt: datetime) -> bool:
|
||||||
|
"""Whether an item belongs in the half-open window ``[start, end + 1 day)``.
|
||||||
|
|
||||||
|
``pub_dt`` None means undated: kept only when the window reaches the present.
|
||||||
|
"""
|
||||||
|
end = to_utc(end_dt)
|
||||||
|
if pub_dt is not None:
|
||||||
|
return to_utc(start_dt) <= to_utc(pub_dt) < end + timedelta(days=1)
|
||||||
|
return end >= datetime.now(timezone.utc) - timedelta(days=1)
|
||||||
@@ -12,6 +12,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from .errors import VendorNotConfiguredError
|
from .errors import VendorNotConfiguredError
|
||||||
@@ -20,6 +21,12 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
FRED_API_BASE = "https://api.stlouisfed.org/fred"
|
FRED_API_BASE = "https://api.stlouisfed.org/fred"
|
||||||
|
|
||||||
|
# FRED's realtime clock runs on US Central (St. Louis Fed). It rejects a
|
||||||
|
# realtime date in its own future with a 400, so the vintage pin is clamped to
|
||||||
|
# this rather than the caller's local date (#1275). pytz (already a dependency)
|
||||||
|
# bundles its own tz database, so this works where system tzdata is absent.
|
||||||
|
FRED_TZ = pytz.timezone("America/Chicago")
|
||||||
|
|
||||||
# Network timeout (seconds) so a stalled request can't hang the agents,
|
# Network timeout (seconds) so a stalled request can't hang the agents,
|
||||||
# mirroring the Alpha Vantage client.
|
# mirroring the Alpha Vantage client.
|
||||||
REQUEST_TIMEOUT = 30
|
REQUEST_TIMEOUT = 30
|
||||||
@@ -115,6 +122,16 @@ def _resolve_series_id(indicator: str) -> str:
|
|||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _fred_today() -> str:
|
||||||
|
"""FRED's current calendar date (US Central) as ``yyyy-mm-dd``.
|
||||||
|
|
||||||
|
The vintage pin is clamped to this: FRED rejects a ``realtime_start`` after
|
||||||
|
its own today with a 400, and ``curr_date`` on a live run comes from the
|
||||||
|
caller's local clock, which can already be tomorrow in Chicago.
|
||||||
|
"""
|
||||||
|
return datetime.now(FRED_TZ).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
def _request(path: str, params: dict) -> dict:
|
def _request(path: str, params: dict) -> dict:
|
||||||
"""GET a FRED endpoint, surfacing FRED's JSON error body on a bad request."""
|
"""GET a FRED endpoint, surfacing FRED's JSON error body on a bad request."""
|
||||||
api_params = {**params, "api_key": get_api_key(), "file_type": "json"}
|
api_params = {**params, "api_key": get_api_key(), "file_type": "json"}
|
||||||
@@ -143,8 +160,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 the realtime bounds
|
||||||
|
set to ``curr_date`` (clamped to FRED's own today) 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 +178,17 @@ 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. FRED defaults both realtime bounds to today, serving
|
||||||
|
# the LATEST revision of every observation; a single-day realtime interval
|
||||||
|
# asks for the values known as of the pin instead, on both the metadata and
|
||||||
|
# observations requests (#1275). Clamp to FRED's today: on a live run
|
||||||
|
# curr_date is the caller's local date, which can be a day ahead of Chicago,
|
||||||
|
# and a realtime date in FRED's future 400s -> the routing layer would then
|
||||||
|
# drop macro data silently. A past curr_date is unaffected, so historical
|
||||||
|
# point-in-time behaviour is preserved.
|
||||||
|
pit = min(curr_date, _fred_today())
|
||||||
|
realtime = {"realtime_start": pit, "realtime_end": pit}
|
||||||
|
|
||||||
# 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 +197,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 +216,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", [])
|
||||||
|
|
||||||
@@ -204,8 +237,10 @@ def get_macro_data(
|
|||||||
|
|
||||||
if not points:
|
if not points:
|
||||||
return header + (
|
return header + (
|
||||||
f"\nNo observations for {series_id} in this window. The series may "
|
f"\nNo observations for {series_id} in this window at the {pit} "
|
||||||
f"report less frequently than the window length; widen look_back_days."
|
f"vintage. The series may report less frequently than the window "
|
||||||
|
f"(try a longer look_back_days), or have no vintage published by "
|
||||||
|
f"then (unpublished as of {pit}, or before ALFRED coverage begins)."
|
||||||
)
|
)
|
||||||
|
|
||||||
first_date, first_val = points[0]
|
first_date, first_val = points[0]
|
||||||
|
|||||||
@@ -21,19 +21,40 @@ import html
|
|||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
import re
|
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
|
||||||
@@ -81,15 +102,47 @@ def _strip_html(content: str) -> str:
|
|||||||
return " ".join(html.unescape(text).split())
|
return " ".join(html.unescape(text).split())
|
||||||
|
|
||||||
|
|
||||||
|
# Headerless-429 backoff when Reddit gives no Retry-After. Jittered so several
|
||||||
|
# analyses sharing an IP don't retry in lockstep and re-collide on the limit.
|
||||||
|
_RETRY_FALLBACK_SECONDS = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def _jitter(seconds: float, frac: float = 0.2) -> float:
|
||||||
|
"""Return ``seconds`` with +/-``frac`` random jitter, to desynchronize
|
||||||
|
concurrent runs pacing against the same per-IP limit."""
|
||||||
|
return seconds * (1.0 + random.uniform(-frac, frac))
|
||||||
|
|
||||||
|
|
||||||
def _retry_after_seconds(exc: HTTPError) -> float | None:
|
def _retry_after_seconds(exc: HTTPError) -> float | None:
|
||||||
"""Seconds to wait from a 429's ``Retry-After`` header, capped at 30s."""
|
"""Seconds to wait from a 429's ``Retry-After`` header, capped at 30s.
|
||||||
|
|
||||||
|
Returns ``None`` only when the header is absent or unparseable; a valid
|
||||||
|
``Retry-After: 0`` returns ``0.0`` (retry at once), not ``None``.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
val = exc.headers.get("Retry-After") if getattr(exc, "headers", None) else None
|
val = exc.headers.get("Retry-After") if getattr(exc, "headers", None) else None
|
||||||
return min(float(val), 30.0) if val else None
|
return min(float(val), 30.0) if val is not None else None
|
||||||
except (ValueError, TypeError, AttributeError):
|
except (ValueError, TypeError, AttributeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Reddit search feeds are small (a page of results); cap the read so a
|
||||||
|
# compromised or misbehaving endpoint can't stream an unbounded body into
|
||||||
|
# memory before we parse it. Overflow raises http.client.HTTPException, which
|
||||||
|
# both fetch paths already treat as a failed fetch (degrade to empty / RSS).
|
||||||
|
_MAX_FEED_BYTES = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _read_capped(resp) -> bytes:
|
||||||
|
"""Read a response body bounded to ``_MAX_FEED_BYTES``, raising on overflow."""
|
||||||
|
data = resp.read(_MAX_FEED_BYTES + 1)
|
||||||
|
if len(data) > _MAX_FEED_BYTES:
|
||||||
|
raise http.client.HTTPException(
|
||||||
|
f"Reddit feed exceeded {_MAX_FEED_BYTES} bytes; refusing to parse"
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def _fetch_subreddit_rss(
|
def _fetch_subreddit_rss(
|
||||||
ticker: str,
|
ticker: str,
|
||||||
sub: str,
|
sub: str,
|
||||||
@@ -108,10 +161,13 @@ def _fetch_subreddit_rss(
|
|||||||
req = Request(url, headers={"User-Agent": _UA})
|
req = Request(url, headers={"User-Agent": _UA})
|
||||||
try:
|
try:
|
||||||
with urlopen(req, timeout=timeout) as resp:
|
with urlopen(req, timeout=timeout) as resp:
|
||||||
root = ET.fromstring(resp.read())
|
root = ET.fromstring(_read_capped(resp))
|
||||||
except HTTPError as exc:
|
except HTTPError as exc:
|
||||||
if exc.code == 429 and _retry:
|
if exc.code == 429 and _retry:
|
||||||
wait = _retry_after_seconds(exc) or 5.0
|
# Honour a server-supplied Retry-After exactly (including 0); jitter
|
||||||
|
# only our own fallback so concurrent runs don't retry in lockstep.
|
||||||
|
retry_after = _retry_after_seconds(exc)
|
||||||
|
wait = retry_after if retry_after is not None else _jitter(_RETRY_FALLBACK_SECONDS)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Reddit RSS 429 for r/%s · %s — backing off %.1fs then retrying once",
|
"Reddit RSS 429 for r/%s · %s — backing off %.1fs then retrying once",
|
||||||
sub, ticker, wait,
|
sub, ticker, wait,
|
||||||
@@ -162,7 +218,7 @@ def _fetch_subreddit_json(
|
|||||||
req = Request(url, headers={"User-Agent": _UA, "Accept": "application/json"})
|
req = Request(url, headers={"User-Agent": _UA, "Accept": "application/json"})
|
||||||
try:
|
try:
|
||||||
with urlopen(req, timeout=timeout) as resp:
|
with urlopen(req, timeout=timeout) as resp:
|
||||||
payload = json.loads(resp.read())
|
payload = json.loads(_read_capped(resp))
|
||||||
children = (payload.get("data") or {}).get("children") or []
|
children = (payload.get("data") or {}).get("children") or []
|
||||||
return [c.get("data", {}) for c in children if isinstance(c, dict)]
|
return [c.get("data", {}) for c in children if isinstance(c, dict)]
|
||||||
except (OSError, http.client.HTTPException, json.JSONDecodeError) as exc:
|
except (OSError, http.client.HTTPException, json.JSONDecodeError) as exc:
|
||||||
@@ -194,6 +250,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 +259,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.
|
||||||
@@ -208,9 +270,10 @@ def fetch_reddit_posts(
|
|||||||
blocks = []
|
blocks = []
|
||||||
total_posts = 0
|
total_posts = 0
|
||||||
for i, sub in enumerate(subreddits):
|
for i, sub in enumerate(subreddits):
|
||||||
if i > 0:
|
if i > 0 and inter_request_delay:
|
||||||
time.sleep(inter_request_delay)
|
time.sleep(_jitter(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>")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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"]:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
saver = self._checkpointer_ctx.__enter__()
|
|
||||||
self.graph = self.workflow.compile(checkpointer=saver)
|
|
||||||
|
|
||||||
step = checkpoint_step(
|
def begin_checkpoint(self, company_name, trade_date, asset_type: str = "stock") -> str | None:
|
||||||
|
"""Recompile the graph with a per-ticker checkpointer and return the
|
||||||
|
``thread_id`` to inject into the stream/invoke ``config`` (or ``None``
|
||||||
|
when checkpointing is disabled).
|
||||||
|
|
||||||
|
Pair every call with :meth:`end_checkpoint` in a ``finally``. Both
|
||||||
|
``propagate`` (via :meth:`checkpoint_scope`) and the CLI stream path use
|
||||||
|
this so ``--checkpoint`` actually resumes (#1249); previously the setup
|
||||||
|
lived only inside ``propagate`` and the CLI streamed the checkpointer-less
|
||||||
|
graph, making the flag a no-op.
|
||||||
|
"""
|
||||||
|
self._resuming = False
|
||||||
|
if not self.config.get("checkpoint_enabled"):
|
||||||
|
return None
|
||||||
|
signature = self._run_signature(asset_type)
|
||||||
|
self._checkpointer_ctx = get_checkpointer(self.config["data_cache_dir"], company_name)
|
||||||
|
saver = self._checkpointer_ctx.__enter__()
|
||||||
|
self.graph = self.workflow.compile(checkpointer=saver)
|
||||||
|
|
||||||
|
step = checkpoint_step(
|
||||||
|
self.config["data_cache_dir"], company_name, str(trade_date), signature
|
||||||
|
)
|
||||||
|
self._resuming = step is not None
|
||||||
|
if step is not None:
|
||||||
|
logger.info("Resuming from step %d for %s on %s", step, company_name, trade_date)
|
||||||
|
else:
|
||||||
|
logger.info("Starting fresh for %s on %s", company_name, trade_date)
|
||||||
|
return thread_id(company_name, str(trade_date), signature)
|
||||||
|
|
||||||
|
def checkpoint_input(self, init_state):
|
||||||
|
"""The value to stream/invoke: ``None`` to resume an existing checkpoint,
|
||||||
|
else the initial state for a fresh run.
|
||||||
|
|
||||||
|
LangGraph resumes an interrupted thread when invoked with ``None``;
|
||||||
|
re-passing the initial state instead appends it through the message
|
||||||
|
reducer, duplicating messages in the resumed state (#1249).
|
||||||
|
"""
|
||||||
|
return None if self._resuming else init_state
|
||||||
|
|
||||||
|
def end_checkpoint(self):
|
||||||
|
"""Restore the plain uncheckpointed graph after a checkpointed run."""
|
||||||
|
if self._checkpointer_ctx is not None:
|
||||||
|
self._checkpointer_ctx.__exit__(None, None, None)
|
||||||
|
self._checkpointer_ctx = None
|
||||||
|
self.graph = self.workflow.compile()
|
||||||
|
self._resuming = False
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def checkpoint_scope(self, company_name, trade_date, asset_type: str = "stock"):
|
||||||
|
"""Context-manager form of begin/end_checkpoint for the propagate path."""
|
||||||
|
try:
|
||||||
|
yield self.begin_checkpoint(company_name, trade_date, asset_type)
|
||||||
|
finally:
|
||||||
|
self.end_checkpoint()
|
||||||
|
|
||||||
|
def clear_checkpoint_on_success(self, company_name, trade_date, asset_type: str = "stock"):
|
||||||
|
"""Drop a completed run's checkpoint so a later run starts fresh (#1249)."""
|
||||||
|
if self.config.get("checkpoint_enabled"):
|
||||||
|
clear_checkpoint(
|
||||||
self.config["data_cache_dir"], company_name, str(trade_date),
|
self.config["data_cache_dir"], company_name, str(trade_date),
|
||||||
self._run_signature(asset_type),
|
self._run_signature(asset_type),
|
||||||
)
|
)
|
||||||
if step is not None:
|
|
||||||
logger.info(
|
|
||||||
"Resuming from step %d for %s on %s", step, company_name, trade_date
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info("Starting fresh for %s on %s", company_name, trade_date)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return self._run_graph(company_name, trade_date, asset_type=asset_type)
|
|
||||||
finally:
|
|
||||||
if self._checkpointer_ctx is not None:
|
|
||||||
self._checkpointer_ctx.__exit__(None, None, None)
|
|
||||||
self._checkpointer_ctx = None
|
|
||||||
self.graph = self.workflow.compile()
|
|
||||||
|
|
||||||
def save_reports(self, final_state, ticker, save_path=None) -> Path:
|
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"])
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user