Compare commits

..
23 Commits
Author SHA1 Message Date
Yijia-Xiao 9968bd8dd1 feat(graph): run the analysts at the same time (#1255)
- each analyst is a graph of its own (model and tools on a private message history) that returns only its report; all start together and the research debate waits for every report
- the message-clearing nodes are gone; a checkpoint saved by the sequential layout starts fresh
- TradingAgentsGraph.stream_run streams the analysts' messages for debug mode and the CLI, whose status and timing now track the analysts side by side
2026-09-25 06:35:54 +00:00
Yijia-Xiao fc1ab1db07 fix(dataflows): write cache files whole, through a temp file of their own
- the OHLCV cache and the SEC EDGAR cache go through dataflows.files.replace_file: a reader sees the old file or the new one, and concurrent writers never share a temp file
2026-09-25 06:35:54 +00:00
Yijia-Xiao 0c602846ba feat(cli): run an analysis without questions from flags (#1127, #1133)
- --ticker, --date and --analysts answer their steps, checked as the prompts check them; --save/--no-save and --show/--no-show answer the questions after the run
- with no terminal, the run stops before it starts and names every flag or TRADINGAGENTS_* variable still needed; a missing API key and an announcement no longer wait for input
- README shows an unattended run
2026-09-24 20:42:51 +00:00
Yijia-Xiao a1b3b5bab8 feat(reports): record the analysis date and what produced a run (#752)
- TradingAgentsGraph.run_settings(): version, provider, models, analysts, debate rounds, language and vendors; no endpoints, keys or paths
- complete_report.md opens with the analysis date and those settings; the saved state log carries them as run_settings
2026-09-24 19:38:41 +00:00
Yijia-Xiao d91d1f4cb4 build: keep the package version in tradingagents.__version__
- pyproject reads the version from tradingagents/__init__.py, so a source checkout, an editable install and a wheel report the same one
- the SEC EDGAR User-Agent names tradingagents.__version__
2026-09-24 19:38:41 +00:00
Yijia-Xiao 45391d0962 feat(docker): keep results in a host folder when TRADINGAGENTS_DATA_DIR is set (#865)
- both compose services mount ${TRADINGAGENTS_DATA_DIR:-tradingagents_data}; unset keeps the named volume
- README and .env.example show the setting
2026-09-24 19:38:41 +00:00
Yijia-Xiao cf960d6382 refactor: one name per decision in the state and its log
- the Research Manager's plan and the Portfolio Manager's decision live only in investment_plan and final_trade_decision; the debate states no longer copy them as judge_decision
- the saved state log writes the Trader's plan as trader_investment_plan, its state key
- the report tree, the final display and the live CLI read the decisions from those fields
2026-09-24 19:38:41 +00:00
Yijia-Xiao 1d20780095 chore: remove code nothing reads
- router.VENDOR_LIST, and the state's sender field with the Trader's partial that only set it
2026-09-24 19:38:41 +00:00
Yijia-Xiao 3f6ad5dc12 docs: say which agent writes each state field, and what the debate limits count
- state field descriptions name the Research Manager, the News and Fundamentals Analysts and the Portfolio Manager; comments that only repeat them are gone
- the debate-limit comments count turns per max_debate_rounds and max_risk_discuss_rounds
- date_window's docstring covers all the point-in-time rules it holds
2026-09-24 19:38:41 +00:00
Yijia-Xiao 674f1087d1 refactor: name the data layer's date argument as_of_date
- vendor functions and date_window take as_of_date, the date data is served as of; the model-facing tool arguments are unchanged
- build_instrument_context and resolve_instrument_context take trade_date, which is what they receive
2026-09-24 19:38:41 +00:00
Yijia-Xiao 5ac5786d0d docs: call it the memory log throughout
- docstrings, comments, messages and the README section use "memory log", matching TradingMemoryLog and memory_log_path
- the write, read and settle paths are named for what they do, not by design phase
2026-09-24 19:38:41 +00:00
Yijia-Xiao c50420fc5c refactor: gather the memory log, settlement and reflection in tradingagents/memory
- memory/log.py holds TradingMemoryLog, still imported from tradingagents.memory
- graph/settlement.py and graph/reflection.py move to memory/; Reflector leaves tradingagents.graph's exports
2026-09-24 19:38:41 +00:00
Yijia-Xiao b690dc7988 fix(rating): read a free-text rating from the decision's own rating line (#1383)
- the first line opening with a rating label is the call, not a rating the text quotes later ("Consensus rating: Buy")
- "rating" must start a word, so "Operating margin: Sell-side" is not a label
2026-09-24 18:45:44 +00:00
Yijia-Xiao a94a411b0e fix(agents): carry the Portfolio Manager's rating through the run (#1383)
- the typed rating is the state's final_rating; propagate returns it, and the memory log tag, the state log and the CLI review check read it
- the decision text is parsed only when the Portfolio Manager answered in free text
- TradingAgentsGraph.process_signal is removed
2026-09-24 18:45:44 +00:00
Yijia-Xiao 05878c96a8 test(net): pin that get_scrubbed keeps the vendor key out of errors (#1398)
- the message and args are masked; no request, response or chained original travels with the error; a passthrough status is returned
2026-09-24 18:45:44 +00:00
Yijia-Xiao f9d6495dca fix(fred): state the dates a macro change spans (#1397)
- the change line reads "Change from <first> to <last>" instead of "Change over window", so a partial-year move no longer reads as year on year
2026-09-24 18:45:44 +00:00
Yijia-Xiao f4269bf193 test: keep the CLI's saved selections out of the user's home (#1395)
- every test saves and reads CLI selections under its own tmp_path
2026-09-24 18:45:44 +00:00
Yijia-Xiao 47ac1f2d1e feat(config): benchmark Taiwan, Korea, Singapore and the main European exchanges (#1392)
- .TW, .TWO, .KS, .KQ, .SI, .DE, .PA, .AS, .SW and .MI tickers are measured against their regional index instead of SPY
2026-09-24 18:45:44 +00:00
Yijia-Xiao 51066935b8 refactor: name the decision log module tradingagents/memory.py
- TradingMemoryLog imports from tradingagents.memory, matching the class, the memory_log_path key and graph.memory_log
2026-09-24 18:45:44 +00:00
Yijia-Xiao 73e0aa0fa6 chore: start 0.5.2 development (0.5.2.dev0) 2026-09-24 18:45:44 +00:00
Chaoqi 6ac7c6f017 fix(dataflows): report a Yahoo rate limit as a rate limit (#1387) 2026-09-24 11:45:39 -07:00
olivergpt ecd3404213 fix(llm): treat deepseek-flash as a thinking model (#1389) 2026-09-24 11:45:29 -07:00
Yijia Xiao 35543d0248 TradingAgents v0.5.1 release 2026-09-24 00:49:14 -07:00
85 changed files with 1560 additions and 740 deletions
+3
View File
@@ -47,6 +47,9 @@ NVIDIA_API_KEY=
#TRADINGAGENTS_MAX_RISK_ROUNDS=1 #TRADINGAGENTS_MAX_RISK_ROUNDS=1
#TRADINGAGENTS_CHECKPOINT_ENABLED=false #TRADINGAGENTS_CHECKPOINT_ENABLED=false
# Docker: a host folder for results, reports and the memory log (unset: the tradingagents_data volume).
#TRADINGAGENTS_DATA_DIR=./data
# Paths and alpha benchmark. Unset uses ~/.tradingagents and the regional index. # Paths and alpha benchmark. Unset uses ~/.tradingagents and the regional index.
#TRADINGAGENTS_RESULTS_DIR= #TRADINGAGENTS_RESULTS_DIR=
#TRADINGAGENTS_CACHE_DIR= #TRADINGAGENTS_CACHE_DIR=
+16 -4
View File
@@ -81,6 +81,8 @@ Our framework decomposes complex trading tasks into specialized roles.
- News Analyst: Monitors global news and macroeconomic indicators, interpreting the impact of events on market conditions. - News Analyst: Monitors global news and macroeconomic indicators, interpreting the impact of events on market conditions.
- Technical Analyst: Utilizes technical indicators (like MACD and RSI) to detect trading patterns and forecast price movements. - Technical Analyst: Utilizes technical indicators (like MACD and RSI) to detect trading patterns and forecast price movements.
The selected analysts work at the same time, each on its own tools, and the research debate starts once all of their reports are in.
<p align="center"> <p align="center">
<img src="assets/analyst.png" width="100%" style="display: inline-block; margin: 0 2%;"> <img src="assets/analyst.png" width="100%" style="display: inline-block; margin: 0 2%;">
</p> </p>
@@ -144,6 +146,8 @@ docker compose run --rm tradingagents
After updating the repository, rebuild the image with `docker compose build`. After updating the repository, rebuild the image with `docker compose build`.
Results, reports, the memory log and the cache live in the `tradingagents_data` volume. To keep them in a folder on the host instead, create the folder and point `TRADINGAGENTS_DATA_DIR` at it, in `.env` or the shell: `mkdir -p data && TRADINGAGENTS_DATA_DIR=./data docker compose run --rm tradingagents`.
For local models with Ollama: For local models with Ollama:
```bash ```bash
docker compose --profile ollama run --rm tradingagents-ollama docker compose --profile ollama run --rm tradingagents-ollama
@@ -199,6 +203,14 @@ python -m cli.main # alternative: run directly from source
``` ```
You will see a screen where you can select your desired tickers, analysis date, LLM provider, research depth, and more. Your previous run's answers come back as the defaults, so pressing Enter accepts them. The `TRADINGAGENTS_*` variables in `.env` still skip their step entirely. You will see a screen where you can select your desired tickers, analysis date, LLM provider, research depth, and more. Your previous run's answers come back as the defaults, so pressing Enter accepts them. The `TRADINGAGENTS_*` variables in `.env` still skip their step entirely.
To run without questions, for a scheduled job or a script, answer the per-run steps with flags and the rest with `TRADINGAGENTS_*` variables:
```bash
export TRADINGAGENTS_LLM_PROVIDER=openai TRADINGAGENTS_QUICK_THINK_LLM=gpt-6-luna TRADINGAGENTS_DEEP_THINK_LLM=gpt-6-sol
export TRADINGAGENTS_OUTPUT_LANGUAGE=English TRADINGAGENTS_MAX_DEBATE_ROUNDS=1 TRADINGAGENTS_MAX_RISK_ROUNDS=1
tradingagents --ticker NVDA --date 2026-09-23 --analysts market,news,fundamentals --save --no-show
```
Each flag skips only its own question. Run without a terminal, a missing answer stops the run before it starts and names the flag or variable to set.
### Markets and tickers ### Markets and tickers
TradingAgents works with any market Yahoo Finance covers, using the exchange-suffixed ticker. Company identity and the alpha benchmark resolve automatically per market. TradingAgents works with any market Yahoo Finance covers, using the exchange-suffixed ticker. Company identity and the alpha benchmark resolve automatically per market.
@@ -304,9 +316,9 @@ An empty `positions` list means a flat book, which is different from passing not
TradingAgents persists two kinds of state across runs. TradingAgents persists two kinds of state across runs.
### Decision log ### Memory log
The decision log is always on. Each completed run appends its decision to `~/.tradingagents/memory/trading_memory.md`. On the next run for the same ticker, TradingAgents fetches the realised return (raw, and alpha against the instrument's regional benchmark), generates a one-paragraph reflection, and injects the most recent same-ticker decisions plus recent cross-ticker lessons into the Portfolio Manager prompt, so each analysis carries forward what worked and what didn't. The memory log is always on. Each completed run appends its decision to `~/.tradingagents/memory/trading_memory.md`. On the next run for the same ticker, TradingAgents fetches the realised return (raw, and alpha against the instrument's regional benchmark), generates a one-paragraph reflection, and injects the most recent same-ticker decisions plus recent cross-ticker lessons into the Portfolio Manager prompt, so each analysis carries forward what worked and what didn't.
Override the path with `TRADINGAGENTS_MEMORY_LOG_PATH`. Override the path with `TRADINGAGENTS_MEMORY_LOG_PATH`.
@@ -330,7 +342,7 @@ _, decision = ta.propagate("NVDA", "2026-09-01")
## Evaluating decisions over time ## Evaluating decisions over time
One run gives one decision, which cannot tell you whether the system decides well. `run_backtest` runs the same pipeline over a grid of tickers and dates, writes to a decision log of its own, and scores the decisions whose holding window has since traded. One run gives one decision, which cannot tell you whether the system decides well. `run_backtest` runs the same pipeline over a grid of tickers and dates, writes to a memory log of its own, and scores the decisions whose holding window has since traded.
```python ```python
from tradingagents.backtest import iter_grid, run_backtest, summarize from tradingagents.backtest import iter_grid, run_backtest, summarize
@@ -346,7 +358,7 @@ From the CLI:
tradingagents backtest NVDA,AAPL --start 2026-06-01 --end 2026-08-01 --every 7 tradingagents backtest NVDA,AAPL --start 2026-06-01 --end 2026-08-01 --every 7
``` ```
Each cell is scored on realized alpha against the instrument's regional benchmark, grouped by rating. Your own decision log is never written to, and re-running the same grid with `run_id=result.run_id` skips the cells that already ran, so an interrupted sweep continues where it stopped. Each cell is scored on realized alpha against the instrument's regional benchmark, grouped by rating. Your own memory log is never written to, and re-running the same grid with `run_id=result.run_id` skips the cells that already ran, so an interrupted sweep continues where it stopped.
## Reproducibility ## Reproducibility
+2 -1
View File
@@ -1,4 +1,5 @@
import getpass import getpass
import sys
import requests import requests
from rich.console import Console from rich.console import Console
@@ -46,7 +47,7 @@ def display_announcements(console: Console, data: dict) -> None:
) )
console.print(panel) console.print(panel)
if require_attention: if require_attention and sys.stdin.isatty():
getpass.getpass("Press Enter to continue...") getpass.getpass("Press Enter to continue...")
else: else:
console.print() console.print()
+15 -33
View File
@@ -404,8 +404,8 @@ def display_complete_report(final_state):
research.append(("Bull Researcher", debate["bull_history"])) research.append(("Bull Researcher", debate["bull_history"]))
if debate.get("bear_history"): if debate.get("bear_history"):
research.append(("Bear Researcher", debate["bear_history"])) research.append(("Bear Researcher", debate["bear_history"]))
if debate.get("judge_decision"): if final_state.get("investment_plan"):
research.append(("Research Manager", debate["judge_decision"])) research.append(("Research Manager", final_state["investment_plan"]))
if research: if research:
console.print(Panel("[bold]II. Research Team Decision[/bold]", border_style="magenta")) console.print(Panel("[bold]II. Research Team Decision[/bold]", border_style="magenta"))
for title, content in research: for title, content in research:
@@ -432,9 +432,9 @@ def display_complete_report(final_state):
console.print(Panel(Markdown(content), title=title, border_style="blue", padding=(1, 2))) console.print(Panel(Markdown(content), title=title, border_style="blue", padding=(1, 2)))
# V. Portfolio Manager Decision # V. Portfolio Manager Decision
if risk.get("judge_decision"): if final_state.get("final_trade_decision"):
console.print(Panel("[bold]V. Portfolio Manager Decision[/bold]", border_style="green")) console.print(Panel("[bold]V. Portfolio Manager Decision[/bold]", border_style="green"))
console.print(Panel(Markdown(risk["judge_decision"]), title="Portfolio Manager", border_style="blue", padding=(1, 2))) console.print(Panel(Markdown(final_state["final_trade_decision"]), title="Portfolio Manager", border_style="blue", padding=(1, 2)))
def update_research_team_status(status): def update_research_team_status(status):
@@ -463,22 +463,17 @@ ANALYST_REPORT_MAP = {
def update_analyst_statuses(message_buffer, chunk, wall_time_tracker=None): def update_analyst_statuses(message_buffer, chunk, wall_time_tracker=None):
"""Update analyst statuses based on accumulated report state. """Update analyst statuses from the reports filed so far.
Logic: The analysts run together: each is in progress until its own report lands.
- Store new report content from the current chunk if present When every selected analyst has filed, the research debate is in progress.
- Check accumulated report_sections (not just current chunk) for status
- Analysts with reports = completed
- First analyst without report = in_progress
- Remaining analysts without reports = pending
- When all analysts done, set Bull Researcher to in_progress
""" """
selected = message_buffer.selected_analysts selected = message_buffer.selected_analysts
found_active = False
if wall_time_tracker is not None: if wall_time_tracker is not None:
sync_analyst_tracker_from_chunk(wall_time_tracker, chunk) sync_analyst_tracker_from_chunk(wall_time_tracker, chunk)
all_filed = True
for analyst_key in ANALYST_ORDER: for analyst_key in ANALYST_ORDER:
if analyst_key not in selected: if analyst_key not in selected:
continue continue
@@ -490,20 +485,15 @@ def update_analyst_statuses(message_buffer, chunk, wall_time_tracker=None):
if chunk.get(report_key): if chunk.get(report_key):
message_buffer.update_report_section(report_key, chunk[report_key]) message_buffer.update_report_section(report_key, chunk[report_key])
# Determine status from accumulated sections, not just current chunk # Status comes from accumulated sections, not just the current chunk.
has_report = bool(message_buffer.report_sections.get(report_key)) if message_buffer.report_sections.get(report_key):
if has_report:
message_buffer.update_agent_status(agent_name, "completed") message_buffer.update_agent_status(agent_name, "completed")
elif not found_active:
message_buffer.update_agent_status(agent_name, "in_progress")
found_active = True
else: else:
message_buffer.update_agent_status(agent_name, "pending") message_buffer.update_agent_status(agent_name, "in_progress")
all_filed = False
# When all analysts complete, transition research team to in_progress
if ( if (
not found_active all_filed
and selected and selected
and message_buffer.agent_status.get("Bull Researcher") == "pending" and message_buffer.agent_status.get("Bull Researcher") == "pending"
): ):
@@ -624,17 +614,9 @@ def sync_analyst_tracker_from_chunk(
chunk: dict[str, str], chunk: dict[str, str],
now: float | None = None, now: float | None = None,
) -> None: ) -> None:
"""The analysts start together; each stops its clock when its report lands."""
current_time = monotonic() if now is None else now current_time = monotonic() if now is None else now
active_found = False
for spec in tracker.plan.specs: for spec in tracker.plan.specs:
has_report = bool(chunk.get(spec.report_key))
if has_report:
tracker.mark_started(spec.key, started_at=current_time) tracker.mark_started(spec.key, started_at=current_time)
if chunk.get(spec.report_key):
tracker.mark_completed(spec.key, completed_at=current_time) tracker.mark_completed(spec.key, completed_at=current_time)
continue
if not active_found:
tracker.mark_started(spec.key, started_at=current_time)
active_found = True
+18 -2
View File
@@ -47,8 +47,23 @@ def analyze(
help="JSON file with current holdings and cash, so the trader, risk and " help="JSON file with current holdings and cash, so the trader, risk and "
"portfolio agents size against your actual position.", "portfolio agents size against your actual position.",
), ),
ticker: str = typer.Option(None, "--ticker", help="Ticker to analyze, e.g. NVDA or 0700.HK; skips the prompt"),
date: str = typer.Option(None, "--date", help="Analysis date, YYYY-MM-DD; skips the prompt"),
analysts: str = typer.Option(
None, "--analysts", help="Comma-separated analysts, e.g. market,news; skips the prompt"
),
save: bool | None = typer.Option(
None, "--save/--no-save", help="Save the report under results_dir without asking"
),
show: bool | None = typer.Option(
None, "--show/--no-show", help="Show the full report at the end without asking"
),
): ):
"""Run an analysis. This is what a bare `tradingagents` does.""" """Run an analysis. This is what a bare `tradingagents` does.
Flags answer their questions; with provider, models, depth and language also
set through TRADINGAGENTS_* variables, the run asks nothing.
"""
if ctx.invoked_subcommand is not None: if ctx.invoked_subcommand is not None:
return return
if clear_checkpoints: if clear_checkpoints:
@@ -64,7 +79,8 @@ def analyze(
raise typer.Exit(code=1) from None raise typer.Exit(code=1) from None
try: try:
run_analysis(checkpoint=checkpoint, portfolio=portfolio_context) flags = {"ticker": ticker, "date": date, "analysts": analysts, "save": save, "show": show}
run_analysis(checkpoint=checkpoint, portfolio=portfolio_context, flags=flags)
except _NO_CONSOLE_ERRORS: except _NO_CONSOLE_ERRORS:
# A terminal with no console buffer cannot host the interactive prompts. # A terminal with no console buffer cannot host the interactive prompts.
# Emit one actionable line on stderr instead of a prompt_toolkit # Emit one actionable line on stderr instead of a prompt_toolkit
+42
View File
@@ -1,7 +1,10 @@
import datetime
import os import os
import sys
from pathlib import Path from pathlib import Path
import questionary import questionary
import typer
from dotenv import find_dotenv, set_key from dotenv import find_dotenv, set_key
from cli.display import console from cli.display import console
@@ -60,6 +63,41 @@ def get_ticker() -> str:
return normalize_ticker_symbol(ticker) if ticker.strip() else "SPY" return normalize_ticker_symbol(ticker) if ticker.strip() else "SPY"
def parse_ticker(value: str) -> str:
"""A ticker given on the command line, canonical; empty or malformed is refused."""
if not value.strip() or not is_valid_ticker_input(value):
raise ValueError(f"not a ticker symbol: {value!r} (e.g. {TICKER_INPUT_EXAMPLES})")
return normalize_ticker_symbol(value)
def parse_analysis_date(value: str) -> str:
"""An analysis date as YYYY-MM-DD, today or earlier."""
try:
day = datetime.datetime.strptime(value.strip(), "%Y-%m-%d").date()
except ValueError:
raise ValueError(f"not a date: {value!r}; use YYYY-MM-DD") from None
if day > datetime.date.today():
raise ValueError(f"{value} is in the future")
return day.isoformat()
def parse_analysts(value: str, asset_type: AssetType) -> list[AnalystType]:
"""Comma-separated analyst names, in the canonical order, checked against the asset."""
# "sentiment" is the name users see; the analyst's key is "social".
names = [{"sentiment": "social"}.get(n, n) for n in (n.strip().lower() for n in value.split(",")) if n]
if not names:
raise ValueError("name at least one analyst")
known = {a.value: a for a in AnalystType}
unknown = [n for n in names if n not in known]
if unknown:
raise ValueError(f"unknown analyst {', '.join(unknown)}; choose from {', '.join(known)}")
available = filter_analysts_for_asset_type(list(known.values()), asset_type)
unavailable = [n for n in names if known[n] not in available]
if unavailable:
raise ValueError(f"{', '.join(unavailable)} is not available for {asset_type.value}")
return [a for a in available if a.value in names]
def normalize_ticker_symbol(ticker: str) -> str: def normalize_ticker_symbol(ticker: str) -> str:
"""Resolve user input to its canonical Yahoo symbol (single source of truth). """Resolve user input to its canonical Yahoo symbol (single source of truth).
@@ -611,6 +649,10 @@ def ensure_api_key(provider: str) -> str | None:
if existing: if existing:
return existing return existing
if not sys.stdin.isatty():
console.print(f"[red]{env_var} is not set; there is no terminal to ask for it.[/red]")
raise typer.Exit(code=1)
console.print( console.print(
f"\n[yellow]{env_var} is not set in your environment.[/yellow]" f"\n[yellow]{env_var} is not set in your environment.[/yellow]"
) )
+51 -28
View File
@@ -2,6 +2,7 @@
import datetime import datetime
import os import os
import sys
import time import time
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
@@ -21,9 +22,9 @@ from cli.display import (
update_display, update_display,
update_research_team_status, update_research_team_status,
) )
from cli.selections import get_user_selections from cli.selections import get_user_selections, unattended_gaps
from cli.stats_handler import StatsCallbackHandler from cli.stats_handler import StatsCallbackHandler
from tradingagents.agents.rating import is_review from tradingagents.agents.rating import is_review, run_rating
from tradingagents.dataflows.symbols import safe_ticker_component from tradingagents.dataflows.symbols import safe_ticker_component
from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.default_config import DEFAULT_CONFIG
from tradingagents.graph.analyst_execution import ( from tradingagents.graph.analyst_execution import (
@@ -93,9 +94,19 @@ def _build_run_config(selections: dict, checkpoint: bool | None) -> dict:
return config return config
def run_analysis(checkpoint: bool | None = None, portfolio=None): def run_analysis(checkpoint: bool | None = None, portfolio=None, flags=None):
# First get all user selections flags = flags or {}
selections = get_user_selections() # With no terminal nothing can answer a prompt: name every question still
# open before any model is called, rather than stopping at the first one.
if not (sys.stdin and sys.stdin.isatty()):
gaps = unattended_gaps(flags)
if gaps:
console.print("[red]No terminal to answer the setup questions. Set:[/red]")
for gap in gaps:
console.print(f" {gap}")
raise typer.Exit(code=1)
selections = get_user_selections(flags)
config = _build_run_config(selections, checkpoint) config = _build_run_config(selections, checkpoint)
@@ -189,9 +200,10 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None):
) )
update_display(layout, stats_handler=stats_handler, start_time=start_time) update_display(layout, stats_handler=stats_handler, start_time=start_time)
first_analyst = analyst_execution_plan.specs[0].agent_node # The analysts start together.
message_buffer.update_agent_status(first_analyst, "in_progress") for spec in analyst_execution_plan.specs:
analyst_wall_time_tracker.mark_started(selected_analyst_keys[0]) message_buffer.update_agent_status(spec.agent_node, "in_progress")
analyst_wall_time_tracker.mark_started(spec.key)
update_display(layout, stats_handler=stats_handler, start_time=start_time) update_display(layout, stats_handler=stats_handler, start_time=start_time)
spinner_text = ( spinner_text = (
@@ -199,7 +211,7 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None):
) )
update_display(layout, spinner_text, stats_handler=stats_handler, start_time=start_time) update_display(layout, spinner_text, stats_handler=stats_handler, start_time=start_time)
# The same initial state propagate() builds: settled decision log, past # The same initial state propagate() builds: settled memory log, past
# context and resolved instrument identity. # context and resolved instrument identity.
init_agent_state = graph.create_run_state( init_agent_state = graph.create_run_state(
selections["ticker"], selections["analysis_date"], selections["asset_type"], portfolio selections["ticker"], selections["analysis_date"], selections["asset_type"], portfolio
@@ -223,8 +235,8 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None):
# try/finally tears the checkpointer down even if the stream raises. # try/finally tears the checkpointer down even if the stream raises.
trace = [] trace = []
try: try:
for chunk in graph.graph.stream(graph.checkpoint_input(init_agent_state), **args): for messages, chunk in graph.stream_run(graph.checkpoint_input(init_agent_state), **args):
for message in chunk.get("messages", []): for message in messages:
msg_id = getattr(message, "id", None) msg_id = getattr(message, "id", None)
if msg_id is not None: if msg_id is not None:
if msg_id in message_buffer._processed_message_ids: if msg_id in message_buffer._processed_message_ids:
@@ -242,6 +254,10 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None):
else: else:
message_buffer.add_tool_call(tool_call.name, tool_call.args) message_buffer.add_tool_call(tool_call.name, tool_call.args)
if chunk is None: # a step inside an analyst's graph: messages only
update_display(layout, stats_handler=stats_handler, start_time=start_time)
continue
update_analyst_statuses( update_analyst_statuses(
message_buffer, message_buffer,
chunk, chunk,
@@ -253,7 +269,7 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None):
debate_state = chunk["investment_debate_state"] debate_state = chunk["investment_debate_state"]
bull_hist = debate_state.get("bull_history", "").strip() bull_hist = debate_state.get("bull_history", "").strip()
bear_hist = debate_state.get("bear_history", "").strip() bear_hist = debate_state.get("bear_history", "").strip()
judge = debate_state.get("judge_decision", "").strip() judge = (chunk.get("investment_plan") or "").strip()
# Only update status when there's actual content # Only update status when there's actual content
if bull_hist or bear_hist: if bull_hist or bear_hist:
@@ -288,7 +304,7 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None):
agg_hist = risk_state.get("aggressive_history", "").strip() agg_hist = risk_state.get("aggressive_history", "").strip()
con_hist = risk_state.get("conservative_history", "").strip() con_hist = risk_state.get("conservative_history", "").strip()
neu_hist = risk_state.get("neutral_history", "").strip() neu_hist = risk_state.get("neutral_history", "").strip()
judge = risk_state.get("judge_decision", "").strip() judge = (chunk.get("final_trade_decision") or "").strip()
if agg_hist: if agg_hist:
if message_buffer.agent_status.get("Aggressive Analyst") != "completed": if message_buffer.agent_status.get("Aggressive Analyst") != "completed":
@@ -358,7 +374,7 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None):
# A decision nobody can read is not a position. Say so here rather than # A decision nobody can read is not a position. Say so here rather than
# leaving the run to look like a normal result. # leaving the run to look like a normal result.
if is_review(graph.process_signal(final_state.get("final_trade_decision", ""))): if is_review(run_rating(final_state)):
console.print( console.print(
"[yellow]No rating could be read from the final decision, so this run " "[yellow]No rating could be read from the final decision, so this run "
"is recorded for review rather than as a position. Re-run, or read the " "is recorded for review rather than as a position. Re-run, or read the "
@@ -366,28 +382,35 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None):
) )
console.print(f"[dim]{analyst_wall_time_tracker.format_summary()}[/dim]") console.print(f"[dim]{analyst_wall_time_tracker.format_summary()}[/dim]")
# Prompt to save report _offer_reports(final_state, graph, config, selections["ticker"],
save_choice = typer.prompt("Save report?", default="Y").strip().upper() save=flags.get("save"), show=flags.get("show"))
if save_choice in ("Y", "YES", ""):
def _offer_reports(final_state, graph, config, ticker, save=None, show=None):
"""Save the report tree and show it; ``save``/``show`` answer the questions when given."""
asked = save is None
if asked:
save = typer.prompt("Save report?", default="Y").strip().upper() in ("Y", "YES", "")
if save:
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
# Under results_dir, not the working directory: in Docker the working # Under results_dir, not the working directory: in Docker the working
# directory is inside the container and the report goes with it, while # directory is inside the container and the report goes with it, while
# results_dir is the mounted volume the rest of the run already writes to. # results_dir is the mounted volume the rest of the run already writes to.
default_path = (Path(config["results_dir"]) / "reports" save_path = (Path(config["results_dir"]) / "reports"
/ f"{safe_ticker_component(selections['ticker'])}_{timestamp}") / f"{safe_ticker_component(ticker)}_{timestamp}")
save_path_str = typer.prompt( if asked: # someone at the prompt may pick another folder
"Save path (press Enter for default)", save_path = Path(typer.prompt(
default=str(default_path) "Save path (press Enter for default)", default=str(save_path)
).strip() ).strip())
save_path = Path(save_path_str)
try: try:
report_file = write_report_tree(final_state, selections["ticker"], save_path) report_file = write_report_tree(final_state, ticker, save_path,
settings=graph.run_settings())
console.print(f"\n[green]✓ Report saved to:[/green] {save_path.resolve()}") console.print(f"\n[green]✓ Report saved to:[/green] {save_path.resolve()}")
console.print(f" [dim]Complete report:[/dim] {report_file.name}") console.print(f" [dim]Complete report:[/dim] {report_file.name}")
except Exception as e: except Exception as e:
console.print(f"[red]Error saving report: {e}[/red]") console.print(f"[red]Error saving report: {e}[/red]")
# Prompt to display full report if show is None:
display_choice = typer.prompt("\nDisplay full report on screen?", default="Y").strip().upper() show = typer.prompt("\nDisplay full report on screen?", default="Y").strip().upper() in ("Y", "YES", "")
if display_choice in ("Y", "YES", ""): if show:
display_complete_report(final_state) display_complete_report(final_state)
+47 -15
View File
@@ -25,6 +25,9 @@ from cli.prompts import (
detect_asset_type, detect_asset_type,
ensure_api_key, ensure_api_key,
get_ticker, get_ticker,
parse_analysis_date,
parse_analysts,
parse_ticker,
prompt_openai_compatible_url, prompt_openai_compatible_url,
resolve_backend_url, resolve_backend_url,
select_analysts, select_analysts,
@@ -36,15 +39,40 @@ from cli.prompts import (
from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.default_config import DEFAULT_CONFIG
def get_user_selections(): def get_user_selections(flags=None):
"""Ask for the run's settings, offering the previous run's answers.""" """Ask for the run's settings, offering the previous run's answers."""
selections = _prompt_selections(load_last_run()) selections = _prompt_selections(load_last_run(), flags or {})
save_last_run(selections) save_last_run(selections)
return selections return selections
def _prompt_selections(prefs): def unattended_gaps(flags) -> list[str]:
"""Walk the selection steps. ``prefs`` prefills, the environment skips.""" """The flags and environment variables a run with no terminal still needs."""
env = os.environ.get
gaps = [f"--{name}" for name in ("ticker", "date", "analysts") if flags.get(name) is None]
gaps += [f"--{name} or --no-{name}" for name in ("save", "show") if flags.get(name) is None]
if not env("TRADINGAGENTS_OUTPUT_LANGUAGE"):
gaps.append("TRADINGAGENTS_OUTPUT_LANGUAGE")
if not (env("TRADINGAGENTS_MAX_DEBATE_ROUNDS") and env("TRADINGAGENTS_MAX_RISK_ROUNDS")):
gaps.append("TRADINGAGENTS_MAX_DEBATE_ROUNDS and TRADINGAGENTS_MAX_RISK_ROUNDS")
if not env("TRADINGAGENTS_LLM_PROVIDER"):
gaps.append("TRADINGAGENTS_LLM_PROVIDER")
if not (env("TRADINGAGENTS_QUICK_THINK_LLM") or env("TRADINGAGENTS_DEEP_THINK_LLM")):
gaps.append("TRADINGAGENTS_QUICK_THINK_LLM or TRADINGAGENTS_DEEP_THINK_LLM")
return gaps
def _from_flag(parse, value, *args):
"""A flag's value through the same check its prompt applies; a bad one ends the run."""
try:
return parse(value, *args)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(code=1) from None
def _prompt_selections(prefs, flags):
"""Walk the selection steps. ``prefs`` prefills; flags and the environment skip."""
with open(Path(__file__).parent / "static" / "welcome.txt", encoding="utf-8") as f: with open(Path(__file__).parent / "static" / "welcome.txt", encoding="utf-8") as f:
welcome_ascii = f.read() welcome_ascii = f.read()
@@ -93,6 +121,10 @@ def _prompt_selections(prefs):
return prompt_fn() return prompt_fn()
# Step 1: Ticker symbol # Step 1: Ticker symbol
if flags.get("ticker") is not None:
selected_ticker = _from_flag(parse_ticker, flags["ticker"])
console.print(f"[green]✓ Ticker from --ticker:[/green] {selected_ticker}")
else:
console.print( console.print(
create_question_box( create_question_box(
"Step 1: Ticker Symbol", "Step 1: Ticker Symbol",
@@ -110,6 +142,10 @@ def _prompt_selections(prefs):
) )
# Step 2: Analysis date # Step 2: Analysis date
if flags.get("date") is not None:
analysis_date = _from_flag(parse_analysis_date, flags["date"])
console.print(f"[green]✓ Analysis date from --date:[/green] {analysis_date}")
else:
default_date = datetime.datetime.now().strftime("%Y-%m-%d") default_date = datetime.datetime.now().strftime("%Y-%m-%d")
console.print( console.print(
create_question_box( create_question_box(
@@ -136,12 +172,15 @@ def _prompt_selections(prefs):
output_language = ask_output_language(prefs.get("output_language")) output_language = ask_output_language(prefs.get("output_language"))
# Step 4: Select analysts # Step 4: Select analysts
prefs = sanitize(prefs, asset_type.value)
if flags.get("analysts") is not None:
selected_analysts = _from_flag(parse_analysts, flags["analysts"], asset_type)
else:
console.print( console.print(
create_question_box( create_question_box(
"Step 4: Analysts Team", "Select your LLM analyst agents for the analysis" "Step 4: Analysts Team", "Select your LLM analyst agents for the analysis"
) )
) )
prefs = sanitize(prefs, asset_type.value)
selected_analysts = select_analysts(asset_type, prefs.get("analysts")) selected_analysts = select_analysts(asset_type, prefs.get("analysts"))
console.print( console.print(
f"[green]Selected analysts:[/green] {', '.join(analyst.value for analyst in selected_analysts)}" f"[green]Selected analysts:[/green] {', '.join(analyst.value for analyst in selected_analysts)}"
@@ -303,13 +342,6 @@ def get_analysis_date():
"", default=datetime.datetime.now().strftime("%Y-%m-%d") "", default=datetime.datetime.now().strftime("%Y-%m-%d")
) )
try: try:
# Validate date format and ensure it's not in the future return parse_analysis_date(date_str)
analysis_date = datetime.datetime.strptime(date_str, "%Y-%m-%d") except ValueError as exc:
if analysis_date.date() > datetime.datetime.now().date(): console.print(f"[red]Error: {exc}[/red]")
console.print("[red]Error: Analysis date cannot be in the future[/red]")
continue
return date_str
except ValueError:
console.print(
"[red]Error: Invalid date format. Please use YYYY-MM-DD[/red]"
)
+2 -2
View File
@@ -4,7 +4,7 @@ services:
env_file: env_file:
- .env - .env
volumes: volumes:
- tradingagents_data:/home/appuser/.tradingagents - ${TRADINGAGENTS_DATA_DIR:-tradingagents_data}:/home/appuser/.tradingagents
tty: true tty: true
stdin_open: true stdin_open: true
@@ -23,7 +23,7 @@ services:
- TRADINGAGENTS_LLM_PROVIDER=ollama - TRADINGAGENTS_LLM_PROVIDER=ollama
- OLLAMA_BASE_URL=http://ollama:11434/v1 - OLLAMA_BASE_URL=http://ollama:11434/v1
volumes: volumes:
- tradingagents_data:/home/appuser/.tradingagents - ${TRADINGAGENTS_DATA_DIR:-tradingagents_data}:/home/appuser/.tradingagents
depends_on: depends_on:
- ollama - ollama
tty: true tty: true
+4 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tradingagents" name = "tradingagents"
version = "0.5.1" dynamic = ["version"]
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"
@@ -42,6 +42,9 @@ bedrock = [
[project.scripts] [project.scripts]
tradingagents = "cli.main:app" tradingagents = "cli.main:app"
[tool.setuptools.dynamic]
version = {attr = "tradingagents.__version__"}
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["tradingagents*", "cli*"] include = ["tradingagents*", "cli*"]
+15
View File
@@ -45,6 +45,21 @@ def _no_network(request, monkeypatch):
monkeypatch.setattr(socket.socket, "connect_ex", refuse) monkeypatch.setattr(socket.socket, "connect_ex", refuse)
@pytest.fixture(autouse=True)
def _at_a_terminal(monkeypatch):
"""Tests of the interactive steps run as if at a terminal; pytest's stdin is
not one. A test of an unattended run sets isatty to False itself."""
import sys
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
@pytest.fixture(autouse=True)
def _own_cli_prefs(tmp_path, monkeypatch):
"""The CLI keeps the last run's selections in the user's home; tests keep theirs apart."""
monkeypatch.setattr("cli.prefs._PREFS_PATH", tmp_path / "cli_prefs.json")
_API_KEY_ENV_VARS = ( _API_KEY_ENV_VARS = (
"OPENAI_API_KEY", "OPENAI_API_KEY",
"GOOGLE_API_KEY", "GOOGLE_API_KEY",
+2 -2
View File
@@ -82,7 +82,7 @@ def test_fundamentals_look_ahead_filter_runs_on_json_string(monkeypatch):
# #1115: the payload arrives as a JSON *string*; the old dict-only guard let # #1115: the payload arrives as a JSON *string*; the old dict-only guard let
# future-dated fiscal periods leak into historical runs. # future-dated fiscal periods leak into historical runs.
monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: _FUNDAMENTALS_JSON) monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: _FUNDAMENTALS_JSON)
out = avf.get_balance_sheet("AAPL", curr_date="2024-01-01") out = avf.get_balance_sheet("AAPL", as_of_date="2024-01-01")
assert isinstance(out, str) # callers still receive a str assert isinstance(out, str) # callers still receive a str
parsed = json.loads(out) parsed = json.loads(out)
assert [r["fiscalDateEnding"] for r in parsed["annualReports"]] == ["2023-12-31"] assert [r["fiscalDateEnding"] for r in parsed["annualReports"]] == ["2023-12-31"]
@@ -98,7 +98,7 @@ def test_fundamentals_no_curr_date_passes_through(monkeypatch):
@pytest.mark.unit @pytest.mark.unit
def test_fundamentals_non_json_body_unchanged(monkeypatch): def test_fundamentals_non_json_body_unchanged(monkeypatch):
monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: "not-json") monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: "not-json")
assert avf.get_cashflow("AAPL", curr_date="2024-01-01") == "not-json" assert avf.get_cashflow("AAPL", as_of_date="2024-01-01") == "not-json"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+2 -2
View File
@@ -11,8 +11,8 @@ class AnalystExecutionPlanTests(unittest.TestCase):
self.assertEqual([spec.key for spec in plan.specs], ["news", "market"]) self.assertEqual([spec.key for spec in plan.specs], ["news", "market"])
self.assertEqual(plan.specs[0].agent_node, "News Analyst") self.assertEqual(plan.specs[0].agent_node, "News Analyst")
self.assertEqual(plan.specs[0].tool_node, "tools_news") self.assertEqual(plan.specs[0].report_key, "news_report")
self.assertEqual(plan.specs[0].clear_node, "Msg Clear News") self.assertFalse(hasattr(plan.specs[0], "clear_node"))
def test_rejects_unknown_analyst_keys(self): def test_rejects_unknown_analyst_keys(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
+3 -3
View File
@@ -1,4 +1,4 @@
"""Backtesting: many single-shot decisions, scored by the decision log. """Backtesting: many single-shot decisions, scored by the memory log.
A run already records its rating and later settles it with realized and alpha A run already records its rating and later settles it with realized and alpha
return against the regional benchmark. A backtest is that machinery over a grid return against the regional benchmark. A backtest is that machinery over a grid
@@ -11,7 +11,7 @@ from __future__ import annotations
import pytest import pytest
from tradingagents.backtest import iter_grid, run_backtest, summarize from tradingagents.backtest import iter_grid, run_backtest, summarize
from tradingagents.decision_log import TradingMemoryLog from tradingagents.memory import TradingMemoryLog
DECISION = "Rating: Buy\n\nbuy it" DECISION = "Rating: Buy\n\nbuy it"
@@ -76,7 +76,7 @@ def _config(tmp_path):
@pytest.mark.unit @pytest.mark.unit
def test_the_live_decision_log_is_never_written(tmp_path): def test_the_live_memory_log_is_never_written(tmp_path):
config = _config(tmp_path) config = _config(tmp_path)
result = run_backtest(["NVDA"], ["2026-01-05", "2026-01-12"], config) result = run_backtest(["NVDA"], ["2026-01-05", "2026-01-12"], config)
+84
View File
@@ -0,0 +1,84 @@
"""Cache files are replaced whole: a reader never sees a half-written file, and
concurrent writers (tool calls run in parallel) never share a temp file."""
import os
from pathlib import Path
import pandas as pd
import pytest
from tradingagents.dataflows.vendors import sec_edgar
from tradingagents.dataflows.vendors.yahoo import ohlcv
def _recording_replace(monkeypatch):
moves = []
real = os.replace
def replace(src, dst):
moves.append((Path(src), Path(dst)))
real(src, dst)
monkeypatch.setattr(os, "replace", replace)
return moves
@pytest.mark.unit
def test_sec_edgar_writers_never_share_a_temp_file(monkeypatch, tmp_path):
monkeypatch.setattr(sec_edgar, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
monkeypatch.setattr(sec_edgar, "_fetch_json", lambda url: {"url": url})
moves = _recording_replace(monkeypatch)
sec_edgar._cached_json("https://example/a", "facts.json")
(tmp_path / "sec_edgar" / "facts.json").unlink()
sec_edgar._cached_json("https://example/a", "facts.json")
temps = [src for src, _ in moves]
assert len(set(temps)) == 2
assert all(src.parent == dst.parent for src, dst in moves)
@pytest.mark.unit
def test_the_price_cache_is_replaced_whole(monkeypatch, tmp_path):
frame = pd.DataFrame({"Date": pd.bdate_range("2026-01-02", periods=3), "Open": 1.0, "High": 1.0,
"Low": 1.0, "Close": 1.0, "Volume": 1})
monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
monkeypatch.setattr(ohlcv.yf, "Ticker", lambda s: type("T", (), {"history": lambda self, **k: frame.set_index("Date")})())
moves = _recording_replace(monkeypatch)
ohlcv.load_ohlcv("AAPL", "2026-01-06")
assert [dst.name for _, dst in moves] == ["AAPL-YFin-data.csv"]
assert moves[0][0].parent == tmp_path and moves[0][0] != moves[0][1]
assert not list(tmp_path.glob("*.tmp*"))
@pytest.mark.unit
def test_a_replaced_file_keeps_the_usual_permissions(tmp_path):
from tradingagents.dataflows.files import replace_file
plain = tmp_path / "plain.txt"
plain.write_text("x")
replaced = tmp_path / "replaced.txt"
replace_file(replaced, lambda temp: Path(temp).write_text("x"))
assert replaced.stat().st_mode & 0o777 == plain.stat().st_mode & 0o777
@pytest.mark.unit
def test_a_file_held_open_elsewhere_keeps_its_old_content(monkeypatch, tmp_path):
"""On Windows a file another reader has open cannot be replaced; the cache
write is skipped rather than failing the call that produced the data."""
from tradingagents.dataflows import files
target = tmp_path / "cache.csv"
target.write_text("old")
def locked(src, dst):
raise PermissionError("in use")
monkeypatch.setattr(files.os, "replace", locked)
files.replace_file(target, lambda temp: Path(temp).write_text("new"))
assert target.read_text() == "old"
assert list(tmp_path.iterdir()) == [target]
+18
View File
@@ -25,6 +25,17 @@ class TestExactIdMatches:
assert caps.supports_tool_choice is False assert caps.supports_tool_choice is False
assert caps.requires_reasoning_content_roundtrip is True assert caps.requires_reasoning_content_roundtrip is True
def test_deepseek_flash_alias_rejects_tool_choice(self):
"""``deepseek-flash`` is what the model picker offers for V4.1 Flash.
It was falling through to _DEFAULT (tool_choice on), so every
structured-output call 400'd with "Thinking mode does not support
this tool_choice" and burned a retry as free text.
"""
caps = get_capabilities("deepseek-flash")
assert caps.supports_tool_choice is False
assert caps.requires_reasoning_content_roundtrip is True
def test_deepseek_v4_pro_rejects_tool_choice(self): def test_deepseek_v4_pro_rejects_tool_choice(self):
caps = get_capabilities("deepseek-v4-pro") caps = get_capabilities("deepseek-v4-pro")
assert caps.supports_tool_choice is False assert caps.supports_tool_choice is False
@@ -48,6 +59,10 @@ class TestPatternMatches:
caps = get_capabilities("deepseek-reasoner-pro") caps = get_capabilities("deepseek-reasoner-pro")
assert caps.supports_tool_choice is False assert caps.supports_tool_choice is False
def test_future_flash_variant_inherits_thinking_quirks(self):
caps = get_capabilities("deepseek-flash-lite")
assert caps.supports_tool_choice is False
def test_minimax_m3_inherits_thinking_quirks(self): def test_minimax_m3_inherits_thinking_quirks(self):
caps = get_capabilities("MiniMax-M3") caps = get_capabilities("MiniMax-M3")
assert caps.supports_tool_choice is False assert caps.supports_tool_choice is False
@@ -128,6 +143,9 @@ class TestOpenRouterDeepSeekNamespace:
def test_prefixed_reasoner_suppresses_tool_choice(self): def test_prefixed_reasoner_suppresses_tool_choice(self):
assert get_capabilities("deepseek/deepseek-reasoner").supports_tool_choice is False assert get_capabilities("deepseek/deepseek-reasoner").supports_tool_choice is False
def test_prefixed_flash_alias_suppresses_tool_choice(self):
assert get_capabilities("deepseek/deepseek-flash").supports_tool_choice is False
def test_prefixed_chat_selects_deepseek_chat_not_default(self): def test_prefixed_chat_selects_deepseek_chat_not_default(self):
# Must resolve to _DEEPSEEK_CHAT, not _DEFAULT: supports_json_schema=False # Must resolve to _DEEPSEEK_CHAT, not _DEFAULT: supports_json_schema=False
# is what distinguishes them (both keep tool_choice). # is what distinguishes them (both keep tool_choice).
+4
View File
@@ -210,6 +210,10 @@ class TestCheckpointSignature(unittest.TestCase):
# Stable for identical inputs. # Stable for identical inputs.
g.config = {"max_debate_rounds": 1, "max_risk_discuss_rounds": 1} g.config = {"max_debate_rounds": 1, "max_risk_discuss_rounds": 1}
self.assertEqual(base, g._run_signature("stock")) self.assertEqual(base, g._run_signature("stock"))
# A checkpoint saved by the sequential layout is not resumed on the
# parallel one: its pending node no longer exists, and the join would
# never fire.
self.assertIn("analysts=parallel", base)
if __name__ == "__main__": if __name__ == "__main__":
+2 -1
View File
@@ -25,7 +25,8 @@ calls: list = []
@pytest.mark.unit @pytest.mark.unit
def test_no_arguments_still_runs_an_analysis(runner): def test_no_arguments_still_runs_an_analysis(runner):
assert runner.invoke(m.app, []).exit_code == 0 assert runner.invoke(m.app, []).exit_code == 0
assert calls == [("analysis", {"checkpoint": None, "portfolio": None})] no_flags = {"ticker": None, "date": None, "analysts": None, "save": None, "show": None}
assert calls == [("analysis", {"checkpoint": None, "portfolio": None, "flags": no_flags})]
@pytest.mark.unit @pytest.mark.unit
+39 -17
View File
@@ -46,11 +46,11 @@ def _state(ticker, final="评级: 买入"):
"company_of_interest": ticker, "trade_date": "2026-09-01", "company_of_interest": ticker, "trade_date": "2026-09-01",
"market_report": "市场", "sentiment_report": "情绪", "news_report": "新闻", "market_report": "市场", "sentiment_report": "情绪", "news_report": "新闻",
"fundamentals_report": "基本面", "investment_plan": "计划", "fundamentals_report": "基本面", "investment_plan": "计划",
"trader_investment_plan": "交易计划", "final_trade_decision": final, "trader_investment_plan": "交易计划", "final_trade_decision": final, "final_rating": "REVIEW",
"investment_debate_state": {"bull_history": "", "bear_history": "", "history": "", "investment_debate_state": {"bull_history": "", "bear_history": "", "history": "",
"current_response": "", "judge_decision": "", "count": 0}, "current_response": "", "count": 0},
"risk_debate_state": {"aggressive_history": "", "conservative_history": "", "risk_debate_state": {"aggressive_history": "", "conservative_history": "",
"neutral_history": "", "history": "", "judge_decision": "", "neutral_history": "", "history": "",
"latest_speaker": "", "current_aggressive_response": "", "latest_speaker": "", "current_aggressive_response": "",
"current_conservative_response": "", "current_neutral_response": "", "current_conservative_response": "", "current_neutral_response": "",
"count": 0}, "count": 0},
@@ -61,7 +61,10 @@ def _bare_graph(tmp_path):
from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.graph.trading_graph import TradingAgentsGraph
graph = object.__new__(TradingAgentsGraph) graph = object.__new__(TradingAgentsGraph)
graph.config = {"results_dir": str(tmp_path)} graph.config = {"results_dir": str(tmp_path), "llm_provider": "openai", "deep_think_llm": "d",
"quick_think_llm": "q", "max_debate_rounds": 1, "max_risk_discuss_rounds": 1,
"output_language": "English", "data_vendors": {}, "tool_vendors": {}}
graph.selected_analysts = ("market",)
return graph return graph
@@ -76,6 +79,20 @@ def test_the_state_log_keeps_non_ascii_readable(tmp_path):
assert json.loads(written) # still valid JSON assert json.loads(written) # still valid JSON
@pytest.mark.unit
def test_the_state_log_names_each_field_as_the_state_does(tmp_path):
"""One name per field: the Trader's plan under its state key, and no second
copy of the managers' decisions under the debate states."""
_bare_graph(tmp_path)._log_state("2026-09-01", _state("NVDA"))
logged = json.loads(next(tmp_path.rglob("full_states_log*.json")).read_text(encoding="utf-8"))
assert logged["trader_investment_plan"] == "交易计划"
assert logged["investment_plan"] == "计划"
assert "trader_investment_decision" not in logged
assert "judge_decision" not in json.dumps(logged)
assert logged["run_settings"]["deep_think_llm"] == "d"
@pytest.mark.unit @pytest.mark.unit
def test_the_live_display_does_not_scroll_the_terminal(): def test_the_live_display_does_not_scroll_the_terminal():
"""A layout taller than the window makes rich redraw by scrolling, which """A layout taller than the window makes rich redraw by scrolling, which
@@ -112,23 +129,28 @@ class AnalystWallTimeTrackerTests(unittest.TestCase):
"Analyst wall time: News 4.00s | Market 2.25s", "Analyst wall time: News 4.00s | Market 2.25s",
) )
def test_syncs_wall_time_from_sequential_chunks(self): def test_analysts_run_together_and_finish_on_their_own_reports(self):
plan = build_analyst_execution_plan(["market", "news"]) plan = build_analyst_execution_plan(["market", "news"])
tracker = AnalystWallTimeTracker(plan) tracker = AnalystWallTimeTracker(plan)
sync_analyst_tracker_from_chunk(tracker, {}, now=10.0) sync_analyst_tracker_from_chunk(tracker, {}, now=10.0)
self.assertEqual(tracker.format_summary(), "Analyst wall time: pending") self.assertEqual(tracker.format_summary(), "Analyst wall time: pending")
sync_analyst_tracker_from_chunk( sync_analyst_tracker_from_chunk(tracker, {"news_report": "done"}, now=13.0)
tracker, self.assertEqual(tracker.format_summary(), "Analyst wall time: News 3.00s")
{"market_report": "done"},
now=13.0,
)
self.assertEqual(tracker.format_summary(), "Analyst wall time: Market 3.00s")
sync_analyst_tracker_from_chunk( sync_analyst_tracker_from_chunk(tracker, {"news_report": "done", "market_report": "done"}, now=18.0)
tracker, self.assertEqual(tracker.format_summary(), "Analyst wall time: Market 8.00s | News 3.00s")
{"market_report": "done", "news_report": "done"},
now=18.0,
) @pytest.mark.unit
self.assertEqual(tracker.format_summary(), "Analyst wall time: Market 3.00s | News 5.00s") def test_every_selected_analyst_is_in_progress_until_its_report_lands():
from cli.display import MessageBuffer, update_analyst_statuses
buffer = MessageBuffer()
buffer.init_for_analysis(["market", "news", "fundamentals"])
update_analyst_statuses(buffer, {"news_report": "done"})
assert buffer.agent_status["Market Analyst"] == "in_progress"
assert buffer.agent_status["Fundamentals Analyst"] == "in_progress"
assert buffer.agent_status["News Analyst"] == "completed"
+162
View File
@@ -0,0 +1,162 @@
"""Unattended CLI runs: flags answer the per-run questions, and a run with no
terminal stops up front, naming what to set, instead of waiting on a prompt."""
import datetime
import pytest
import typer
from cli import prompts, run, selections
from cli.models import AnalystType, AssetType
UNATTENDED_ENV = {
"TRADINGAGENTS_OUTPUT_LANGUAGE": "English",
"TRADINGAGENTS_MAX_DEBATE_ROUNDS": "1",
"TRADINGAGENTS_MAX_RISK_ROUNDS": "1",
"TRADINGAGENTS_LLM_PROVIDER": "openai",
"TRADINGAGENTS_QUICK_THINK_LLM": "gpt-6-luna",
}
FLAGS = {"ticker": "NVDA", "date": "2026-09-23", "analysts": "market,news", "save": True, "show": False}
@pytest.mark.unit
class TestFlagValues:
def test_a_ticker_is_normalised_and_an_empty_one_refused(self):
assert prompts.parse_ticker("0700.hk") == "0700.HK"
with pytest.raises(ValueError, match="ticker"):
prompts.parse_ticker(" ")
with pytest.raises(ValueError, match="ticker"):
prompts.parse_ticker("NV DA")
def test_a_date_must_be_a_past_or_present_day(self):
assert prompts.parse_analysis_date("2026-09-23") == "2026-09-23"
tomorrow = (datetime.date.today() + datetime.timedelta(days=1)).isoformat()
with pytest.raises(ValueError, match="future"):
prompts.parse_analysis_date(tomorrow)
with pytest.raises(ValueError, match="YYYY-MM-DD"):
prompts.parse_analysis_date("23/09/2026")
def test_analysts_are_named_and_checked_against_the_asset(self):
assert prompts.parse_analysts(" Market, news ", AssetType.STOCK) == [AnalystType.MARKET, AnalystType.NEWS]
with pytest.raises(ValueError, match="market, social, news, fundamentals"):
prompts.parse_analysts("market,macro", AssetType.STOCK)
with pytest.raises(ValueError, match="crypto"):
prompts.parse_analysts("fundamentals", AssetType.CRYPTO)
with pytest.raises(ValueError, match="at least one"):
prompts.parse_analysts(" , ", AssetType.STOCK)
@pytest.mark.unit
def test_flags_skip_exactly_their_own_steps(monkeypatch):
for name, value in UNATTENDED_ENV.items():
monkeypatch.setenv(name, value)
def no_prompt(*a, **k):
raise AssertionError("prompted although a flag answered the step")
for step in ("get_ticker", "get_analysis_date", "select_analysts"):
monkeypatch.setattr(selections, step, no_prompt)
monkeypatch.setattr(selections, "fetch_announcements", lambda: None)
monkeypatch.setattr(selections, "display_announcements", lambda *a: None)
chosen = selections._prompt_selections({}, FLAGS)
assert chosen["ticker"] == "NVDA" and chosen["analysis_date"] == "2026-09-23"
assert chosen["analysts"] == [AnalystType.MARKET, AnalystType.NEWS]
@pytest.mark.unit
def test_without_a_terminal_every_unanswered_question_is_named_before_the_run(monkeypatch, capsys):
for name in UNATTENDED_ENV:
monkeypatch.delenv(name, raising=False)
monkeypatch.setattr(run.sys.stdin, "isatty", lambda: False)
with pytest.raises(typer.Exit):
run.run_analysis(flags={"ticker": "NVDA"})
out = capsys.readouterr().out
for needed in ("--date", "--analysts", "--save", "--show", "TRADINGAGENTS_LLM_PROVIDER"):
assert needed in out
assert "--ticker" not in out
@pytest.mark.unit
def test_nothing_is_missing_when_flags_and_environment_answer_every_step(monkeypatch):
for name, value in UNATTENDED_ENV.items():
monkeypatch.setenv(name, value)
assert selections.unattended_gaps(FLAGS) == []
@pytest.mark.unit
def test_save_and_show_answer_the_questions_after_the_run(monkeypatch, tmp_path):
def no_prompt(*a, **k):
raise AssertionError("prompted although --save/--show answered")
monkeypatch.setattr(run.typer, "prompt", no_prompt)
shown = []
monkeypatch.setattr(run, "display_complete_report", lambda state: shown.append(state))
graph = type("G", (), {"run_settings": lambda self: {}})()
state = {"market_report": "M", "final_trade_decision": "**Rating**: Hold"}
run._offer_reports(state, graph, {"results_dir": str(tmp_path)}, "NVDA", save=True, show=False)
assert list(tmp_path.glob("reports/NVDA_*/complete_report.md"))
assert shown == []
@pytest.mark.unit
def test_an_announcement_does_not_wait_for_enter_without_a_terminal(monkeypatch):
from cli import announcements
from cli.display import console
def no_wait(*a):
raise AssertionError("waited for Enter with no terminal")
monkeypatch.setattr(announcements.getpass, "getpass", no_wait)
monkeypatch.setattr(announcements.sys.stdin, "isatty", lambda: False)
announcements.display_announcements(console, {"content": "Maintenance tonight", "require_attention": True})
@pytest.mark.unit
def test_a_missing_key_without_a_terminal_names_the_variable(monkeypatch, capsys):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setattr(prompts.sys.stdin, "isatty", lambda: False)
with pytest.raises(typer.Exit):
prompts.ensure_api_key("openai")
assert "OPENAI_API_KEY" in capsys.readouterr().out
@pytest.mark.unit
def test_the_command_passes_its_flags_to_the_run(monkeypatch):
from typer.testing import CliRunner
from cli import main as cli_main
seen = {}
monkeypatch.setattr(cli_main, "run_analysis", lambda **kw: seen.update(kw))
result = CliRunner().invoke(cli_main.app, ["--ticker", "NVDA", "--date", "2026-09-23",
"--analysts", "market,news", "--save", "--no-show"])
assert result.exit_code == 0, result.output
assert seen["flags"] == FLAGS
@pytest.mark.unit
def test_sentiment_names_the_sentiment_analyst():
assert prompts.parse_analysts("sentiment,market", AssetType.STOCK) == [AnalystType.MARKET, AnalystType.SOCIAL]
@pytest.mark.unit
def test_an_empty_flag_is_checked_like_its_step_not_reported_missing(monkeypatch):
assert "--ticker" not in selections.unattended_gaps(dict(FLAGS, ticker=""))
@pytest.mark.unit
def test_a_closed_stdin_counts_as_no_terminal(monkeypatch, capsys):
monkeypatch.setattr(run.sys, "stdin", None)
with pytest.raises(typer.Exit):
run.run_analysis(flags={})
assert "--ticker" in capsys.readouterr().out
@@ -1,4 +1,4 @@
"""The CLI must use the decision log the same way propagate() does. """The CLI must use the memory log the same way propagate() does.
The CLI streams the graph itself instead of calling propagate(), so memory steps The CLI streams the graph itself instead of calling propagate(), so memory steps
that lived only in propagate() never ran on the primary entry point: pending that lived only in propagate() never ran on the primary entry point: pending
@@ -12,8 +12,8 @@ from __future__ import annotations
import pytest import pytest
import cli.run as cli_run import cli.run as cli_run
from tradingagents.decision_log import TradingMemoryLog
from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.memory import TradingMemoryLog
def _bare_graph(tmp_path): def _bare_graph(tmp_path):
@@ -76,10 +76,6 @@ class _FakeGraph:
self.calls.append(("create_run_state", ticker, trade_date)) self.calls.append(("create_run_state", ticker, trade_date))
return {"messages": [], "company_of_interest": ticker} return {"messages": [], "company_of_interest": ticker}
def process_signal(self, text):
from tradingagents.agents.rating import parse_rating
return parse_rating(text)
def record_decision(self, ticker, trade_date, final_state): def record_decision(self, ticker, trade_date, final_state):
self.calls.append(("record_decision", ticker, trade_date, final_state.get("final_trade_decision"))) self.calls.append(("record_decision", ticker, trade_date, final_state.get("final_trade_decision")))
@@ -99,9 +95,9 @@ class _FakeGraph:
def end_checkpoint(self): def end_checkpoint(self):
pass pass
def stream(self, graph_input, **kwargs): def stream_run(self, graph_input, **kwargs):
yield {"messages": [], "market_report": "M"} yield [], {"messages": [], "market_report": "M"}
yield {"messages": [], "final_trade_decision": "Rating: Buy\n\nBuy NVDA."} yield [], {"messages": [], "final_trade_decision": "Rating: Buy\n\nBuy NVDA.", "final_rating": "Buy"}
class _NullLive: class _NullLive:
@@ -151,7 +147,7 @@ def _run_cli(monkeypatch, tmp_path, fake):
monkeypatch.setattr(cli_run, "create_layout", lambda: None) monkeypatch.setattr(cli_run, "create_layout", lambda: None)
monkeypatch.setattr(cli_run, "update_display", lambda *a, **k: None) monkeypatch.setattr(cli_run, "update_display", lambda *a, **k: None)
monkeypatch.setattr(cli_run, "Live", _NullLive) monkeypatch.setattr(cli_run, "Live", _NullLive)
monkeypatch.setattr(cli_run, "get_user_selections", lambda: { monkeypatch.setattr(cli_run, "get_user_selections", lambda flags=None: {
"ticker": "NVDA", "analysis_date": "2026-01-10", "ticker": "NVDA", "analysis_date": "2026-01-10",
"analysts": [AnalystType.MARKET], "asset_type": "stock", "analysts": [AnalystType.MARKET], "asset_type": "stock",
}) })
@@ -164,7 +160,7 @@ def _run_cli(monkeypatch, tmp_path, fake):
@pytest.mark.unit @pytest.mark.unit
def test_cli_run_uses_the_decision_log_like_propagate(tmp_path, monkeypatch): def test_cli_run_uses_the_memory_log_like_propagate(tmp_path, monkeypatch):
fake = _FakeGraph() fake = _FakeGraph()
_run_cli(monkeypatch, tmp_path, fake) _run_cli(monkeypatch, tmp_path, fake)
+4 -10
View File
@@ -27,12 +27,6 @@ SAVED = {
} }
@pytest.fixture(autouse=True)
def _home(tmp_path, monkeypatch):
monkeypatch.setattr("cli.prefs._PREFS_PATH", tmp_path / "cli_prefs.json")
return tmp_path
@pytest.mark.unit @pytest.mark.unit
def test_round_trip(): def test_round_trip():
save_last_run(SAVED) save_last_run(SAVED)
@@ -45,18 +39,18 @@ def test_missing_file_is_not_an_error():
@pytest.mark.unit @pytest.mark.unit
def test_a_corrupt_file_degrades_to_no_memory(_home): def test_a_corrupt_file_degrades_to_no_memory(tmp_path):
(_home / "cli_prefs.json").write_text("{not json") (tmp_path / "cli_prefs.json").write_text("{not json")
assert load_last_run() == {} assert load_last_run() == {}
@pytest.mark.unit @pytest.mark.unit
def test_a_half_written_file_cannot_be_observed(_home): def test_a_half_written_file_cannot_be_observed(tmp_path):
"""Two runs finishing together must never leave a torn file behind.""" """Two runs finishing together must never leave a torn file behind."""
save_last_run(SAVED) save_last_run(SAVED)
save_last_run({**SAVED, "research_depth": 5}) save_last_run({**SAVED, "research_depth": 5})
assert load_last_run()["research_depth"] == 5 assert load_last_run()["research_depth"] == 5
assert list((_home).glob("*.tmp*")) == [] assert list(tmp_path.glob("*.tmp*")) == []
# --- validation against the current choices --------------------------------- # --- validation against the current choices ---------------------------------
+1 -1
View File
@@ -148,7 +148,7 @@ def test_settling_reads_the_graphs_own_config(monkeypatch):
graph = _graph(config) graph = _graph(config)
graph.memory_log = graph.reflector = None # the settlement below is a stand-in graph.memory_log = graph.reflector = None # the settlement below is a stand-in
seen = [] seen = []
from tradingagents.graph import settlement from tradingagents.memory import settlement
monkeypatch.setattr(settlement, "settle_pending", monkeypatch.setattr(settlement, "settle_pending",
lambda *a: seen.append(get_vendor("core_stock_apis", "get_stock_data"))) lambda *a: seen.append(get_vendor("core_stock_apis", "get_stock_data")))
+4 -2
View File
@@ -4,6 +4,8 @@ requested end_date (and the current day) is actually included.
Regressions for #986 (current-day OHLCV excluded) and #987 (requested end_date Regressions for #986 (current-day OHLCV excluded) and #987 (requested end_date
row omitted). row omitted).
""" """
from types import SimpleNamespace
import pandas as pd import pandas as pd
import pytest import pytest
@@ -44,7 +46,7 @@ def test_load_ohlcv_requests_inclusive_end(monkeypatch, tmp_path):
set_config({"data_cache_dir": str(tmp_path)}) set_config({"data_cache_dir": str(tmp_path)})
captured = {} captured = {}
def fake_download(symbol, start, end, **kwargs): def fake_history(start, end, **kwargs):
captured["end"] = end captured["end"] = end
idx = pd.to_datetime([pd.Timestamp.today().normalize()]) idx = pd.to_datetime([pd.Timestamp.today().normalize()])
return pd.DataFrame( return pd.DataFrame(
@@ -53,7 +55,7 @@ def test_load_ohlcv_requests_inclusive_end(monkeypatch, tmp_path):
index=idx, index=idx,
) )
monkeypatch.setattr(ohlcv.yf, "download", fake_download) monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace(history=fake_history))
today = pd.Timestamp.today().strftime("%Y-%m-%d") today = pd.Timestamp.today().strftime("%Y-%m-%d")
ohlcv.load_ohlcv("AAPL", today) ohlcv.load_ohlcv("AAPL", today)
+12 -10
View File
@@ -98,8 +98,10 @@ class FredFormattingTests(unittest.TestCase):
self.assertIn("Units: %", out) self.assertIn("Units: %", out)
self.assertIn("Frequency: Monthly (SA)", out) self.assertIn("Frequency: Monthly (SA)", out)
self.assertIn("**Latest:** 4.4 (2025-09-01)", out) self.assertIn("**Latest:** 4.4 (2025-09-01)", out)
# change over the window: 4.4 - 4.1 = +0.30 # The change names the observations it spans, not the lookback window,
self.assertIn("+0.30", out) # so a 3-month move on a monthly series cannot read as year on year.
self.assertIn("**Change from 2025-06-01 to 2025-09-01:** +0.30 (+7.32%), from 4.1", out)
self.assertNotIn("Change over window", out)
self.assertIn("| 2025-06-01 | 4.1 |", out) self.assertIn("| 2025-06-01 | 4.1 |", out)
def test_missing_value_is_skipped(self): def test_missing_value_is_skipped(self):
@@ -133,13 +135,13 @@ class FredFormattingTests(unittest.TestCase):
with mock.patch.object(fred, "_request", side_effect=_request_stub(obs=obs)): with mock.patch.object(fred, "_request", side_effect=_request_stub(obs=obs)):
out = fred.get_macro_data("unemployment", "2025-12-31", 365) out = fred.get_macro_data("unemployment", "2025-12-31", 365)
self.assertIn(f"most recent {fred.MAX_ROWS}", out) self.assertIn(f"most recent {fred.MAX_ROWS}", out)
# change-over-window must reference the true first (0) and last value # the change must reference the true first (0) and last value
self.assertIn("from 0 ", out) self.assertIn("+49.00, from 0\n", out)
body_rows = [ln for ln in out.splitlines() if ln.startswith("| 2025")] body_rows = [ln for ln in out.splitlines() if ln.startswith("| 2025")]
self.assertEqual(len(body_rows), fred.MAX_ROWS) self.assertEqual(len(body_rows), fred.MAX_ROWS)
def test_window_is_lookahead_safe(self): def test_window_is_lookahead_safe(self):
# observation_end must equal curr_date so a past date never pulls future data. # observation_end must equal as_of_date so a past date never pulls future data.
captured = {} captured = {}
def _capture(path, params): def _capture(path, params):
@@ -154,9 +156,9 @@ class FredFormattingTests(unittest.TestCase):
def test_requests_pin_the_data_vintage(self): def test_requests_pin_the_data_vintage(self):
# #1275: both the metadata and observations requests must pin the vintage # #1275: both the metadata and observations requests must pin the vintage
# to curr_date (clamped to FRED's today), or FRED serves the latest # to as_of_date (clamped to FRED's today), or FRED serves the latest
# revision and revision-prone series leak future information. A past # revision and revision-prone series leak future information. A past
# curr_date sits below FRED's today, so it pins through unchanged. # as_of_date sits below FRED's today, so it pins through unchanged.
captured = {} captured = {}
def _capture(path, params): def _capture(path, params):
@@ -172,11 +174,11 @@ class FredFormattingTests(unittest.TestCase):
self.assertEqual(captured[path]["realtime_end"], "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): 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, # #1275 regression: on a live run as_of_date is the caller's LOCAL date,
# which can be a day ahead of FRED's US-Central clock. Pinning the vintage # 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 # 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 # silently. The pin must clamp to FRED's today; the observation window
# (future bars can't exist yet) stays at curr_date. # (future bars can't exist yet) stays at as_of_date.
captured = {} captured = {}
def _capture(path, params): def _capture(path, params):
@@ -190,7 +192,7 @@ class FredFormattingTests(unittest.TestCase):
for path in ("series", "series/observations"): for path in ("series", "series/observations"):
self.assertEqual(captured[path]["realtime_start"], "2026-08-31", path) self.assertEqual(captured[path]["realtime_start"], "2026-08-31", path)
self.assertEqual(captured[path]["realtime_end"], "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 # the observation window still tracks as_of_date, not the clamped vintage
self.assertEqual(captured["series/observations"]["observation_end"], "2026-09-01") self.assertEqual(captured["series/observations"]["observation_end"], "2026-09-01")
+5 -5
View File
@@ -10,7 +10,7 @@ FRED (#1275), social (#1220) and memory (#1251) leaks.
Both vendors withhold on one shared rule (``date_window.withhold_live_profile``) Both vendors withhold on one shared rule (``date_window.withhold_live_profile``)
so switching ``fundamental_data`` between them cannot reintroduce the leak. The so switching ``fundamental_data`` between them cannot reintroduce the leak. The
statement tools stay point-in-time by filtering on ``curr_date``, and a live run statement tools stay point-in-time by filtering on ``as_of_date``, and a live run
is unchanged. All API access is mocked. is unchanged. All API access is mocked.
""" """
from __future__ import annotations from __future__ import annotations
@@ -45,19 +45,19 @@ _LEAKY = ("3500000000000", "34.2", "260.1", "391000000000",
"Apple Inc.", "Technology", "Consumer Electronics") "Apple Inc.", "Technology", "Consumer Electronics")
def _yf(curr_date, info=_INFO, today=_TODAY): def _yf(as_of_date, info=_INFO, today=_TODAY):
with mock.patch.object(date_window, "get_current_date", return_value=today), \ with mock.patch.object(date_window, "get_current_date", return_value=today), \
mock.patch.object(yahoo_fundamentals, "yf_retry", lambda fn: info), \ mock.patch.object(yahoo_fundamentals, "yf_retry", lambda fn: info), \
mock.patch.object(yahoo_market.yf, "Ticker"): mock.patch.object(yahoo_market.yf, "Ticker"):
return yahoo_fundamentals.get_fundamentals("AAPL", curr_date) return yahoo_fundamentals.get_fundamentals("AAPL", as_of_date)
def _av(curr_date, today=_TODAY): def _av(as_of_date, today=_TODAY):
"""Alpha Vantage path; the API call is mocked so a leak would be visible.""" """Alpha Vantage path; the API call is mocked so a leak would be visible."""
with mock.patch.object(date_window, "get_current_date", return_value=today), \ with mock.patch.object(date_window, "get_current_date", return_value=today), \
mock.patch.object(av, "_make_api_request", mock.patch.object(av, "_make_api_request",
return_value="MarketCapitalization: 3500000000000") as req: return_value="MarketCapitalization: 3500000000000") as req:
return av.get_fundamentals("AAPL", curr_date), req return av.get_fundamentals("AAPL", as_of_date), req
@pytest.mark.unit @pytest.mark.unit
+65
View File
@@ -0,0 +1,65 @@
"""``get_scrubbed``: the vendor key must not survive into an error.
FRED and Alpha Vantage authenticate with a query parameter, so requests quotes
the key back in the text of HTTP, connection and timeout errors, and in the
``request`` and ``response`` it attaches (#1324).
"""
from unittest import mock
import pytest
import requests
from tradingagents.dataflows.net import get_scrubbed
# A fabricated value in the shape of a FRED key.
_KEY = "deadbeefdeadbeefdeadbeefdeadbeef"
_LEAK = (
"HTTPSConnectionPool(host='api.stlouisfed.org', port=443): Max retries "
f"exceeded with url: /fred/series?series_id=DGS10&api_key={_KEY}&file_type=json"
)
def _response(status_code):
response = mock.Mock(spec=requests.Response)
response.status_code = status_code
response.raise_for_status.side_effect = requests.HTTPError(_LEAK) if status_code >= 400 else None
return response
def _call(**kwargs):
return get_scrubbed("https://api.stlouisfed.org/fred/series",
params={"api_key": _KEY}, timeout=5, secret=_KEY, **kwargs)
@pytest.mark.unit
@pytest.mark.parametrize("failure", [
{"side_effect": requests.ConnectionError(_LEAK)},
{"return_value": _response(500)},
], ids=["transport", "status"])
def test_the_key_is_masked_in_the_error(failure):
with mock.patch.object(requests, "get", **failure), pytest.raises(requests.RequestException) as caught:
_call()
assert _KEY not in str(caught.value) and _KEY not in repr(caught.value.args)
assert "api_key=***" in str(caught.value)
@pytest.mark.unit
def test_nothing_that_holds_the_url_travels_with_the_error():
"""The request, the response and a chained original each carry the full URL."""
leaky = requests.ConnectionError(_LEAK)
leaky.request = mock.Mock(url=f"https://x.invalid/?api_key={_KEY}")
leaky.response = mock.Mock(url=f"https://x.invalid/?api_key={_KEY}")
with mock.patch.object(requests, "get", side_effect=leaky), pytest.raises(requests.ConnectionError) as caught:
_call()
error = caught.value
assert error.request is None and error.response is None
assert error.__cause__ is None and error.__context__ is None
@pytest.mark.unit
def test_a_passthrough_status_is_returned_for_the_caller():
response = _response(400)
with mock.patch.object(requests, "get", return_value=response):
assert _call(passthrough=(400,)) is response
response.raise_for_status.assert_not_called()
+50 -4
View File
@@ -34,8 +34,10 @@ STRUCTURED = {
schemas.ResearchPlan: schemas.ResearchPlan( schemas.ResearchPlan: schemas.ResearchPlan(
recommendation=schemas.PortfolioRating.OVERWEIGHT, rationale="r", strategic_actions="a"), recommendation=schemas.PortfolioRating.OVERWEIGHT, rationale="r", strategic_actions="a"),
schemas.TraderProposal: schemas.TraderProposal(action=schemas.TraderAction.BUY, reasoning="r"), schemas.TraderProposal: schemas.TraderProposal(action=schemas.TraderAction.BUY, reasoning="r"),
# The thesis quotes another party's rating; the decision is still the PM's own.
schemas.PortfolioDecision: schemas.PortfolioDecision( schemas.PortfolioDecision: schemas.PortfolioDecision(
rating=schemas.PortfolioRating.OVERWEIGHT, executive_summary="s", investment_thesis="t"), rating=schemas.PortfolioRating.OVERWEIGHT, executive_summary="s",
investment_thesis="Street consensus rating: Buy (28 of 35 analysts)."),
schemas.SentimentReport: schemas.SentimentReport( schemas.SentimentReport: schemas.SentimentReport(
overall_band=schemas.SentimentBand.NEUTRAL, overall_score=5.0, confidence="low", narrative="n"), overall_band=schemas.SentimentBand.NEUTRAL, overall_score=5.0, confidence="low", narrative="n"),
} }
@@ -50,6 +52,7 @@ class ScriptedModel(BaseChatModel):
structured: bool = False structured: bool = False
tools: tuple = () tools: tuple = ()
calls: list = Field(default_factory=list) # shared across bound copies calls: list = Field(default_factory=list) # shared across bound copies
threads: set = Field(default_factory=set) # threads that served a tool-bound call
fail_at: int | None = None # raise on this call, once fail_at: int | None = None # raise on this call, once
@property @property
@@ -71,6 +74,11 @@ class ScriptedModel(BaseChatModel):
def _generate(self, messages, stop=None, run_manager=None, **kwargs) -> ChatResult: def _generate(self, messages, stop=None, run_manager=None, **kwargs) -> ChatResult:
self._count() self._count()
if self.tools:
import threading
import time
self.threads.add(threading.current_thread().name)
time.sleep(0.05) # long enough for concurrent analysts to overlap
if self.tools and not isinstance(messages[-1], ToolMessage): if self.tools and not isinstance(messages[-1], ToolMessage):
calls = [{"name": t.name, "id": f"call_{i}", calls = [{"name": t.name, "id": f"call_{i}",
"args": {k: v for k, v in ARGS.items() "args": {k: v for k, v in ARGS.items()
@@ -111,12 +119,12 @@ def offline(monkeypatch, tmp_path):
return called return called
def _graph(tmp_path, monkeypatch, model, **config): def _graph(tmp_path, monkeypatch, model, debug=False, **config):
cfg = copy.deepcopy(DEFAULT_CONFIG) cfg = copy.deepcopy(DEFAULT_CONFIG)
cfg.update(results_dir=str(tmp_path / "results"), data_cache_dir=str(tmp_path / "cache"), cfg.update(results_dir=str(tmp_path / "results"), data_cache_dir=str(tmp_path / "cache"),
memory_log_path=str(tmp_path / "log.md"), **config) memory_log_path=str(tmp_path / "log.md"), **config)
monkeypatch.setattr(trading_graph, "create_llm_client", lambda **k: _Client(model)) monkeypatch.setattr(trading_graph, "create_llm_client", lambda **k: _Client(model))
return trading_graph.TradingAgentsGraph(config=cfg) return trading_graph.TradingAgentsGraph(config=cfg, debug=debug)
@pytest.mark.unit @pytest.mark.unit
@@ -126,7 +134,7 @@ def test_a_full_run_reaches_a_logged_decision(tmp_path, monkeypatch, offline, st
state, signal = graph.propagate("NVDA", TRADE_DATE) state, signal = graph.propagate("NVDA", TRADE_DATE)
assert signal == "Overweight" assert signal == state["final_rating"] == "Overweight"
for key in ("market_report", "sentiment_report", "news_report", "fundamentals_report", for key in ("market_report", "sentiment_report", "news_report", "fundamentals_report",
"investment_plan", "trader_investment_plan", "final_trade_decision"): "investment_plan", "trader_investment_plan", "final_trade_decision"):
assert state[key].strip(), key assert state[key].strip(), key
@@ -167,3 +175,41 @@ def test_a_graph_reused_across_runs_keeps_no_run_state(tmp_path, monkeypatch, of
held = [v for v in vars(graph).values() if isinstance(v, dict) and TRADE_DATE in v] held = [v for v in vars(graph).values() if isinstance(v, dict) and TRADE_DATE in v]
assert held == [] assert held == []
assert len(list(tmp_path.glob("results/NVDA/TradingAgentsStrategy_logs/*.json"))) == 2 assert len(list(tmp_path.glob("results/NVDA/TradingAgentsStrategy_logs/*.json"))) == 2
@pytest.mark.unit
def test_the_analysts_run_at_the_same_time(tmp_path, monkeypatch, offline):
model = ScriptedModel()
graph = _graph(tmp_path, monkeypatch, model)
assert not [n for n in graph.graph.get_graph().nodes if n.startswith("Msg Clear")]
graph.propagate("NVDA", TRADE_DATE)
assert len(model.threads) > 1
@pytest.mark.unit
def test_a_debug_run_prints_the_analysts_work_and_reaches_the_same_decision(tmp_path, monkeypatch, offline, capsys):
"""Debug mode streams the analysts' own graphs, so their tool calls still print."""
graph = _graph(tmp_path, monkeypatch, ScriptedModel(), debug=True)
state, signal = graph.propagate("NVDA", TRADE_DATE)
assert signal == "Overweight"
assert state["market_report"].strip() and state["fundamentals_report"].strip()
printed = capsys.readouterr().out
assert "get_stock_data" in printed and "get_balance_sheet" in printed
@pytest.mark.unit
def test_each_report_streams_as_soon_as_its_analyst_files_it(tmp_path, monkeypatch, offline):
"""The main state takes the analysts' reports only when the slowest one is
done; the CLI shows each report, and stops each clock, as it lands."""
graph = _graph(tmp_path, monkeypatch, ScriptedModel())
reports = ("market_report", "sentiment_report", "news_report", "fundamentals_report")
first = next(state for _, state in graph.stream_run(graph.create_run_state("NVDA", TRADE_DATE),
**graph.propagator.get_graph_args())
if state and any(state.get(k) for k in reports))
assert sum(bool(first.get(k)) for k in reports) == 1
-50
View File
@@ -5,11 +5,9 @@ import unittest
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage
from tradingagents.agents.context import ( from tradingagents.agents.context import (
build_instrument_context, build_instrument_context,
create_msg_delete,
get_instrument_context_from_state, get_instrument_context_from_state,
resolve_instrument_identity, resolve_instrument_identity,
) )
@@ -118,53 +116,5 @@ class GetInstrumentContextFromStateTests(unittest.TestCase):
self.assertIn("crypto asset", context) self.assertIn("crypto asset", context)
@pytest.mark.unit
class ContextAnchoredPlaceholderTests(unittest.TestCase):
"""#888 — the message-clear placeholder must not be a bare 'Continue'."""
def _run(self, state_extra):
state = {
"messages": [
HumanMessage(content="old", id="h1"),
AIMessage(content="reply", id="a1"),
],
**state_extra,
}
return create_msg_delete()(state)
def test_placeholder_is_not_bare_continue(self):
result = self._run(
{"company_of_interest": "EC", "asset_type": "stock", "trade_date": "2026-05-28"}
)
placeholder = result["messages"][-1]
self.assertIsInstance(placeholder, HumanMessage)
self.assertNotEqual(placeholder.content.strip(), "Continue")
def test_placeholder_carries_resolved_identity(self):
result = self._run(
{
"company_of_interest": "EC",
"instrument_context": "The instrument to analyze is `EC`. Resolved identity: Company: Ecopetrol.",
"trade_date": "2026-05-28",
}
)
content = result["messages"][-1].content
self.assertIn("Ecopetrol", content)
self.assertIn("2026-05-28", content)
def test_old_messages_are_removed(self):
result = self._run({"company_of_interest": "EC", "trade_date": "2026-05-28"})
removals = [m for m in result["messages"] if isinstance(m, RemoveMessage)]
humans = [m for m in result["messages"] if isinstance(m, HumanMessage)]
self.assertEqual(len(removals), 2)
self.assertEqual(len(humans), 1)
def test_safe_defaults_when_state_minimal(self):
result = create_msg_delete()({"messages": [], "company_of_interest": "EC"})
placeholder = result["messages"][-1]
self.assertNotEqual(placeholder.content.strip(), "Continue")
self.assertIn("EC", placeholder.content)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+20 -12
View File
@@ -7,11 +7,10 @@ import pytest
from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager from tradingagents.agents.managers.portfolio_manager import create_portfolio_manager
from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating from tradingagents.agents.schemas import PortfolioDecision, PortfolioRating
from tradingagents.decision_log import TradingMemoryLog
from tradingagents.graph import settlement
from tradingagents.graph.propagation import Propagator from tradingagents.graph.propagation import Propagator
from tradingagents.graph.reflection import Reflector
from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.memory import TradingMemoryLog, settlement
from tradingagents.memory.reflection import Reflector
_SEP = TradingMemoryLog._SEPARATOR _SEP = TradingMemoryLog._SEPARATOR
@@ -75,7 +74,6 @@ def _make_pm_state(past_context=""):
"aggressive_history": "", "aggressive_history": "",
"conservative_history": "", "conservative_history": "",
"neutral_history": "", "neutral_history": "",
"judge_decision": "",
"current_aggressive_response": "", "current_aggressive_response": "",
"current_conservative_response": "", "current_conservative_response": "",
"current_neutral_response": "", "current_neutral_response": "",
@@ -628,6 +626,16 @@ class TestDeferredReflection:
"benchmark_map": DEFAULT_CONFIG["benchmark_map"]} "benchmark_map": DEFAULT_CONFIG["benchmark_map"]}
assert settlement.resolve_benchmark("PETR4.SA", config) == "^BVSP" assert settlement.resolve_benchmark("PETR4.SA", config) == "^BVSP"
@pytest.mark.parametrize(("ticker", "index"), [
("2330.TW", "^TWII"), ("6488.TWO", "^TWII"), ("005930.KS", "^KS11"), ("247540.KQ", "^KQ11"), ("D05.SI", "^STI"), ("SAP.DE", "^GDAXI"),
("MC.PA", "^FCHI"), ("ASML.AS", "^AEX"), ("NESN.SW", "^SSMI"), ("ENI.MI", "FTSEMIB.MI"),
])
def test_resolve_benchmark_regional_indexes(self, ticker, index):
"""Taiwan, Korea, Singapore and the main European exchanges were measured against SPY."""
from tradingagents.default_config import DEFAULT_CONFIG
config = {"benchmark_ticker": None, "benchmark_map": DEFAULT_CONFIG["benchmark_map"]}
assert settlement.resolve_benchmark(ticker, config) == index
def test_resolve_benchmark_us_ticker_defaults_to_spy(self): def test_resolve_benchmark_us_ticker_defaults_to_spy(self):
"""US tickers (no dotted suffix) take the empty-suffix entry.""" """US tickers (no dotted suffix) take the empty-suffix entry."""
config = { config = {
@@ -863,12 +871,12 @@ class TestLegacyRemoval:
def test_financial_situation_memory_removed(self): def test_financial_situation_memory_removed(self):
"""FinancialSituationMemory must not be importable from the memory module.""" """FinancialSituationMemory must not be importable from the memory module."""
import tradingagents.decision_log as m import tradingagents.memory as m
assert not hasattr(m, "FinancialSituationMemory") assert not hasattr(m, "FinancialSituationMemory")
def test_bm25_not_imported(self): def test_bm25_not_imported(self):
"""rank_bm25 must not be present in the memory module namespace.""" """rank_bm25 must not be present in the memory module namespace."""
import tradingagents.decision_log as m import tradingagents.memory as m
assert not hasattr(m, "BM25Okapi") assert not hasattr(m, "BM25Okapi")
def test_reflect_and_remember_removed(self): def test_reflect_and_remember_removed(self):
@@ -888,6 +896,7 @@ class TestLegacyRemoval:
fake_state = { fake_state = {
"final_trade_decision": "Rating: Buy\nBuy NVDA.", "final_trade_decision": "Rating: Buy\nBuy NVDA.",
"final_rating": "Buy",
"company_of_interest": "NVDA", "company_of_interest": "NVDA",
"trade_date": "2026-01-10", "trade_date": "2026-01-10",
"market_report": "", "market_report": "",
@@ -896,13 +905,13 @@ class TestLegacyRemoval:
"fundamentals_report": "", "fundamentals_report": "",
"investment_debate_state": { "investment_debate_state": {
"bull_history": "", "bear_history": "", "history": "", "bull_history": "", "bear_history": "", "history": "",
"current_response": "", "judge_decision": "", "current_response": "",
}, },
"investment_plan": "", "investment_plan": "",
"trader_investment_plan": "", "trader_investment_plan": "",
"risk_debate_state": { "risk_debate_state": {
"aggressive_history": "", "conservative_history": "", "aggressive_history": "", "conservative_history": "",
"neutral_history": "", "history": "", "judge_decision": "", "neutral_history": "", "history": "",
"current_aggressive_response": "", "current_conservative_response": "", "current_aggressive_response": "", "current_conservative_response": "",
"current_neutral_response": "", "count": 1, "latest_speaker": "", "current_neutral_response": "", "count": 1, "latest_speaker": "",
}, },
@@ -914,7 +923,6 @@ class TestLegacyRemoval:
mock_graph.graph.invoke.return_value = fake_state mock_graph.graph.invoke.return_value = fake_state
mock_graph.propagator.create_initial_state.return_value = fake_state mock_graph.propagator.create_initial_state.return_value = fake_state
mock_graph.propagator.get_graph_args.return_value = {} mock_graph.propagator.get_graph_args.return_value = {}
mock_graph.process_signal.return_value = "Buy"
# Bind the real _run_graph so propagate's call to self._run_graph executes # Bind the real _run_graph so propagate's call to self._run_graph executes
# the actual write path instead of the auto-MagicMock. # the actual write path instead of the auto-MagicMock.
mock_graph._run_graph = functools.partial( mock_graph._run_graph = functools.partial(
@@ -934,8 +942,8 @@ class TestLegacyRemoval:
def test_a_failed_reflection_leaves_the_entry_pending_and_lets_the_run_start(tmp_path, monkeypatch): def test_a_failed_reflection_leaves_the_entry_pending_and_lets_the_run_start(tmp_path, monkeypatch):
"""Settling past decisions happens on the way into a new run, and reflection """Settling past decisions happens on the way into a new run, and reflection
calls an LLM. A transient failure there must not stop the new analysis.""" calls an LLM. A transient failure there must not stop the new analysis."""
from tradingagents.decision_log import TradingMemoryLog
from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.memory import TradingMemoryLog
graph = object.__new__(TradingAgentsGraph) graph = object.__new__(TradingAgentsGraph)
graph.config = {"memory_log_path": str(tmp_path / "m.md")} graph.config = {"memory_log_path": str(tmp_path / "m.md")}
@@ -967,8 +975,8 @@ def test_a_failed_reflection_leaves_the_entry_pending_and_lets_the_run_start(tmp
def test_the_holding_window_is_configurable(tmp_path, monkeypatch): def test_the_holding_window_is_configurable(tmp_path, monkeypatch):
"""A decision written for months should not be graded at a week without the """A decision written for months should not be graded at a week without the
operator choosing that window.""" operator choosing that window."""
from tradingagents.decision_log import TradingMemoryLog
from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.memory import TradingMemoryLog
graph = object.__new__(TradingAgentsGraph) graph = object.__new__(TradingAgentsGraph)
graph.config = {"memory_log_path": str(tmp_path / "m.md"), "holding_period_days": 21} graph.config = {"memory_log_path": str(tmp_path / "m.md"), "holding_period_days": 21}
@@ -994,7 +1002,7 @@ def test_the_holding_window_is_configurable(tmp_path, monkeypatch):
def test_the_reflection_states_the_window_it_judges(): def test_the_reflection_states_the_window_it_judges():
"""Judging a months-long thesis on a week's alpha, without saying so, turns """Judging a months-long thesis on a week's alpha, without saying so, turns
a scope mismatch into a lesson that the call was wrong.""" a scope mismatch into a lesson that the call was wrong."""
from tradingagents.graph.reflection import Reflector from tradingagents.memory.reflection import Reflector
prompt = Reflector(None)._system_prompt(holding_days=5) prompt = Reflector(None)._system_prompt(holding_days=5)
assert "5" in prompt and "trading day" in prompt assert "5" in prompt and "trading day" in prompt
+1 -1
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import pytest import pytest
from tradingagents.decision_log import TradingMemoryLog from tradingagents.memory import TradingMemoryLog
def _log(tmp_path): def _log(tmp_path):
+4 -4
View File
@@ -33,19 +33,19 @@ class TestLoadOhlcvNoPoison(unittest.TestCase):
os.rmdir(self._tmp) os.rmdir(self._tmp)
def test_empty_download_raises_and_does_not_cache(self): def test_empty_download_raises_and_does_not_cache(self):
empty = pd.DataFrame() empty = mock.Mock(history=mock.Mock(return_value=pd.DataFrame()))
# Yahoo answers, so an empty download means the symbol has no data. # Yahoo answers, so an empty download means the symbol has no data.
reachable = mock.patch.object(ohlcv, "vendor_reachable", return_value=True) reachable = mock.patch.object(ohlcv, "vendor_reachable", return_value=True)
reachable.start() reachable.start()
self.addCleanup(reachable.stop) self.addCleanup(reachable.stop)
with mock.patch.object(ohlcv.yf, "download", return_value=empty), \ with mock.patch.object(ohlcv.yf, "Ticker", return_value=empty), \
self.assertRaises(NoMarketDataError): self.assertRaises(NoMarketDataError):
ohlcv.load_ohlcv("FAKE", "2026-01-01") ohlcv.load_ohlcv("FAKE", "2026-01-01")
# Nothing should have been written to the cache. # Nothing should have been written to the cache.
self.assertEqual(os.listdir(self._tmp), []) self.assertEqual(os.listdir(self._tmp), [])
# A second call must re-attempt the fetch (no poisoned cache served). # A second call must re-attempt the fetch (no poisoned cache served).
with mock.patch.object(ohlcv.yf, "download", return_value=empty) as dl2: with mock.patch.object(ohlcv.yf, "Ticker", return_value=empty) as dl2:
with self.assertRaises(NoMarketDataError): with self.assertRaises(NoMarketDataError):
ohlcv.load_ohlcv("FAKE", "2026-01-01") ohlcv.load_ohlcv("FAKE", "2026-01-01")
self.assertTrue(dl2.called) self.assertTrue(dl2.called)
@@ -102,4 +102,4 @@ def test_an_unreachable_yahoo_is_not_reported_as_a_symbol_without_insider_data()
with mock.patch.object(fundamentals.yf, "Ticker", return_value=ticker), \ with mock.patch.object(fundamentals.yf, "Ticker", return_value=ticker), \
mock.patch.object(fundamentals, "vendor_reachable", return_value=False), \ mock.patch.object(fundamentals, "vendor_reachable", return_value=False), \
pytest.raises(VendorRateLimitError): pytest.raises(VendorRateLimitError):
fundamentals.get_insider_transactions("AAPL", curr_date="2026-09-21") fundamentals.get_insider_transactions("AAPL", as_of_date="2026-09-21")
+4 -2
View File
@@ -8,6 +8,7 @@ day (#1330).
from __future__ import annotations from __future__ import annotations
import os import os
from types import SimpleNamespace
import pandas as pd import pandas as pd
import pytest import pytest
@@ -35,7 +36,7 @@ def _write(tmp_path, name="AAPL-YFin-data.csv", age_seconds=0.0, last_date="2026
def _load(tmp_path, monkeypatch, curr_date, download): def _load(tmp_path, monkeypatch, curr_date, download):
monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
monkeypatch.setattr(ohlcv.pd.Timestamp, "today", staticmethod(lambda: NOW)) monkeypatch.setattr(ohlcv.pd.Timestamp, "today", staticmethod(lambda: NOW))
monkeypatch.setattr(ohlcv.yf, "download", download) monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace(history=download))
return ohlcv.load_ohlcv("AAPL", curr_date) return ohlcv.load_ohlcv("AAPL", curr_date)
@@ -98,7 +99,8 @@ def test_one_cache_file_per_symbol_across_days(tmp_path, monkeypatch):
monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)}) monkeypatch.setattr(ohlcv, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
frame = pd.DataFrame({"Date": pd.to_datetime(["2026-07-16", "2026-07-17"]), "Close": [1.0, 2.0]}) frame = pd.DataFrame({"Date": pd.to_datetime(["2026-07-16", "2026-07-17"]), "Close": [1.0, 2.0]})
downloads = [] downloads = []
monkeypatch.setattr(ohlcv.yf, "download", lambda *a, **k: downloads.append(1) or frame.set_index("Date")) monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace(
history=lambda *a, **k: downloads.append(1) or frame.set_index("Date")))
for day in ("2026-07-18 10:00", "2026-07-19 10:00", "2026-07-20 10:00"): for day in ("2026-07-18 10:00", "2026-07-19 10:00", "2026-07-20 10:00"):
now = pd.Timestamp(day) now = pd.Timestamp(day)
+4 -3
View File
@@ -14,6 +14,7 @@ anywhere counts as no data and the staleness check judges the rest.
from __future__ import annotations from __future__ import annotations
import os import os
from types import SimpleNamespace
import pandas as pd import pandas as pd
import pytest import pytest
@@ -105,7 +106,7 @@ def _run_load(monkeypatch, tmp_path, frame, curr_date):
def _fail_download(*a, **k): def _fail_download(*a, **k):
raise AssertionError("should use the seeded cache, not download") raise AssertionError("should use the seeded cache, not download")
monkeypatch.setattr(ohlcv.yf, "download", _fail_download) monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace(history=_fail_download))
return ohlcv.load_ohlcv("AAPL", curr_date) return ohlcv.load_ohlcv("AAPL", curr_date)
@@ -198,8 +199,8 @@ def test_the_snapshot_does_not_present_a_filled_price_as_reported(monkeypatch, t
cache = tmp_path / "AAPL-YFin-data.csv" cache = tmp_path / "AAPL-YFin-data.csv"
cache.write_text(frame.to_csv(index=False)) cache.write_text(frame.to_csv(index=False))
_stamp(cache, today) _stamp(cache, today)
monkeypatch.setattr(ohlcv.yf, "download", lambda *a, **k: (_ for _ in ()).throw( monkeypatch.setattr(ohlcv.yf, "Ticker", lambda symbol: SimpleNamespace(
AssertionError("should read the seeded cache"))) history=lambda *a, **k: (_ for _ in ()).throw(AssertionError("should read the seeded cache"))))
out = snapshot.build_verified_market_snapshot("AAPL", "2026-05-08", 3) out = snapshot.build_verified_market_snapshot("AAPL", "2026-05-08", 3)
+4 -5
View File
@@ -76,9 +76,9 @@ def test_load_reads_a_valid_file(tmp_path):
# --- threading through the graph -------------------------------------------- # --- threading through the graph --------------------------------------------
def _bare_graph(tmp_path): def _bare_graph(tmp_path):
from tradingagents.decision_log import TradingMemoryLog
from tradingagents.graph.propagation import Propagator from tradingagents.graph.propagation import Propagator
from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.memory import TradingMemoryLog
graph = object.__new__(TradingAgentsGraph) graph = object.__new__(TradingAgentsGraph)
graph.config = {"memory_log_path": str(tmp_path / "m.md"), "max_debate_rounds": 1, graph.config = {"memory_log_path": str(tmp_path / "m.md"), "max_debate_rounds": 1,
@@ -139,11 +139,11 @@ def test_decision_agents_see_the_portfolio(module, factory, monkeypatch):
"news_report": "N", "fundamentals_report": "F", "investment_plan": "P", "news_report": "N", "fundamentals_report": "F", "investment_plan": "P",
"trader_investment_plan": "T", "past_context": "", "trader_investment_plan": "T", "past_context": "",
"portfolio_context": "PORTFOLIO_BLOCK_MARKER", "portfolio_context": "PORTFOLIO_BLOCK_MARKER",
"investment_debate_state": {"history": "", "judge_decision": "", "count": 0}, "investment_debate_state": {"history": "", "count": 0},
"risk_debate_state": {"history": "", "latest_speaker": "", "count": 0, "risk_debate_state": {"history": "", "latest_speaker": "", "count": 0,
"aggressive_history": "", "conservative_history": "", "neutral_history": "", "aggressive_history": "", "conservative_history": "", "neutral_history": "",
"current_aggressive_response": "", "current_conservative_response": "", "current_aggressive_response": "", "current_conservative_response": "",
"current_neutral_response": "", "judge_decision": ""}, "current_neutral_response": ""},
} }
node = getattr(mod, factory)(_LLM()) node = getattr(mod, factory)(_LLM())
node(state) node(state)
@@ -174,9 +174,8 @@ def test_completed_run_clears_the_checkpoint_it_wrote(tmp_path, monkeypatch):
graph.debug = False graph.debug = False
graph._resuming = False graph._resuming = False
graph.propagator.get_graph_args = lambda callbacks=None: {} graph.propagator.get_graph_args = lambda callbacks=None: {}
graph.process_signal = lambda d: "Hold"
graph._log_state = lambda *a, **k: None graph._log_state = lambda *a, **k: None
graph.graph = type("G", (), {"invoke": lambda self, i, **k: {"final_trade_decision": "Rating: Hold\n\nx"}})() graph.graph = type("G", (), {"invoke": lambda self, i, **k: {"final_trade_decision": "Rating: Hold\n\nx", "final_rating": "Hold"}})()
book = PortfolioContext.model_validate(HOLDING) book = PortfolioContext.model_validate(HOLDING)
written = graph._run_signature("stock", book) # what begin_checkpoint keys on written = graph._run_signature("stock", book) # what begin_checkpoint keys on
+2 -2
View File
@@ -74,11 +74,11 @@ def test_a_report_that_was_never_produced_says_so(module, factory):
"market_report": "RSI 61, price 178.", "sentiment_report": "", "news_report": "", "market_report": "RSI 61, price 178.", "sentiment_report": "", "news_report": "",
"fundamentals_report": "", "investment_plan": "P", "trader_investment_plan": "T", "fundamentals_report": "", "investment_plan": "P", "trader_investment_plan": "T",
"investment_debate_state": {"bull_history": "", "bear_history": "", "history": "", "investment_debate_state": {"bull_history": "", "bear_history": "", "history": "",
"current_response": "", "judge_decision": "", "count": 0}, "current_response": "", "count": 0},
"risk_debate_state": {"history": "", "latest_speaker": "", "count": 0, "risk_debate_state": {"history": "", "latest_speaker": "", "count": 0,
"aggressive_history": "", "conservative_history": "", "neutral_history": "", "aggressive_history": "", "conservative_history": "", "neutral_history": "",
"current_aggressive_response": "", "current_conservative_response": "", "current_aggressive_response": "", "current_conservative_response": "",
"current_neutral_response": "", "judge_decision": ""}, "current_neutral_response": ""},
} }
getattr(mod, factory)(_LLM())(state) getattr(mod, factory)(_LLM())(state)
+46 -12
View File
@@ -64,7 +64,7 @@ def test_the_scale_quoted_in_a_prompt_does_not_become_the_rating():
@pytest.mark.unit @pytest.mark.unit
def test_the_memory_log_records_review_rather_than_a_tradeable_hold(tmp_path): def test_the_memory_log_records_review_rather_than_a_tradeable_hold(tmp_path):
from tradingagents.decision_log import TradingMemoryLog from tradingagents.memory import TradingMemoryLog
log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")}) log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")})
log.store_decision("NVDA", "2026-01-05", REFUSAL) log.store_decision("NVDA", "2026-01-05", REFUSAL)
@@ -76,7 +76,7 @@ def test_the_memory_log_records_review_rather_than_a_tradeable_hold(tmp_path):
@pytest.mark.unit @pytest.mark.unit
def test_the_signal_and_the_log_agree_on_the_same_decision(tmp_path): def test_the_signal_and_the_log_agree_on_the_same_decision(tmp_path):
from tradingagents.agents.rating import parse_rating from tradingagents.agents.rating import parse_rating
from tradingagents.decision_log import TradingMemoryLog from tradingagents.memory import TradingMemoryLog
log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")}) log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")})
for text in (INVERTED, REFUSAL, "**Rating**: Buy\n\nAccumulate."): for text in (INVERTED, REFUSAL, "**Rating**: Buy\n\nAccumulate."):
@@ -91,7 +91,7 @@ def test_the_signal_and_the_log_agree_on_the_same_decision(tmp_path):
def test_an_unscored_decision_is_left_out_of_the_backtest_figures(tmp_path): def test_an_unscored_decision_is_left_out_of_the_backtest_figures(tmp_path):
"""REVIEW has no direction, so it cannot count for or against the system.""" """REVIEW has no direction, so it cannot count for or against the system."""
from tradingagents.backtest import summarize from tradingagents.backtest import summarize
from tradingagents.decision_log import TradingMemoryLog from tradingagents.memory import TradingMemoryLog
log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")}) log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")})
log.store_decision("NVDA", "2026-01-05", "**Rating**: Buy\n\nx") log.store_decision("NVDA", "2026-01-05", "**Rating**: Buy\n\nx")
@@ -121,10 +121,6 @@ def test_the_cli_says_when_a_run_produced_no_usable_rating(monkeypatch, tmp_path
def record_decision(self, *a, **k): def record_decision(self, *a, **k):
pass pass
def process_signal(self, text):
from tradingagents.agents.rating import parse_rating
return parse_rating(text)
def get_graph_args(self, callbacks=None): def get_graph_args(self, callbacks=None):
return {} return {}
@@ -140,8 +136,8 @@ def test_the_cli_says_when_a_run_produced_no_usable_rating(monkeypatch, tmp_path
def end_checkpoint(self): def end_checkpoint(self):
pass pass
def stream(self, *a, **k): def stream_run(self, *a, **k):
yield {"messages": [], "final_trade_decision": REFUSAL} yield [], {"messages": [], "final_trade_decision": REFUSAL, "final_rating": RATING_REVIEW}
fake = _Graph() fake = _Graph()
fake.graph = fake fake.graph = fake
@@ -155,7 +151,7 @@ def test_the_cli_says_when_a_run_produced_no_usable_rating(monkeypatch, tmp_path
monkeypatch.setattr(m.console, "print", lambda *a, **k: printed.append(" ".join(str(x) for x in a))) monkeypatch.setattr(m.console, "print", lambda *a, **k: printed.append(" ".join(str(x) for x in a)))
monkeypatch.setattr(cli_run, "display_complete_report", lambda *a, **k: None) monkeypatch.setattr(cli_run, "display_complete_report", lambda *a, **k: None)
monkeypatch.setattr(m.typer, "prompt", lambda *a, **k: "N") monkeypatch.setattr(m.typer, "prompt", lambda *a, **k: "N")
monkeypatch.setattr(cli_run, "get_user_selections", lambda: { monkeypatch.setattr(cli_run, "get_user_selections", lambda flags=None: {
"ticker": "NVDA", "analysis_date": "2026-01-10", "ticker": "NVDA", "analysis_date": "2026-01-10",
"analysts": [AnalystType.MARKET], "asset_type": "stock", "analysts": [AnalystType.MARKET], "asset_type": "stock",
}) })
@@ -198,11 +194,11 @@ def test_a_decision_prompt_states_the_shape_of_its_answer(module, factory, must_
"news_report": "N", "fundamentals_report": "F", "investment_plan": "P", "news_report": "N", "fundamentals_report": "F", "investment_plan": "P",
"trader_investment_plan": "T", "past_context": "", "portfolio_context": "", "trader_investment_plan": "T", "past_context": "", "portfolio_context": "",
"investment_debate_state": {"bull_history": "b", "bear_history": "r", "history": "h", "investment_debate_state": {"bull_history": "b", "bear_history": "r", "history": "h",
"current_response": "", "judge_decision": "", "count": 2}, "current_response": "", "count": 2},
"risk_debate_state": {"history": "h", "latest_speaker": "", "count": 3, "risk_debate_state": {"history": "h", "latest_speaker": "", "count": 3,
"aggressive_history": "", "conservative_history": "", "neutral_history": "", "aggressive_history": "", "conservative_history": "", "neutral_history": "",
"current_aggressive_response": "", "current_conservative_response": "", "current_aggressive_response": "", "current_conservative_response": "",
"current_neutral_response": "", "judge_decision": ""}, "current_neutral_response": ""},
} }
getattr(mod, factory)(_LLM())(state) getattr(mod, factory)(_LLM())(state)
@@ -210,3 +206,41 @@ def test_a_decision_prompt_states_the_shape_of_its_answer(module, factory, must_
assert "## Output" in prompt, "no output-format section in the prompt" assert "## Output" in prompt, "no output-format section in the prompt"
section = prompt.split("## Output", 1)[1] section = prompt.split("## Output", 1)[1]
assert f"**{must_name}**" in section, section[:300] assert f"**{must_name}**" in section, section[:300]
@pytest.mark.unit
def test_a_state_without_the_typed_rating_reads_it_from_the_decision():
"""A run finished by an older version and resumed from its checkpoint has
no final_rating; every reader falls back the same way instead of one
raising and another reporting REVIEW."""
from tradingagents.agents.rating import run_rating
assert run_rating({"final_rating": "Hold", "final_trade_decision": "**Rating**: Buy"}) == "Hold"
assert run_rating({"final_trade_decision": "**Rating**: Sell\n\nExit."}) == "Sell"
assert run_rating({}) == RATING_REVIEW
@pytest.mark.unit
@pytest.mark.parametrize("quoted", [
"Street consensus rating: Buy (28 of 35 analysts).",
"Moody's affirmed the credit rating: Buy-side demand for the bonds stayed firm.",
"Operating margin: Sell-side estimates sit below guidance.",
])
def test_a_rating_the_text_quotes_does_not_replace_the_decision(quoted):
"""A free-text decision opens with its own rating line; a rating it quotes
as evidence, or a word merely ending in 'rating', is not the call."""
text = f"**Rating**: Hold\n\n**Investment Thesis**: {quoted} We wait for margins."
assert extract_rating(text) == "Hold"
@pytest.mark.unit
@pytest.mark.parametrize("quoted", [
"- Rating: Buy (Goldman Sachs, 12m target 180)",
"| Rating: Buy | Morgan Stanley |",
"> Rating: Buy, per the sell-side note",
"Street consensus rating: Buy",
"Consensus rating: Buy (28 of 35 analysts)",
])
def test_a_quoted_rating_in_a_list_table_or_quote_is_not_the_decision(quoted):
text = f"Our rating: Hold\n\nWhat others say:\n{quoted}\n\nWe wait for margins."
assert extract_rating(text) == "Hold"
+61 -5
View File
@@ -13,12 +13,19 @@ def _state():
return { return {
"market_report": "MKT", "market_report": "MKT",
"news_report": "NEWS", "news_report": "NEWS",
"investment_debate_state": {"judge_decision": "RM PLAN"}, "investment_debate_state": {"bull_history": "BULL"},
"investment_plan": "RM PLAN",
"trader_investment_plan": "TRADE", "trader_investment_plan": "TRADE",
"risk_debate_state": {"judge_decision": "PM DECISION"}, "risk_debate_state": {"neutral_history": "NEUTRAL"},
"final_trade_decision": "PM DECISION",
} }
SETTINGS = {"version": "0.5.2", "llm_provider": "openai", "deep_think_llm": "gpt-6-sol",
"quick_think_llm": "gpt-6-luna", "analysts": ["market", "news"], "max_debate_rounds": 1,
"max_risk_discuss_rounds": 2, "data_vendors": {"core_stock_apis": "yfinance"}}
@pytest.mark.unit @pytest.mark.unit
def test_write_report_tree_creates_files(tmp_path): def test_write_report_tree_creates_files(tmp_path):
out = write_report_tree(_state(), "AAPL", tmp_path) out = write_report_tree(_state(), "AAPL", tmp_path)
@@ -35,16 +42,65 @@ def test_write_report_tree_creates_files(tmp_path):
@pytest.mark.unit @pytest.mark.unit
def test_save_reports_explicit_path(tmp_path): def test_save_reports_explicit_path(tmp_path):
# Unbound: with an explicit save_path, the method doesn't touch self/config. graph = SimpleNamespace(run_settings=lambda: SETTINGS)
out = TradingAgentsGraph.save_reports(None, _state(), "AAPL", save_path=tmp_path) out = TradingAgentsGraph.save_reports(graph, _state(), "AAPL", save_path=tmp_path)
assert (tmp_path / "complete_report.md").exists() assert (tmp_path / "complete_report.md").exists()
assert out == tmp_path / "complete_report.md" assert out == tmp_path / "complete_report.md"
@pytest.mark.unit @pytest.mark.unit
def test_save_reports_defaults_under_results_dir(tmp_path): def test_save_reports_defaults_under_results_dir(tmp_path):
mock_self = SimpleNamespace(config={"results_dir": str(tmp_path)}) mock_self = SimpleNamespace(config={"results_dir": str(tmp_path)}, run_settings=lambda: SETTINGS)
out = TradingAgentsGraph.save_reports(mock_self, _state(), "AAPL") out = TradingAgentsGraph.save_reports(mock_self, _state(), "AAPL")
assert out.exists() assert out.exists()
assert out.parent.parent.name == "reports" # results_dir/reports/AAPL_<stamp>/... assert out.parent.parent.name == "reports" # results_dir/reports/AAPL_<stamp>/...
assert out.parent.name.startswith("AAPL_") assert out.parent.name.startswith("AAPL_")
@pytest.mark.unit
def test_the_report_names_the_analysis_date_and_what_produced_it(tmp_path):
state = dict(_state(), trade_date="2026-09-23")
header = write_report_tree(state, "NVDA", tmp_path, settings=SETTINGS).read_text().split("## ")[0]
assert "Analysis date: 2026-09-23" in header
assert "TradingAgents 0.5.2" in header
assert "openai, deep gpt-6-sol, quick gpt-6-luna" in header
assert "Analysts: market, news" in header
assert "research debate rounds 1, risk debate rounds 2" in header
assert "core_stock_apis yfinance" in header
@pytest.mark.unit
def test_run_settings_record_the_run_without_endpoints_or_paths():
from tradingagents.graph.trading_graph import TradingAgentsGraph
graph = object.__new__(TradingAgentsGraph)
graph.selected_analysts = ("market", "news")
graph.config = {"llm_provider": "openai", "deep_think_llm": "gpt-6-sol", "quick_think_llm": "gpt-6-luna",
"max_debate_rounds": 1, "max_risk_discuss_rounds": 1, "output_language": "English",
"data_vendors": {"core_stock_apis": "yfinance"}, "tool_vendors": {},
"backend_url": "https://user:secret@relay.example/v1", "results_dir": "/home/me/results"}
settings = graph.run_settings()
assert settings["analysts"] == ["market", "news"]
assert settings["deep_think_llm"] == "gpt-6-sol"
assert "secret" not in str(settings) and "/home/me" not in str(settings)
@pytest.mark.unit
def test_a_partial_settings_dict_still_writes_the_report(tmp_path):
out = write_report_tree(_state(), "AAPL", tmp_path, settings={"llm_provider": "openai"})
assert "openai" in out.read_text()
@pytest.mark.unit
def test_run_settings_name_the_version_of_the_running_code():
import tradingagents
from tradingagents.graph.trading_graph import TradingAgentsGraph
graph = object.__new__(TradingAgentsGraph)
graph.selected_analysts, graph.config = ("market",), {}
assert graph.run_settings()["version"] == tradingagents.__version__
+5 -15
View File
@@ -221,22 +221,12 @@ def test_a_period_reported_under_two_tags_takes_the_preferred_one_not_both():
@pytest.mark.unit @pytest.mark.unit
def test_the_default_identification_tracks_the_installed_version(monkeypatch): def test_the_default_identification_names_the_package_version(monkeypatch):
"""A release should identify itself, not a version frozen in the source.""" """SEC asks automated clients to identify themselves; a release names its own version."""
import tradingagents
monkeypatch.delenv("SEC_EDGAR_USER_AGENT", raising=False) monkeypatch.delenv("SEC_EDGAR_USER_AGENT", raising=False)
monkeypatch.setattr(sec_edgar.metadata, "version", lambda name: "9.9.9") assert sec_edgar._user_agent() == f"TradingAgents/{tradingagents.__version__} (contact@example.com)"
assert sec_edgar._user_agent() == "TradingAgents/9.9.9 (contact@example.com)"
@pytest.mark.unit
def test_an_uninstalled_checkout_still_identifies_itself(monkeypatch):
monkeypatch.delenv("SEC_EDGAR_USER_AGENT", raising=False)
def _missing(name):
raise sec_edgar.metadata.PackageNotFoundError(name)
monkeypatch.setattr(sec_edgar.metadata, "version", _missing)
assert "@" in sec_edgar._user_agent()
@pytest.mark.unit @pytest.mark.unit
-17
View File
@@ -75,20 +75,3 @@ class TestExtractRating:
# The memory log tags an unreadable decision REVIEW, never a tradeable rating. # The memory log tags an unreadable decision REVIEW, never a tradeable rating.
assert parse_rating("No rating here.") == RATING_REVIEW assert parse_rating("No rating here.") == RATING_REVIEW
assert parse_rating("No rating here.", default="Underweight") == "Underweight" 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)
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"
+1 -1
View File
@@ -72,7 +72,7 @@ def test_research_manager_prompt_states_constraint():
"company_of_interest": "NVDA", "company_of_interest": "NVDA",
"investment_debate_state": { "investment_debate_state": {
"history": "h", "bull_history": "b", "bear_history": "r", "history": "h", "bull_history": "b", "bear_history": "r",
"current_response": "", "judge_decision": "", "count": 1, "current_response": "", "count": 1,
}, },
}) })
assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"]) assert NO_EXTERNAL_TOOLS in _prompt_text(captured["prompt"])
-1
View File
@@ -292,7 +292,6 @@ def _make_rm_state():
"bull_history": "Bull says...", "bull_history": "Bull says...",
"bear_history": "Bear says...", "bear_history": "Bear says...",
"current_response": "", "current_response": "",
"judge_decision": "",
"count": 1, "count": 1,
}, },
} }
+13 -1
View File
@@ -1,4 +1,4 @@
"""The suite runs the same on any machine: no test reaches the network.""" """The suite runs the same on any machine: no test reaches the network or the user's files."""
import socket import socket
@@ -15,3 +15,15 @@ def test_a_test_cannot_reach_the_network(connect):
machine it runs on; conftest refuses the connection instead.""" machine it runs on; conftest refuses the connection instead."""
with pytest.raises(OSError, match="reach the network"): with pytest.raises(OSError, match="reach the network"):
connect() connect()
@pytest.mark.unit
def test_a_test_saves_cli_selections_to_its_own_directory(tmp_path):
"""The CLI remembers the last run's selections in the user's home; a test
that runs the selection flow would otherwise overwrite them."""
from cli import prefs
prefs.save_last_run({"analysts": ["market"]})
assert prefs._PREFS_PATH.is_relative_to(tmp_path)
assert prefs._PREFS_PATH.exists()
+1 -1
View File
@@ -10,7 +10,7 @@ import pandas as pd
import tradingagents.agents.context as au import tradingagents.agents.context as au
import tradingagents.dataflows.vendors.yahoo.market as yahoo_market import tradingagents.dataflows.vendors.yahoo.market as yahoo_market
import tradingagents.dataflows.vendors.yahoo.news as ynews import tradingagents.dataflows.vendors.yahoo.news as ynews
from tradingagents.graph import settlement from tradingagents.memory import settlement
def test_identity_lookup_normalizes_symbol(monkeypatch): def test_identity_lookup_normalizes_symbol(monkeypatch):
+5 -5
View File
@@ -70,14 +70,14 @@ def test_alpha_vantage_insider_filings_after_the_date_are_dropped():
@pytest.mark.unit @pytest.mark.unit
def test_polymarket_withholds_live_odds_from_a_historical_run(): def test_polymarket_withholds_live_odds_from_a_historical_run():
with mock.patch.object(polymarket, "_request", side_effect=AssertionError("must not fetch")): with mock.patch.object(polymarket, "_request", side_effect=AssertionError("must not fetch")):
out = polymarket.get_prediction_markets("Fed rate cut", curr_date="2025-06-01") out = polymarket.get_prediction_markets("Fed rate cut", as_of_date="2025-06-01")
assert "withheld" in out assert "withheld" in out
@pytest.mark.unit @pytest.mark.unit
def test_polymarket_serves_a_current_run(): def test_polymarket_serves_a_current_run():
with mock.patch.object(polymarket, "_request", return_value={"events": []}) as req: with mock.patch.object(polymarket, "_request", return_value={"events": []}) as req:
polymarket.get_prediction_markets("Fed rate cut", curr_date=polymarket.get_current_date()) polymarket.get_prediction_markets("Fed rate cut", as_of_date=polymarket.get_current_date())
req.assert_called_once() req.assert_called_once()
@@ -103,7 +103,7 @@ def test_a_historical_run_is_told_the_identity_is_current(monkeypatch):
identity = {"company_name": "Example Corp", "sector": "Technology", identity = {"company_name": "Example Corp", "sector": "Technology",
"industry": "Software", "exchange": "NMS"} "industry": "Software", "exchange": "NMS"}
historical = build_instrument_context("EXMP", "stock", identity, curr_date="2024-03-14") historical = build_instrument_context("EXMP", "stock", identity, trade_date="2024-03-14")
assert "Example Corp" in historical assert "Example Corp" in historical
assert "2024-03-14" in historical and "today" in historical.lower() assert "2024-03-14" in historical and "today" in historical.lower()
@@ -114,7 +114,7 @@ def test_a_current_run_is_not_cluttered_with_a_vintage_note(monkeypatch):
from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.date_window import get_current_date
today = build_instrument_context("EXMP", "stock", {"company_name": "Example Corp"}, today = build_instrument_context("EXMP", "stock", {"company_name": "Example Corp"},
curr_date=get_current_date()) trade_date=get_current_date())
assert "Example Corp" in today assert "Example Corp" in today
assert "resolved today" not in today.lower() assert "resolved today" not in today.lower()
@@ -297,7 +297,7 @@ def test_an_unavailable_notice_names_no_date_after_the_run():
coverage_gap([pd.Timestamp(today, tz="UTC")], "2025-01-01", "2025-01-07", "Feed", "news"), coverage_gap([pd.Timestamp(today, tz="UTC")], "2025-01-01", "2025-01-07", "Feed", "news"),
withhold_live_profile("2025-01-07", "AAPL"), withhold_live_profile("2025-01-07", "AAPL"),
_yf_insider(_insider_frame(today), "2025-01-07"), _yf_insider(_insider_frame(today), "2025-01-07"),
build_instrument_context("EXMP", "stock", {"company_name": "Example"}, curr_date="2025-01-07"), build_instrument_context("EXMP", "stock", {"company_name": "Example"}, trade_date="2025-01-07"),
] ]
for notice in notices: for notice in notices:
assert _dates_after(notice, "2025-01-07") == [], notice assert _dates_after(notice, "2025-01-07") == [], notice
+18
View File
@@ -0,0 +1,18 @@
"""The package version has one source, tradingagents.__version__, which the build reads."""
import re
from pathlib import Path
import pytest
import tradingagents
PYPROJECT = (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text(encoding="utf-8")
@pytest.mark.unit
def test_the_build_reads_the_version_from_the_package():
assert re.search(r'^dynamic = \["version"\]', PYPROJECT, re.M)
assert 'version = {attr = "tradingagents.__version__"}' in PYPROJECT
assert not re.search(r'^version = "', PYPROJECT, re.M)
assert re.fullmatch(r"\d+\.\d+\.\d+(\.dev\d+|rc\d+)?", tradingagents.__version__)
+130
View File
@@ -0,0 +1,130 @@
"""A Yahoo rate limit is retried, then reported as a rate limit.
When the limit outlasts the retries, the agent must hear that the vendor is
throttled. "The symbol may be invalid or delisted" is a claim about the company
that nobody checked, and an exception out of a tool ends the run.
"""
import pandas as pd
import pytest
import yfinance as yf
from yfinance.data import YfData
from yfinance.exceptions import YFRateLimitError
from tradingagents.agents.tools import (
get_global_news,
get_indicators,
get_insider_transactions,
get_news,
get_stock_data,
get_verified_market_snapshot,
)
from tradingagents.dataflows import router
from tradingagents.dataflows.config import set_config
from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError
from tradingagents.dataflows.vendors.yahoo import fundamentals, ohlcv
DAY = "2026-09-18"
def _rate_limited(*args, **kwargs):
raise YFRateLimitError()
@pytest.fixture
def yahoo(monkeypatch, tmp_path):
set_config({"data_cache_dir": str(tmp_path)})
monkeypatch.setattr(ohlcv.time, "sleep", lambda seconds: None)
for module in (ohlcv, fundamentals):
monkeypatch.setattr(module, "vendor_reachable", lambda url: True)
return monkeypatch
@pytest.mark.unit
@pytest.mark.parametrize("tool, args", [
pytest.param(get_stock_data, ("AAPL", "2026-09-10", DAY), id="stock_data"),
pytest.param(get_indicators, ("AAPL", "rsi", DAY, 5), id="indicators"),
pytest.param(get_verified_market_snapshot, ("AAPL", DAY), id="snapshot"),
pytest.param(get_news, ("AAPL", "2026-09-10", DAY), id="news"),
pytest.param(get_global_news, (DAY, 7, 5), id="global_news"),
pytest.param(get_insider_transactions, ("AAPL",), id="insider"),
])
def test_a_rate_limit_that_outlasts_the_retries_is_reported_as_one(yahoo, tool, args):
for name in ("history", "get_news"):
yahoo.setattr(yf.Ticker, name, _rate_limited)
yahoo.setattr(yf.Ticker, "insider_transactions", property(_rate_limited))
yahoo.setattr(yf, "Search", _rate_limited)
out = tool.func(*args, trade_date=DAY)
assert out.startswith("DATA_UNAVAILABLE"), out
assert "delisted" not in out
@pytest.mark.unit
def test_the_indicator_path_retries_a_rate_limit(yahoo):
"""The prices behind every indicator were fetched with ``yf.download``, which
returns an empty frame for a 429, so they were never retried."""
bar = pd.DataFrame({"Open": [1.0], "High": [1.0], "Low": [1.0], "Close": [1.0],
"Volume": [100]}, index=pd.DatetimeIndex([DAY], name="Date"))
answers = [YFRateLimitError(), bar]
def history(self, **kwargs):
answer = answers.pop(0)
if isinstance(answer, Exception):
raise answer
return answer
yahoo.setattr(yf.Ticker, "history", history)
assert ohlcv.load_ohlcv("AAPL", DAY)["Close"].tolist() == [1.0]
@pytest.mark.unit
def test_yfinance_raises_the_rate_limit_from_history(yahoo):
yahoo.setattr(YfData, "_make_request", _rate_limited)
with pytest.raises(VendorRateLimitError, match="rate limited"):
ohlcv.load_ohlcv("AAPL", DAY)
@pytest.mark.unit
def test_fundamentals_ask_a_new_ticker_after_a_rate_limit(yahoo):
"""A Ticker keeps a failed ``info`` fetch as done, so asking the same one
again reads an empty profile, which looks like a symbol with no data."""
class Ticker:
def __init__(self, symbol):
self.fetched = False
@property
def info(self):
if self.fetched:
return {}
self.fetched = True
raise YFRateLimitError()
yahoo.setattr(yf, "Ticker", Ticker)
out = router.route_to_vendor("get_fundamentals", "AAPL", None)
assert out.startswith("DATA_UNAVAILABLE"), out
@pytest.mark.unit
def test_another_price_fetch_error_still_tells_an_outage_from_an_unknown_symbol(yahoo):
"""``Ticker.history`` lets some errors through that ``yf.download`` turned
into an empty frame. Whether Yahoo answers still decides which one it is."""
def refused(self, **kwargs):
raise ConnectionError("curl: (7) Failed to connect to query2.finance.yahoo.com")
yahoo.setattr(yf.Ticker, "history", refused)
yahoo.setattr(ohlcv, "vendor_reachable", lambda url: False)
with pytest.raises(VendorRateLimitError, match="unreachable"):
ohlcv.load_ohlcv("AAPL", DAY)
yahoo.setattr(ohlcv, "vendor_reachable", lambda url: True)
with pytest.raises(NoMarketDataError):
ohlcv.load_ohlcv("AAPL", DAY)
+4
View File
@@ -1,3 +1,7 @@
"""TradingAgents: multi-agent LLM financial trading framework."""
__version__ = "0.5.2.dev0"
from dotenv import find_dotenv, load_dotenv from dotenv import find_dotenv, load_dotenv
# Load .env at package import so DEFAULT_CONFIG's env-var overlay and every LLM # Load .env at package import so DEFAULT_CONFIG's env-var overlay and every LLM
-2
View File
@@ -2,7 +2,6 @@ from .analysts.fundamentals_analyst import create_fundamentals_analyst
from .analysts.market_analyst import create_market_analyst from .analysts.market_analyst import create_market_analyst
from .analysts.news_analyst import create_news_analyst from .analysts.news_analyst import create_news_analyst
from .analysts.sentiment_analyst import create_sentiment_analyst from .analysts.sentiment_analyst import create_sentiment_analyst
from .context import create_msg_delete
from .managers.portfolio_manager import create_portfolio_manager from .managers.portfolio_manager import create_portfolio_manager
from .managers.research_manager import create_research_manager from .managers.research_manager import create_research_manager
from .researchers.bear_researcher import create_bear_researcher from .researchers.bear_researcher import create_bear_researcher
@@ -15,7 +14,6 @@ from .trader.trader import create_trader
__all__ = [ __all__ = [
"AgentState", "AgentState",
"create_msg_delete",
"InvestDebateState", "InvestDebateState",
"RiskDebateState", "RiskDebateState",
"create_bear_researcher", "create_bear_researcher",
+3 -35
View File
@@ -6,8 +6,6 @@ import logging
from collections.abc import Mapping from collections.abc import Mapping
from typing import Any from typing import Any
from langchain_core.messages import HumanMessage, RemoveMessage
from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.date_window import get_current_date
from tradingagents.dataflows.vendors.yahoo.fundamentals import get_company_profile from tradingagents.dataflows.vendors.yahoo.fundamentals import get_company_profile
@@ -100,7 +98,7 @@ def build_instrument_context(
ticker: str, ticker: str,
asset_type: str = "stock", asset_type: str = "stock",
identity: Mapping[str, str] | None = None, identity: Mapping[str, str] | None = None,
curr_date: str | None = None, trade_date: str | None = None,
) -> str: ) -> str:
"""Describe the exact instrument so agents preserve identity and ticker. """Describe the exact instrument so agents preserve identity and ticker.
@@ -144,10 +142,10 @@ def build_instrument_context(
"result explicitly disproves this resolved identity." "result explicitly disproves this resolved identity."
) )
today = get_current_date() today = get_current_date()
if curr_date and str(curr_date) < today: if trade_date and str(trade_date) < today:
context += ( context += (
f" This identity is how the vendor describes the instrument today, " f" This identity is how the vendor describes the instrument today, "
f"not necessarily on {curr_date}: a name or classification changed " f"not necessarily on {trade_date}: a name or classification changed "
f"since then would read as the current one." f"since then would read as the current one."
) )
@@ -206,33 +204,3 @@ def get_portfolio_context_from_state(state: Mapping[str, Any]) -> str:
"holdings or cash, so do not assume a flat book; give direction and " "holdings or cash, so do not assume a flat book; give direction and "
"sizing guidance in terms the caller can apply to their own position." "sizing guidance in terms the caller can apply to their own position."
) )
def create_msg_delete():
def delete_messages(state):
"""Clear messages and add a context-anchored placeholder.
The placeholder must not be a bare ``"Continue"``: some
OpenAI-compatible providers interpret that literally as the user task
and produce output about the word "continue" instead of analysing the
instrument (#888). Anchoring it to the resolved instrument context and
date keeps the next analyst on-task even if the provider treats the
placeholder as a standalone request.
"""
messages = state["messages"]
removal_operations = [RemoveMessage(id=m.id) for m in messages]
instrument_context = get_instrument_context_from_state(state)
trade_date = state.get("trade_date", "the requested date")
placeholder = HumanMessage(
content=(
f"Proceed with your assigned analysis for this workflow. "
f"{instrument_context} The analysis date is {trade_date}."
)
)
return {"messages": removal_operations + [placeholder]}
return delete_messages
@@ -1,11 +1,11 @@
"""Portfolio Manager: synthesises the risk-analyst debate into the final decision. """Portfolio Manager: synthesises the risk-analyst debate into the final decision.
Uses LangChain's ``with_structured_output`` so the LLM produces a typed Uses LangChain's ``with_structured_output`` so the LLM produces a typed
``PortfolioDecision`` directly, in a single call. The result is rendered ``PortfolioDecision`` directly, in a single call. Its rating is the run's
back to markdown for storage in ``final_trade_decision`` so memory log, ``final_rating``, and the decision is rendered to markdown as
CLI display, and saved reports continue to consume the same shape they do ``final_trade_decision`` for the memory log, CLI display and saved reports.
today. When a provider does not expose structured output, the agent falls When a provider does not expose structured output, the agent falls back to
back gracefully to free-text generation. free-text generation and the rating is read from that text.
""" """
from __future__ import annotations from __future__ import annotations
@@ -15,12 +15,9 @@ from tradingagents.agents.context import (
get_language_instruction, get_language_instruction,
get_portfolio_context_from_state, get_portfolio_context_from_state,
) )
from tradingagents.agents.rating import parse_rating
from tradingagents.agents.schemas import PortfolioDecision, render_pm_decision from tradingagents.agents.schemas import PortfolioDecision, render_pm_decision
from tradingagents.agents.structured import ( from tradingagents.agents.structured import NO_EXTERNAL_TOOLS, bind_structured, invoke_structured
NO_EXTERNAL_TOOLS,
bind_structured,
invoke_structured_or_freetext,
)
def create_portfolio_manager(llm): def create_portfolio_manager(llm):
@@ -78,16 +75,17 @@ Write these sections, in this order, starting with the rating on its own line:
{NO_EXTERNAL_TOOLS}{get_language_instruction()}""" {NO_EXTERNAL_TOOLS}{get_language_instruction()}"""
final_trade_decision = invoke_structured_or_freetext( # The typed rating is the decision; the rendered text only carries it.
structured_llm, # Read back from text, a rating the thesis quotes could replace it.
llm, decision = invoke_structured(structured_llm, prompt, "Portfolio Manager")
prompt, if decision is not None:
render_pm_decision, final_trade_decision = render_pm_decision(decision)
"Portfolio Manager", final_rating = decision.rating.value
) else:
final_trade_decision = llm.invoke(prompt).content
final_rating = parse_rating(final_trade_decision)
new_risk_debate_state = { new_risk_debate_state = {
"judge_decision": final_trade_decision,
"history": risk_debate_state["history"], "history": risk_debate_state["history"],
"aggressive_history": risk_debate_state["aggressive_history"], "aggressive_history": risk_debate_state["aggressive_history"],
"conservative_history": risk_debate_state["conservative_history"], "conservative_history": risk_debate_state["conservative_history"],
@@ -102,6 +100,7 @@ Write these sections, in this order, starting with the rating on its own line:
return { return {
"risk_debate_state": new_risk_debate_state, "risk_debate_state": new_risk_debate_state,
"final_trade_decision": final_trade_decision, "final_trade_decision": final_trade_decision,
"final_rating": final_rating,
} }
return portfolio_manager_node return portfolio_manager_node
@@ -59,7 +59,6 @@ Write these sections, in this order, starting with the recommendation on its own
) )
new_investment_debate_state = { new_investment_debate_state = {
"judge_decision": investment_plan,
"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", ""),
"bull_history": investment_debate_state.get("bull_history", ""), "bull_history": investment_debate_state.get("bull_history", ""),
+37 -13
View File
@@ -2,8 +2,7 @@
The same five-tier scale (Buy, Overweight, Hold, Underweight, Sell) is used by: The same five-tier scale (Buy, Overweight, Hold, Underweight, Sell) is used by:
- The Research Manager (investment plan recommendation) - The Research Manager (investment plan recommendation)
- The Portfolio Manager (final position decision) - The Portfolio Manager (final position decision; its free-text fallback is read here)
- The signal processor (rating extracted for downstream consumers)
- 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.
@@ -33,10 +32,20 @@ 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 any dash or colon a model writes as the separator. # bold wrappers and any dash or colon a model writes as the separator. "rating"
_RATING_LABEL_RE = re.compile(r"rating\b[^:\-\u2010-\u2015]*[:\-\u2010-\u2015][\s*]*(\w+)", # must start a word, so "Operating margin: Sell-side" is not a label.
_RATING_LABEL_RE = re.compile(r"(?<![a-z])rating\b[^:\-\u2010-\u2015]*[:\-\u2010-\u2015][\s*]*(\w+)",
re.IGNORECASE) re.IGNORECASE)
# The same label opening its own line ("**Rating**: X", "## Final Rating - X",
# "Our rating: X"): the shape the Portfolio Manager is asked to write its
# decision in. Only emphasis and heading marks may precede it, so a list item,
# table row or blockquote quoting someone else's rating is not one.
_RATING_LINE_RE = re.compile(
r"[\s*_#]*(?:\w+\s+)?rating[^\w:\-\u2010-\u2015]*[:\-\u2010-\u2015][\s*]*(\w+)",
re.IGNORECASE,
)
# A line presenting the scale rather than a decision ("Rating Scale: Buy, ..."). # A line presenting the scale rather than a decision ("Rating Scale: Buy, ...").
_RATING_SCALE_RE = re.compile(r"rating\s*(scale|options|legend)", re.IGNORECASE) _RATING_SCALE_RE = re.compile(r"rating\s*(scale|options|legend)", re.IGNORECASE)
@@ -51,25 +60,31 @@ def extract_rating(text: str) -> str | None:
Two-pass strategy on the NFKC-normalized text (so fullwidth punctuation like Two-pass strategy on the NFKC-normalized text (so fullwidth punctuation like
``Rating:Overweight`` is matched the same as ASCII): ``Rating:Overweight`` is matched the same as ASCII):
1. An explicit "Rating: X" label (tolerant of markdown bold). 1. An explicit "Rating: X" label (tolerant of markdown bold): the first one
2. The first standalone 5-tier rating word found anywhere. opening its own line, else the last one anywhere.
2. A single 5-tier rating word, when the text names only one.
""" """
if not text: if not text:
return None return None
norm = unicodedata.normalize("NFKC", text) norm = unicodedata.normalize("NFKC", text)
# The labelled rating, taking the last one written: a decision states its # A decision is asked to open with its rating on its own line, so the first
# rating after discussing the alternatives. Lines presenting the scale # such line is the call; later ones may quote someone else's ("Consensus
# itself are a legend the model echoed, not a call. # rating: Buy"). Without one, the last label anywhere wins: prose states its
labelled = None # rating after discussing the alternatives. Lines presenting the scale itself
# are a legend the model echoed, not a call.
on_own_line = anywhere = None
for line in norm.splitlines(): for line in norm.splitlines():
if _RATING_SCALE_RE.search(line): if _RATING_SCALE_RE.search(line):
continue continue
m = _RATING_LINE_RE.match(line)
if on_own_line is None and m and m.group(1).lower() in _RATING_SET:
on_own_line = m.group(1).capitalize()
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:
labelled = m.group(1).capitalize() anywhere = m.group(1).capitalize()
if labelled: if on_own_line or anywhere:
return labelled return on_own_line or anywhere
# No label. A single rating word in the text is the call; several are an # No label. A single rating word in the text is the call; several are an
# argument, and picking one of them reports a direction nobody decided -- # argument, and picking one of them reports a direction nobody decided --
@@ -88,6 +103,15 @@ def parse_rating(text: str, default: str = RATING_REVIEW) -> str:
return rating if rating is not None else default return rating if rating is not None else default
def run_rating(final_state: dict) -> str:
"""A finished run's rating: the Portfolio Manager's own, else read from its decision.
The fallback serves a state without ``final_rating``, such as a run an older
version completed and a checkpoint hands back unchanged.
"""
return final_state.get("final_rating") or parse_rating(final_state.get("final_trade_decision", ""))
def is_review(signal: str) -> bool: def is_review(signal: str) -> bool:
"""Whether a signal is the non-tradeable REVIEW sentinel (#1170).""" """Whether a signal is the non-tradeable REVIEW sentinel (#1170)."""
return signal == RATING_REVIEW return signal == RATING_REVIEW
+20 -25
View File
@@ -8,70 +8,65 @@ from typing_extensions import TypedDict
class InvestDebateState(TypedDict): class InvestDebateState(TypedDict):
bull_history: Annotated[ bull_history: Annotated[
str, "Bullish Conversation history" str, "Bullish Conversation history"
] # Bullish Conversation history ]
bear_history: Annotated[ bear_history: Annotated[
str, "Bearish Conversation history" str, "Bearish Conversation history"
] # Bullish Conversation history ]
history: Annotated[str, "Conversation history"] # Conversation history history: Annotated[str, "Conversation history"]
current_response: Annotated[str, "Latest response"] # Last response current_response: Annotated[str, "Latest response"]
judge_decision: Annotated[str, "Final judge decision"] # Last response count: Annotated[int, "Length of the current conversation"]
count: Annotated[int, "Length of the current conversation"] # Conversation length
# Risk management team state # Risk management team state
class RiskDebateState(TypedDict): class RiskDebateState(TypedDict):
aggressive_history: Annotated[ aggressive_history: Annotated[
str, "Aggressive Agent's Conversation history" str, "Aggressive Agent's Conversation history"
] # Conversation history ]
conservative_history: Annotated[ conservative_history: Annotated[
str, "Conservative Agent's Conversation history" str, "Conservative Agent's Conversation history"
] # Conversation history ]
neutral_history: Annotated[ neutral_history: Annotated[
str, "Neutral Agent's Conversation history" str, "Neutral Agent's Conversation history"
] # Conversation history ]
history: Annotated[str, "Conversation history"] # Conversation history history: Annotated[str, "Conversation history"]
latest_speaker: Annotated[str, "Analyst that spoke last"] latest_speaker: Annotated[str, "Analyst that spoke last"]
current_aggressive_response: Annotated[ current_aggressive_response: Annotated[
str, "Latest response by the aggressive analyst" str, "Latest response by the aggressive analyst"
] # Last response ]
current_conservative_response: Annotated[ current_conservative_response: Annotated[
str, "Latest response by the conservative analyst" str, "Latest response by the conservative analyst"
] # Last response ]
current_neutral_response: Annotated[ current_neutral_response: Annotated[
str, "Latest response by the neutral analyst" str, "Latest response by the neutral analyst"
] # Last response ]
judge_decision: Annotated[str, "Judge's decision"] count: Annotated[int, "Length of the current conversation"]
count: Annotated[int, "Length of the current conversation"] # Conversation length
class AgentState(MessagesState): class AgentState(MessagesState):
company_of_interest: Annotated[str, "Company that we are interested in trading"] company_of_interest: Annotated[str, "Company that we are interested in trading"]
asset_type: Annotated[str, "Asset type under analysis such as stock or crypto"] asset_type: Annotated[str, "Asset type under analysis such as stock or crypto"]
instrument_context: Annotated[str, "Deterministic ticker identity resolved at run start"] instrument_context: Annotated[str, "Deterministic ticker identity resolved at run start"]
trade_date: Annotated[str, "What date we are trading at"] trade_date: Annotated[str, "The analysis date; data is served as of it"]
sender: Annotated[str, "Agent that sent this message"]
# research step # research step
market_report: Annotated[str, "Report from the Market Analyst"] market_report: Annotated[str, "Report from the Market Analyst"]
sentiment_report: Annotated[str, "Report from the Sentiment Analyst"] sentiment_report: Annotated[str, "Report from the Sentiment Analyst"]
news_report: Annotated[ news_report: Annotated[str, "Report from the News Analyst on company and world news"]
str, "Report from the News Researcher of current world affairs" fundamentals_report: Annotated[str, "Report from the Fundamentals Analyst"]
]
fundamentals_report: Annotated[str, "Report from the Fundamentals Researcher"]
# researcher team discussion step # researcher team discussion step
investment_debate_state: Annotated[ investment_debate_state: Annotated[
InvestDebateState, "Current state of the debate on if to invest or not" InvestDebateState, "Current state of the debate on if to invest or not"
] ]
investment_plan: Annotated[str, "Plan generated by the Analyst"] investment_plan: Annotated[str, "Investment plan from the Research Manager"]
trader_investment_plan: Annotated[str, "Plan generated by the Trader"] trader_investment_plan: Annotated[str, "Transaction proposal from the Trader"]
# risk management team discussion step # risk management team discussion step
risk_debate_state: Annotated[ risk_debate_state: Annotated[
RiskDebateState, "Current state of the debate on evaluating risk" RiskDebateState, "Current state of the debate on evaluating risk"
] ]
final_trade_decision: Annotated[str, "Final decision made by the Risk Analysts"] final_trade_decision: Annotated[str, "Final decision from the Portfolio Manager"]
final_rating: Annotated[str, "The Portfolio Manager's 5-tier rating, or REVIEW when it has none"]
past_context: Annotated[str, "Memory log context injected at run start (same-ticker decisions + cross-ticker lessons)"] past_context: Annotated[str, "Memory log context injected at run start (same-ticker decisions + cross-ticker lessons)"]
portfolio_context: Annotated[str, "Caller-supplied holdings and cash, rendered at run start; empty when not provided"] portfolio_context: Annotated[str, "Caller-supplied holdings and cash, rendered at run start; empty when not provided"]
+20 -14
View File
@@ -56,21 +56,15 @@ def bind_structured(llm: Any, schema: type[T], agent_name: str) -> Any | None:
return None return None
def invoke_structured_or_freetext( def invoke_structured(structured_llm: Any | None, prompt: Any, agent_name: str) -> T | None:
structured_llm: Any | None, """Run the structured call; ``None`` when there is none or it fails.
plain_llm: Any,
prompt: Any,
render: Callable[[T], str],
agent_name: str,
) -> str:
"""Run the structured call and render to markdown; fall back to free-text on any failure.
``prompt`` is whatever the underlying LLM accepts (a string for chat ``prompt`` is whatever the underlying LLM accepts (a string for chat
invocations, a list of message dicts for chat models that take that invocations, a list of message dicts for chat models that take that
shape). The same value is forwarded to the free-text path so the shape), so a caller can forward the same value to its free-text fallback.
fallback sees the same input the structured call did.
""" """
if structured_llm is not None: if structured_llm is None:
return None
try: try:
result = structured_llm.invoke(prompt) result = structured_llm.invoke(prompt)
if result is None: if result is None:
@@ -78,12 +72,24 @@ def invoke_structured_or_freetext(
# the tool, leaving the parser with nothing to return. Treat it # the tool, leaving the parser with nothing to return. Treat it
# as a structured miss and fall back, with a clear reason. # as a structured miss and fall back, with a clear reason.
raise ValueError("structured output returned no parsed result") raise ValueError("structured output returned no parsed result")
return render(result) return result
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
"%s: structured-output invocation failed (%s); retrying once as free text", "%s: structured-output invocation failed (%s); retrying once as free text",
agent_name, exc, agent_name, exc,
) )
return None
response = plain_llm.invoke(prompt)
return response.content def invoke_structured_or_freetext(
structured_llm: Any | None,
plain_llm: Any,
prompt: Any,
render: Callable[[T], str],
agent_name: str,
) -> str:
"""Run the structured call and render to markdown; fall back to free-text on any failure."""
result = invoke_structured(structured_llm, prompt, agent_name)
if result is not None:
return render(result)
return plain_llm.invoke(prompt).content
+6 -1
View File
@@ -10,7 +10,8 @@ from langchain_core.tools import tool
from langgraph.prebuilt import InjectedState from langgraph.prebuilt import InjectedState
from tradingagents.dataflows.date_window import as_of, as_of_window from tradingagents.dataflows.date_window import as_of, as_of_window
from tradingagents.dataflows.router import route_to_vendor from tradingagents.dataflows.errors import VendorRateLimitError
from tradingagents.dataflows.router import route_to_vendor, vendor_unavailable
from tradingagents.dataflows.vendors.yahoo.snapshot import build_verified_market_snapshot from tradingagents.dataflows.vendors.yahoo.snapshot import build_verified_market_snapshot
@@ -83,7 +84,11 @@ def get_verified_market_snapshot(
price levels, Bollinger bands, RSI, MACD, moving averages, support / price levels, Bollinger bands, RSI, MACD, moving averages, support /
resistance, or historical comparisons, and treat it as the source of truth. resistance, or historical comparisons, and treat it as the source of truth.
""" """
# An exception out of a tool would end the run.
try:
return build_verified_market_snapshot(symbol, as_of(curr_date, trade_date), look_back_days) return build_verified_market_snapshot(symbol, as_of(curr_date, trade_date), look_back_days)
except VendorRateLimitError as exc:
return vendor_unavailable("get_verified_market_snapshot", exc)
@tool @tool
+2 -5
View File
@@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
import functools
from langchain_core.messages import AIMessage from langchain_core.messages import AIMessage
from tradingagents.agents.context import ( from tradingagents.agents.context import (
@@ -22,7 +20,7 @@ from tradingagents.agents.structured import (
def create_trader(llm): def create_trader(llm):
structured_llm = bind_structured(llm, TraderProposal, "Trader") structured_llm = bind_structured(llm, TraderProposal, "Trader")
def trader_node(state, name): def trader_node(state):
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"]
@@ -95,7 +93,6 @@ def create_trader(llm):
return { return {
"messages": [AIMessage(content=trader_plan)], "messages": [AIMessage(content=trader_plan)],
"trader_investment_plan": trader_plan, "trader_investment_plan": trader_plan,
"sender": name,
} }
return functools.partial(trader_node, name="Trader") return trader_node
+5 -5
View File
@@ -2,7 +2,7 @@
One run yields one decision, so it cannot say whether the system decides well. One run yields one decision, so it cannot say whether the system decides well.
This runs the same machinery over many (ticker, date) cells and reads the This runs the same machinery over many (ticker, date) cells and reads the
aggregate. The decision log is the results table: every run already records its aggregate. The memory log is the results table: every run already records its
rating and later settles it with realized and alpha return against the rating and later settles it with realized and alpha return against the
instrument's regional benchmark, so there is nothing to record separately. instrument's regional benchmark, so there is nothing to record separately.
@@ -24,8 +24,8 @@ from pathlib import Path
from tradingagents.agents.rating import RATING_REVIEW from tradingagents.agents.rating import RATING_REVIEW
from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.date_window import get_current_date
from tradingagents.dataflows.symbols import safe_ticker_component from tradingagents.dataflows.symbols import safe_ticker_component
from tradingagents.decision_log import TradingMemoryLog
from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.memory import TradingMemoryLog
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -134,7 +134,7 @@ def run_backtest(
selected_analysts=("market", "social", "news", "fundamentals"), selected_analysts=("market", "social", "news", "fundamentals"),
run_id: str | None = None, run_id: str | None = None,
) -> BacktestResult: ) -> BacktestResult:
"""Analyze every ticker on every date, into a decision log of this run's own. """Analyze every ticker on every date, into a memory log of this run's own.
The live log stays untouched: a sweep would otherwise flood the context that The live log stays untouched: a sweep would otherwise flood the context that
real runs read back. Cells already in this run's log are skipped, so an real runs read back. Cells already in this run's log are skipped, so an
@@ -176,13 +176,13 @@ def run_backtest(
def summarize(source: BacktestResult | str | Path) -> BacktestSummary: def summarize(source: BacktestResult | str | Path) -> BacktestSummary:
"""Score the settled decisions of a backtest, or of a decision log at a path, by rating.""" """Score the settled decisions of a backtest, or of a memory log at a path, by rating."""
if isinstance(source, BacktestResult): if isinstance(source, BacktestResult):
path = source.log_path # a run whose cells all failed wrote no log: nothing to score path = source.log_path # a run whose cells all failed wrote no log: nothing to score
elif Path(source).is_file(): elif Path(source).is_file():
path = Path(source) path = Path(source)
else: else:
raise FileNotFoundError(f"no decision log at {source}") raise FileNotFoundError(f"no memory log at {source}")
entries = TradingMemoryLog({"memory_log_path": str(path)}).load_entries() entries = TradingMemoryLog({"memory_log_path": str(path)}).load_entries()
# A decision with no readable rating has no direction, so it can neither # A decision with no readable rating has no direction, so it can neither
# count for nor against the system; it is reported as unscored instead. # count for nor against the system; it is reported as unscored instead.
+16 -15
View File
@@ -1,12 +1,13 @@
"""Shared look-ahead-safe date-window filtering for dated content. """Point-in-time rules shared by every dated path: data is served as of the run's date.
News, StockTwits, and Reddit all pull recent items that must be trimmed to the - ``as_of`` / ``as_of_window`` clamp a date or window the model asks for to the
analysis window so a historical/backtest run never sees content published after trade date, so no tool reaches a vendor with a later one.
its as-of date. Centralizing the rule keeps every source consistent (#1126, - ``in_window`` trims dated items (news, StockTwits, Reddit) to the analysis
#1220): every timestamp is normalized to UTC, the upper bound is exclusive at window: timestamps normalized to UTC, the upper bound exclusive at midnight
midnight after ``end`` (so an item stamped exactly then can't leak), and an after ``end``, and an undated item kept only when the window reaches the
undated item is kept only when the window reaches the present (a live run), since present, since a backtest cannot prove it is not from the future (#1126, #1220).
in a backtest we can't prove it isn't future. - ``coverage_gap`` reports a window a feed cannot reach as unavailable, not empty.
- ``withhold_live_profile`` withholds present-day snapshots from historical runs.
""" """
from __future__ import annotations from __future__ import annotations
@@ -95,7 +96,7 @@ def as_of_window(start_date: str, end_date: str, trade_date: str) -> tuple[str,
return f"{_parse(end) - span:%Y-%m-%d}", end return f"{_parse(end) - span:%Y-%m-%d}", end
def withhold_live_profile(curr_date: str | None, label: str) -> str | None: def withhold_live_profile(as_of_date: str | None, label: str) -> str | None:
"""Notice to serve instead of a live-only company profile, or None to serve it. """Notice to serve instead of a live-only company profile, or None to serve it.
Vendor "company overview" endpoints (yfinance ``Ticker.info``, Alpha Vantage Vendor "company overview" endpoints (yfinance ``Ticker.info``, Alpha Vantage
@@ -105,20 +106,20 @@ def withhold_live_profile(curr_date: str | None, label: str) -> str | None:
Every fundamentals vendor withholds on this rule, so switching between them Every fundamentals vendor withholds on this rule, so switching between them
cannot reintroduce the leak. cannot reintroduce the leak.
""" """
if not curr_date: if not as_of_date:
return None return None
today = get_current_date() today = get_current_date()
if curr_date >= today: if as_of_date >= today:
return None return None
return ( return (
f"# Company Fundamentals for {label}\n" f"# Company Fundamentals for {label}\n"
f"# Point-in-time as of: {curr_date}\n\n" f"# Point-in-time as of: {as_of_date}\n\n"
f"Profile fundamentals are withheld for this date. This vendor serves " f"Profile fundamentals are withheld for this date. This vendor serves "
f"only present-day values with no historical vintage: market " f"only present-day values with no historical vintage: market "
f"cap, valuation multiples, the 52-week range and TTM income move with " f"cap, valuation multiples, the 52-week range and TTM income move with "
f"today's quote, and even the name, sector and industry reflect today " f"today's quote, and even the name, sector and industry reflect today "
f"rather than {curr_date} (companies rename and get reclassified). " f"rather than {as_of_date} (companies rename and get reclassified). "
f"Serving them would put post-decision information into a {curr_date} " f"Serving them would put post-decision information into a {as_of_date} "
f"analysis. Point-in-time fundamentals for {curr_date} are available " f"analysis. Point-in-time fundamentals for {as_of_date} are available "
f"from the balance sheet, income statement, and cash flow tools." f"from the balance sheet, income statement, and cash flow tools."
) )
+31
View File
@@ -0,0 +1,31 @@
"""Cache files written whole, safe under concurrent writers."""
import logging
import os
import uuid
from collections.abc import Callable
from pathlib import Path
logger = logging.getLogger(__name__)
def replace_file(path, write: Callable[[str], None]) -> None:
"""Write ``path`` through a uniquely named temp file beside it, then move it into place.
A reader sees the old file or the new one, never a partial write, and two
writers of the same path (tool calls run concurrently) never share a temp file.
``write`` receives the temp path and creates the file, so it gets the usual
permissions. The file is a cache: where another reader holds it open (Windows),
the old one stays and the write is skipped.
"""
path = Path(path)
temp = path.with_name(f"{path.name}.{uuid.uuid4().hex}.tmp")
try:
write(str(temp))
os.replace(temp, path)
except PermissionError as exc:
temp.unlink(missing_ok=True)
logger.warning("Kept the cached %s; it is in use (%s)", path.name, exc)
except BaseException:
temp.unlink(missing_ok=True)
raise
+10 -13
View File
@@ -86,14 +86,6 @@ TOOLS_CATEGORIES = {
} }
} }
VENDOR_LIST = [
"yfinance",
"sec_edgar",
"fred",
"polymarket",
"alpha_vantage",
]
# Optional enrichment categories. These add macro/event context to the news # Optional enrichment categories. These add macro/event context to the news
# analyst but are not core to a decision, so a vendor failure here degrades to a # analyst but are not core to a decision, so a vendor failure here degrades to a
# sentinel instead of aborting the run (a bad LLM-supplied indicator, a missing # sentinel instead of aborting the run (a bad LLM-supplied indicator, a missing
@@ -181,6 +173,15 @@ def get_vendor(category: str, method: str = None) -> str:
return config.get("data_vendors", {}).get(category, "default") return config.get("data_vendors", {}).get(category, "default")
def vendor_unavailable(method: str, error: VendorRateLimitError) -> str:
"""What a call returns when every vendor was throttled or unreachable."""
return (
f"DATA_UNAVAILABLE: no configured vendor could serve {method} right now "
f"({error}). This says nothing about the instrument; report the "
f"data as unavailable and do not estimate or fabricate values."
)
def route_to_vendor(method: str, *args, **kwargs): def route_to_vendor(method: str, *args, **kwargs):
"""Route method calls to appropriate vendor implementation with fallback support.""" """Route method calls to appropriate vendor implementation with fallback support."""
category = get_category_for_method(method) category = get_category_for_method(method)
@@ -273,11 +274,7 @@ def route_to_vendor(method: str, *args, **kwargs):
# Every vendor was throttled or unreachable: that is a fact about the # Every vendor was throttled or unreachable: that is a fact about the
# vendors, not about the instrument, and it must not end the run. # vendors, not about the instrument, and it must not end the run.
if last_unavailable is not None: if last_unavailable is not None:
return ( return vendor_unavailable(method, last_unavailable)
f"DATA_UNAVAILABLE: no configured vendor could serve {method} right now "
f"({last_unavailable}). This says nothing about the instrument; report the "
f"data as unavailable and do not estimate or fabricate values."
)
if first_error is not None: if first_error is not None:
if category in OPTIONAL_CATEGORIES: if category in OPTIONAL_CATEGORIES:
+15 -15
View File
@@ -4,14 +4,14 @@ from tradingagents.dataflows.date_window import withhold_live_profile
from tradingagents.dataflows.vendors.alpha_vantage.common import _make_api_request from tradingagents.dataflows.vendors.alpha_vantage.common import _make_api_request
def _filter_reports_by_date(result, curr_date: str): def _filter_reports_by_date(result, as_of_date: str):
"""Drop annual/quarterly reports dated after curr_date to prevent look-ahead. """Drop annual/quarterly reports dated after as_of_date to prevent look-ahead.
``_make_api_request`` returns the fundamentals payload as a JSON string, so ``_make_api_request`` returns the fundamentals payload as a JSON string, so
parse, filter, and re-serialize. A non-JSON body or an unset ``curr_date`` is parse, filter, and re-serialize. A non-JSON body or an unset ``as_of_date`` is
returned unchanged. returned unchanged.
""" """
if not curr_date or not isinstance(result, str): if not as_of_date or not isinstance(result, str):
return result return result
try: try:
payload = json.loads(result) payload = json.loads(result)
@@ -23,28 +23,28 @@ def _filter_reports_by_date(result, curr_date: str):
if isinstance(payload.get(key), list): if isinstance(payload.get(key), list):
payload[key] = [ payload[key] = [
r for r in payload[key] r for r in payload[key]
if r.get("fiscalDateEnding", "") <= curr_date if r.get("fiscalDateEnding", "") <= as_of_date
] ]
return json.dumps(payload) return json.dumps(payload)
def get_fundamentals(ticker: str, curr_date: str = None) -> str: def get_fundamentals(ticker: str, as_of_date: str = None) -> str:
""" """
Retrieve comprehensive fundamental data for a given ticker symbol using Alpha Vantage. Retrieve comprehensive fundamental data for a given ticker symbol using Alpha Vantage.
OVERVIEW serves only present-day values and carries no historical vintage, so OVERVIEW serves only present-day values and carries no historical vintage, so
a past ``curr_date`` withholds it rather than leaking post-decision figures a past ``as_of_date`` withholds it rather than leaking post-decision figures
into a backtest (#1300); the statement endpoints below stay point-in-time via into a backtest (#1300); the statement endpoints below stay point-in-time via
``_filter_reports_by_date``. ``_filter_reports_by_date``.
Args: Args:
ticker (str): Ticker symbol of the company ticker (str): Ticker symbol of the company
curr_date (str): Analysis date, yyyy-mm-dd as_of_date (str): Analysis date, yyyy-mm-dd
Returns: Returns:
str: Company overview data including financial ratios and key metrics str: Company overview data including financial ratios and key metrics
""" """
withheld = withhold_live_profile(curr_date, ticker) withheld = withhold_live_profile(as_of_date, ticker)
if withheld: if withheld:
return withheld return withheld
@@ -55,20 +55,20 @@ def get_fundamentals(ticker: str, curr_date: str = None) -> str:
return _make_api_request("OVERVIEW", params) return _make_api_request("OVERVIEW", params)
def get_balance_sheet(ticker: str, freq: str = "quarterly", curr_date: str = None): def get_balance_sheet(ticker: str, freq: str = "quarterly", as_of_date: str = None):
"""Retrieve balance sheet data for a given ticker symbol using Alpha Vantage.""" """Retrieve balance sheet data for a given ticker symbol using Alpha Vantage."""
result = _make_api_request("BALANCE_SHEET", {"symbol": ticker}) result = _make_api_request("BALANCE_SHEET", {"symbol": ticker})
return _filter_reports_by_date(result, curr_date) return _filter_reports_by_date(result, as_of_date)
def get_cashflow(ticker: str, freq: str = "quarterly", curr_date: str = None): def get_cashflow(ticker: str, freq: str = "quarterly", as_of_date: str = None):
"""Retrieve cash flow statement data for a given ticker symbol using Alpha Vantage.""" """Retrieve cash flow statement data for a given ticker symbol using Alpha Vantage."""
result = _make_api_request("CASH_FLOW", {"symbol": ticker}) result = _make_api_request("CASH_FLOW", {"symbol": ticker})
return _filter_reports_by_date(result, curr_date) return _filter_reports_by_date(result, as_of_date)
def get_income_statement(ticker: str, freq: str = "quarterly", curr_date: str = None): def get_income_statement(ticker: str, freq: str = "quarterly", as_of_date: str = None):
"""Retrieve income statement data for a given ticker symbol using Alpha Vantage.""" """Retrieve income statement data for a given ticker symbol using Alpha Vantage."""
result = _make_api_request("INCOME_STATEMENT", {"symbol": ticker}) result = _make_api_request("INCOME_STATEMENT", {"symbol": ticker})
return _filter_reports_by_date(result, curr_date) return _filter_reports_by_date(result, as_of_date)
+6 -6
View File
@@ -9,7 +9,7 @@ logger = logging.getLogger(__name__)
def get_indicator( def get_indicator(
symbol: str, symbol: str,
indicator: str, indicator: str,
curr_date: str, as_of_date: str,
look_back_days: int, look_back_days: int,
interval: str = "daily", interval: str = "daily",
time_period: int = 14, time_period: int = 14,
@@ -21,7 +21,7 @@ def get_indicator(
Args: Args:
symbol: ticker symbol of the company symbol: ticker symbol of the company
indicator: technical indicator to get the analysis and report of indicator: technical indicator to get the analysis and report of
curr_date: The current trading date you are trading on, YYYY-mm-dd as_of_date: The current trading date you are trading on, YYYY-mm-dd
look_back_days: how many days to look back look_back_days: how many days to look back
interval: Time interval (daily, weekly, monthly) interval: Time interval (daily, weekly, monthly)
time_period: Number of data points for calculation time_period: Number of data points for calculation
@@ -72,8 +72,8 @@ def get_indicator(
f"Alpha Vantage does not serve {indicator}; it serves {list(supported_indicators)}" f"Alpha Vantage does not serve {indicator}; it serves {list(supported_indicators)}"
) )
curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") as_of_dt = datetime.strptime(as_of_date, "%Y-%m-%d")
before = curr_date_dt - relativedelta(days=look_back_days) before = as_of_dt - relativedelta(days=look_back_days)
# Get the full data for the period instead of making individual calls # Get the full data for the period instead of making individual calls
_, required_series_type = supported_indicators[indicator] _, required_series_type = supported_indicators[indicator]
@@ -184,7 +184,7 @@ def get_indicator(
date_str = values[date_col_idx].strip() date_str = values[date_col_idx].strip()
date_dt = datetime.strptime(date_str, "%Y-%m-%d") date_dt = datetime.strptime(date_str, "%Y-%m-%d")
if before <= date_dt <= curr_date_dt: if before <= date_dt <= as_of_dt:
value = values[value_col_idx].strip() value = values[value_col_idx].strip()
result_data.append((date_dt, value)) result_data.append((date_dt, value))
except (ValueError, IndexError): except (ValueError, IndexError):
@@ -201,7 +201,7 @@ def get_indicator(
ind_string = "No data available for the specified date range.\n" ind_string = "No data available for the specified date range.\n"
result_str = ( result_str = (
f"## {indicator.upper()} values from {before.strftime('%Y-%m-%d')} to {curr_date}:\n\n" f"## {indicator.upper()} values from {before.strftime('%Y-%m-%d')} to {as_of_date}:\n\n"
+ ind_string + ind_string
+ "\n\n" + "\n\n"
+ indicator_descriptions.get(indicator, "No description available.") + indicator_descriptions.get(indicator, "No description available.")
+8 -8
View File
@@ -33,13 +33,13 @@ def get_news(ticker, start_date, end_date) -> dict[str, str] | str:
return _make_api_request("NEWS_SENTIMENT", params) return _make_api_request("NEWS_SENTIMENT", params)
def get_global_news(curr_date, look_back_days: int | None = None, limit: int | None = None) -> dict[str, str] | str: def get_global_news(as_of_date, look_back_days: int | None = None, limit: int | None = None) -> dict[str, str] | str:
"""Returns global market news & sentiment data without ticker-specific filtering. """Returns global market news & sentiment data without ticker-specific filtering.
Covers broad market topics like financial markets, economy, and more. Covers broad market topics like financial markets, economy, and more.
Args: Args:
curr_date: Current date in yyyy-mm-dd format. as_of_date: Current date in yyyy-mm-dd format.
look_back_days: Number of days to look back; ``None`` uses look_back_days: Number of days to look back; ``None`` uses
``global_news_lookback_days`` from the active config. ``global_news_lookback_days`` from the active config.
limit: Maximum number of articles; ``None`` uses limit: Maximum number of articles; ``None`` uses
@@ -56,28 +56,28 @@ def get_global_news(curr_date, look_back_days: int | None = None, limit: int | N
if limit is None: if limit is None:
limit = config["global_news_article_limit"] limit = config["global_news_article_limit"]
curr_dt = datetime.strptime(curr_date, "%Y-%m-%d") curr_dt = datetime.strptime(as_of_date, "%Y-%m-%d")
start_dt = curr_dt - timedelta(days=look_back_days) start_dt = curr_dt - timedelta(days=look_back_days)
start_date = start_dt.strftime("%Y-%m-%d") start_date = start_dt.strftime("%Y-%m-%d")
params = { params = {
"topics": "financial_markets,economy_macro,economy_monetary", "topics": "financial_markets,economy_macro,economy_monetary",
"time_from": format_datetime_for_api(start_date), "time_from": format_datetime_for_api(start_date),
"time_to": format_datetime_for_api(curr_date, end_of_day=True), "time_to": format_datetime_for_api(as_of_date, end_of_day=True),
"limit": str(limit), "limit": str(limit),
} }
return _make_api_request("NEWS_SENTIMENT", params) return _make_api_request("NEWS_SENTIMENT", params)
def get_insider_transactions(symbol: str, curr_date: str | None = None) -> dict[str, str] | str: def get_insider_transactions(symbol: str, as_of_date: str | None = None) -> dict[str, str] | str:
"""Returns latest and historical insider transactions by key stakeholders. """Returns latest and historical insider transactions by key stakeholders.
Covers transactions by founders, executives, board members, etc. Covers transactions by founders, executives, board members, etc.
Args: Args:
symbol: Ticker symbol. Example: "IBM". symbol: Ticker symbol. Example: "IBM".
curr_date: When given, only transactions on or before it (yyyy-mm-dd). as_of_date: When given, only transactions on or before it (yyyy-mm-dd).
Returns: Returns:
Dictionary containing insider transaction data or JSON string. Dictionary containing insider transaction data or JSON string.
@@ -88,8 +88,8 @@ def get_insider_transactions(symbol: str, curr_date: str | None = None) -> dict[
} }
response = _make_api_request("INSIDER_TRANSACTIONS", params) response = _make_api_request("INSIDER_TRANSACTIONS", params)
if not curr_date: if not as_of_date:
return response return response
payload = json.loads(response) payload = json.loads(response)
payload["data"] = [t for t in payload["data"] if t["transaction_date"] <= curr_date] payload["data"] = [t for t in payload["data"] if t["transaction_date"] <= as_of_date]
return json.dumps(payload) return json.dumps(payload)
+12 -12
View File
@@ -126,7 +126,7 @@ def _fred_today() -> str:
"""FRED's current calendar date (US Central) as ``yyyy-mm-dd``. """FRED's current calendar date (US Central) as ``yyyy-mm-dd``.
The vintage pin is clamped to this: FRED rejects a ``realtime_start`` after 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 its own today with a 400, and ``as_of_date`` on a live run comes from the
caller's local clock, which can already be tomorrow in Chicago. caller's local clock, which can already be tomorrow in Chicago.
""" """
return datetime.now(FRED_TZ).strftime("%Y-%m-%d") return datetime.now(FRED_TZ).strftime("%Y-%m-%d")
@@ -155,7 +155,7 @@ def _request(path: str, params: dict) -> dict:
def get_macro_data( def get_macro_data(
indicator: str, indicator: str,
curr_date: str, as_of_date: str,
look_back_days: int | None = None, look_back_days: int | None = None,
) -> str: ) -> str:
"""Fetch a FRED macroeconomic series as a formatted markdown report. """Fetch a FRED macroeconomic series as a formatted markdown report.
@@ -163,9 +163,9 @@ 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: The as-of date (yyyy-mm-dd). It bounds the observation window as_of_date: The as-of date (yyyy-mm-dd). It bounds the observation window
AND pins the data vintage: FRED is queried with the realtime bounds 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 set to ``as_of_date`` (clamped to FRED's own today) so a historical
run sees the values that were actually published by that date, not run sees the values that were actually published by that date, not
later revisions. Without this, revision-prone series (CPI, GDP, ...) later revisions. Without this, revision-prone series (CPI, GDP, ...)
would leak future information into a backtest (#1275). would leak future information into a backtest (#1275).
@@ -178,18 +178,18 @@ def get_macro_data(
if look_back_days is None: if look_back_days is None:
look_back_days = DEFAULT_LOOKBACK_DAYS look_back_days = DEFAULT_LOOKBACK_DAYS
end_dt = datetime.strptime(curr_date, "%Y-%m-%d") end_dt = datetime.strptime(as_of_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 # Pin the data vintage. FRED defaults both realtime bounds to today, serving
# the LATEST revision of every observation; a single-day realtime interval # 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 # 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 # 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, # as_of_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 # 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 # drop macro data silently. A past as_of_date is unaffected, so historical
# point-in-time behaviour is preserved. # point-in-time behaviour is preserved.
pit = min(curr_date, _fred_today()) pit = min(as_of_date, _fred_today())
realtime = {"realtime_start": pit, "realtime_end": pit} 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
@@ -217,7 +217,7 @@ def get_macro_data(
{ {
"series_id": series_id, "series_id": series_id,
"observation_start": start_date, "observation_start": start_date,
"observation_end": curr_date, "observation_end": as_of_date,
"sort_order": "asc", "sort_order": "asc",
**realtime, **realtime,
}, },
@@ -235,7 +235,7 @@ def get_macro_data(
f"- Units: {units}\n" f"- Units: {units}\n"
f"- Frequency: {frequency}" f"- Frequency: {frequency}"
f"{f' ({seasonal})' if seasonal else ''}\n" f"{f' ({seasonal})' if seasonal else ''}\n"
f"- Window: {start_date} to {curr_date}\n" f"- Window: {start_date} to {as_of_date}\n"
) )
if not points: if not points:
@@ -254,8 +254,8 @@ def get_macro_data(
pct = f" ({delta / base * 100:+.2f}%)" if base != 0 else "" pct = f" ({delta / base * 100:+.2f}%)" if base != 0 else ""
summary = ( summary = (
f"\n**Latest:** {last_val} ({last_date}) | " f"\n**Latest:** {last_val} ({last_date}) | "
f"**Change over window:** {delta:+.2f}{pct} " f"**Change from {first_date} to {last_date}:** {delta:+.2f}{pct}, "
f"from {first_val} ({first_date})\n" f"from {first_val}\n"
) )
except ValueError: except ValueError:
summary = f"\n**Latest:** {last_val} ({last_date})\n" summary = f"\n**Latest:** {last_val} ({last_date})\n"
+5 -5
View File
@@ -67,7 +67,7 @@ def _is_forward_looking(market: dict, now: datetime) -> bool:
) )
def get_prediction_markets(topic: str, limit: int | None = None, curr_date: str | None = None) -> str: def get_prediction_markets(topic: str, limit: int | None = None, as_of_date: str | None = None) -> str:
"""Return live prediction-market probabilities for an event topic. """Return live prediction-market probabilities for an event topic.
Args: Args:
@@ -75,7 +75,7 @@ def get_prediction_markets(topic: str, limit: int | None = None, curr_date: str
"US election", or a sector/company event. "US election", or a sector/company event.
limit: Max markets to return (ranked by traded volume); ``None`` uses limit: Max markets to return (ranked by traded volume); ``None`` uses
DEFAULT_LIMIT. DEFAULT_LIMIT.
curr_date: The analysis date. Polymarket serves only live odds, so a as_of_date: The analysis date. Polymarket serves only live odds, so a
date before today withholds them. date before today withholds them.
Returns: Returns:
@@ -83,11 +83,11 @@ def get_prediction_markets(topic: str, limit: int | None = None, curr_date: str
each with its implied probability, traded volume, resolution date, and each with its implied probability, traded volume, resolution date, and
recent (1-week) move. recent (1-week) move.
""" """
if curr_date and curr_date < get_current_date(): if as_of_date and as_of_date < get_current_date():
return ( return (
f"Prediction-market odds are withheld for {curr_date}. Polymarket serves " f"Prediction-market odds are withheld for {as_of_date}. Polymarket serves "
f"only live odds on open markets, with no historical vintage, so serving " f"only live odds on open markets, with no historical vintage, so serving "
f"them would put post-decision information into a {curr_date} analysis." f"them would put post-decision information into a {as_of_date} analysis."
) )
if limit is None: if limit is None:
limit = DEFAULT_LIMIT limit = DEFAULT_LIMIT
+21 -28
View File
@@ -5,7 +5,7 @@ statement at the fiscal period end. That is two claims a run should not make: a
period that has ended is not public until the company files, weeks later, and a period that has ended is not public until the company files, weeks later, and a
figure that was later restated is not what investors saw at the time. figure that was later restated is not what investors saw at the time.
EDGAR reports every fact with the date it was filed, so a run dated ``curr_date`` EDGAR reports every fact with the date it was filed, so a run dated ``as_of_date``
serves exactly what was on file by then, restatements included at the vintage serves exactly what was on file by then, restatements included at the vintage
that was current: Apple's 2008 total assets read 39.6B until the 2010 amendment that was current: Apple's 2008 total assets read 39.6B until the 2010 amendment
restated them to 36.2B. restated them to 36.2B.
@@ -22,13 +22,14 @@ import logging
import os import os
import time import time
from datetime import date, datetime from datetime import date, datetime
from importlib import metadata
from pathlib import Path from pathlib import Path
import requests import requests
from tradingagents import __version__
from tradingagents.dataflows.config import get_config from tradingagents.dataflows.config import get_config
from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError
from tradingagents.dataflows.files import replace_file
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -93,15 +94,9 @@ def _user_agent() -> str:
so SEC can reach you about your traffic rather than the project. so SEC can reach you about your traffic rather than the project.
""" """
configured = os.getenv("SEC_EDGAR_USER_AGENT", "").strip() configured = os.getenv("SEC_EDGAR_USER_AGENT", "").strip()
return configured or f"TradingAgents/{_version()} (contact@example.com)" return configured or f"TradingAgents/{__version__} (contact@example.com)"
def _version() -> str:
"""The installed package version, so a release identifies itself correctly."""
try:
return metadata.version("tradingagents")
except metadata.PackageNotFoundError:
return "dev"
def _fetch_json(url: str) -> dict: def _fetch_json(url: str) -> dict:
@@ -128,9 +123,7 @@ def _cached_json(url: str, name: str) -> dict:
pass # a truncated file is a miss, not a failure pass # a truncated file is a miss, not a failure
data = _fetch_json(url) data = _fetch_json(url)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_suffix(".tmp") replace_file(path, lambda temp: Path(temp).write_text(json.dumps(data), encoding="utf-8"))
temp.write_text(json.dumps(data), encoding="utf-8")
os.replace(temp, path)
return data return data
@@ -144,7 +137,7 @@ def cik_for(ticker: str) -> str | None:
return None return None
def _as_of(facts: dict, tags: tuple[str, ...], curr_date: str, span: tuple[int, int], def _as_of(facts: dict, tags: tuple[str, ...], as_of_date: str, span: tuple[int, int],
forms: tuple[str, ...] = ()) -> tuple[dict, str]: forms: tuple[str, ...] = ()) -> tuple[dict, str]:
"""({period end: value}, unit) for the first tag the filer reports, as known then. """({period end: value}, unit) for the first tag the filer reports, as known then.
@@ -164,7 +157,7 @@ def _as_of(facts: dict, tags: tuple[str, ...], curr_date: str, span: tuple[int,
latest: dict[str, dict] = {} latest: dict[str, dict] = {}
covered: set[str] = set() # period ends a filing of ``forms`` reports covered: set[str] = set() # period ends a filing of ``forms`` reports
for fact in unit_values: for fact in unit_values:
if fact["filed"] > curr_date or fact["end"] in values: if fact["filed"] > as_of_date or fact["end"] in values:
continue continue
# A duration fact (revenue, cash flow) must cover the span asked # A duration fact (revenue, cash flow) must cover the span asked
# for. An instant fact (a balance) has no span and serves both. # for. An instant fact (a balance) has no span and serves both.
@@ -184,8 +177,8 @@ def _as_of(facts: dict, tags: tuple[str, ...], curr_date: str, span: tuple[int,
return dict(sorted(values.items())), chosen_unit return dict(sorted(values.items())), chosen_unit
def _statement(kind: str, ticker: str, freq: str, curr_date: str, title: str) -> str: def _statement(kind: str, ticker: str, freq: str, as_of_date: str, title: str) -> str:
curr_date = curr_date or datetime.now().strftime("%Y-%m-%d") as_of_date = as_of_date or datetime.now().strftime("%Y-%m-%d")
cik = cik_for(ticker) cik = cik_for(ticker)
if cik is None: if cik is None:
raise NoMarketDataError(ticker, ticker, "not a US SEC filer") raise NoMarketDataError(ticker, ticker, "not a US SEC filer")
@@ -198,14 +191,14 @@ def _statement(kind: str, ticker: str, freq: str, curr_date: str, title: str) ->
quarterly = freq.lower() == "quarterly" quarterly = freq.lower() == "quarterly"
span = _SPANS["quarterly" if quarterly else "annual"] span = _SPANS["quarterly" if quarterly else "annual"]
forms = () if quarterly else _ANNUAL_FORMS forms = () if quarterly else _ANNUAL_FORMS
lines = {label: _as_of(us_gaap, tags, curr_date, span, forms) for label, tags in _STATEMENTS[kind]} lines = {label: _as_of(us_gaap, tags, as_of_date, span, forms) for label, tags in _STATEMENTS[kind]}
periods = sorted({end for values, _ in lines.values() for end in values}) periods = sorted({end for values, _ in lines.values() for end in values})
if not periods: if not periods:
raise NoMarketDataError(ticker, ticker, f"no {freq} {title.lower()} filed by {curr_date}") raise NoMarketDataError(ticker, ticker, f"no {freq} {title.lower()} filed by {as_of_date}")
header = ( header = (
f"# {title} for {ticker.upper()} ({freq}), USD in millions unless the row says otherwise\n" f"# {title} for {ticker.upper()} ({freq}), USD in millions unless the row says otherwise\n"
f"# SEC EDGAR facts filed on or before {curr_date}, at the values filed then\n\n" f"# SEC EDGAR facts filed on or before {as_of_date}, at the values filed then\n\n"
) )
rows = [",".join([""] + periods)] rows = [",".join([""] + periods)]
for label, (values, unit) in lines.items(): for label, (values, unit) in lines.items():
@@ -223,21 +216,21 @@ def _statement(kind: str, ticker: str, freq: str, curr_date: str, title: str) ->
return header + "\n".join(rows) + "\n" return header + "\n".join(rows) + "\n"
def get_balance_sheet(ticker: str, freq: str = "quarterly", curr_date: str | None = None) -> str: def get_balance_sheet(ticker: str, freq: str = "quarterly", as_of_date: str | None = None) -> str:
"""Balance sheet as filed on or before ``curr_date``.""" """Balance sheet as filed on or before ``as_of_date``."""
return _statement("balance_sheet", ticker, freq, curr_date, "Balance Sheet") return _statement("balance_sheet", ticker, freq, as_of_date, "Balance Sheet")
def get_income_statement(ticker: str, freq: str = "quarterly", curr_date: str | None = None) -> str: def get_income_statement(ticker: str, freq: str = "quarterly", as_of_date: str | None = None) -> str:
"""Income statement as filed on or before ``curr_date``. """Income statement as filed on or before ``as_of_date``.
A fourth quarter is never derived: filers report it only inside the annual A fourth quarter is never derived: filers report it only inside the annual
figure, and subtracting three separately filed quarters would invent a number figure, and subtracting three separately filed quarters would invent a number
with no filing date behind it. with no filing date behind it.
""" """
return _statement("income_statement", ticker, freq, curr_date, "Income Statement") return _statement("income_statement", ticker, freq, as_of_date, "Income Statement")
def get_cashflow(ticker: str, freq: str = "quarterly", curr_date: str | None = None) -> str: def get_cashflow(ticker: str, freq: str = "quarterly", as_of_date: str | None = None) -> str:
"""Cash flow statement as filed on or before ``curr_date``.""" """Cash flow statement as filed on or before ``as_of_date``."""
return _statement("cashflow", ticker, freq, curr_date, "Cash Flow Statement") return _statement("cashflow", ticker, freq, as_of_date, "Cash Flow Statement")
+22 -23
View File
@@ -16,25 +16,24 @@ from tradingagents.dataflows.vendors.yahoo.ohlcv import (
def get_fundamentals( def get_fundamentals(
ticker: Annotated[str, "ticker symbol of the company"], ticker: Annotated[str, "ticker symbol of the company"],
curr_date: Annotated[str, "analysis date in YYYY-MM-DD format"] = None as_of_date: Annotated[str, "analysis date in YYYY-MM-DD format"] = None
): ):
"""Get company fundamentals overview from yfinance. """Get company fundamentals overview from yfinance.
``Ticker.info`` is a present-day snapshot with no historical vintage, so a ``Ticker.info`` is a present-day snapshot with no historical vintage, so a
past ``curr_date`` withholds it through the shared point-in-time guard past ``as_of_date`` withholds it through the shared point-in-time guard
(``date_window.withhold_live_profile``, #1300). (``date_window.withhold_live_profile``, #1300).
""" """
canonical = normalize_symbol(ticker) canonical = normalize_symbol(ticker)
# Guard before the request: the response would only be discarded, and the # Guard before the request: the response would only be discarded, and the
# answer does not depend on it. # answer does not depend on it.
withheld = withhold_live_profile(curr_date, canonical) withheld = withhold_live_profile(as_of_date, canonical)
if withheld: if withheld:
return withheld return withheld
try: try:
ticker_obj = yf.Ticker(canonical) info = yf_retry(lambda: yf.Ticker(canonical).info)
info = yf_retry(lambda: ticker_obj.info)
if not info: if not info:
raise_for_empty(ticker, canonical, "fundamentals") raise_for_empty(ticker, canonical, "fundamentals")
@@ -100,14 +99,14 @@ _PERIOD_END_VINTAGE = (
) )
def _statement(ticker, freq, curr_date, title, quarterly_attr, annual_attr) -> str: def _statement(ticker, freq, as_of_date, title, quarterly_attr, annual_attr) -> str:
"""One financial statement as CSV, cut at ``curr_date`` by period end.""" """One financial statement as CSV, cut at ``as_of_date`` by period end."""
canonical = normalize_symbol(ticker) canonical = normalize_symbol(ticker)
what = title.lower() what = title.lower()
try: try:
ticker_obj = yf.Ticker(canonical) ticker_obj = yf.Ticker(canonical)
attr = quarterly_attr if freq.lower() == "quarterly" else annual_attr attr = quarterly_attr if freq.lower() == "quarterly" else annual_attr
data = filter_financials_by_date(yf_retry(lambda: getattr(ticker_obj, attr)), curr_date) data = filter_financials_by_date(yf_retry(lambda: getattr(ticker_obj, attr)), as_of_date)
if data.empty: if data.empty:
raise_for_empty(ticker, canonical, f"{what} data") raise_for_empty(ticker, canonical, f"{what} data")
return f"# {title} data for {canonical} ({freq})\n" + _PERIOD_END_VINTAGE + data.to_csv() return f"# {title} data for {canonical} ({freq})\n" + _PERIOD_END_VINTAGE + data.to_csv()
@@ -120,28 +119,28 @@ def _statement(ticker, freq, curr_date, title, quarterly_attr, annual_attr) -> s
def get_balance_sheet( def get_balance_sheet(
ticker: Annotated[str, "ticker symbol of the company"], ticker: Annotated[str, "ticker symbol of the company"],
freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly", freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly",
curr_date: Annotated[str, "current date in YYYY-MM-DD format"] = None as_of_date: Annotated[str, "current date in YYYY-MM-DD format"] = None
): ):
"""Get balance sheet data from yfinance.""" """Get balance sheet data from yfinance."""
return _statement(ticker, freq, curr_date, "Balance Sheet", "quarterly_balance_sheet", "balance_sheet") return _statement(ticker, freq, as_of_date, "Balance Sheet", "quarterly_balance_sheet", "balance_sheet")
def get_cashflow( def get_cashflow(
ticker: Annotated[str, "ticker symbol of the company"], ticker: Annotated[str, "ticker symbol of the company"],
freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly", freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly",
curr_date: Annotated[str, "current date in YYYY-MM-DD format"] = None as_of_date: Annotated[str, "current date in YYYY-MM-DD format"] = None
): ):
"""Get cash flow data from yfinance.""" """Get cash flow data from yfinance."""
return _statement(ticker, freq, curr_date, "Cash Flow", "quarterly_cashflow", "cashflow") return _statement(ticker, freq, as_of_date, "Cash Flow", "quarterly_cashflow", "cashflow")
def get_income_statement( def get_income_statement(
ticker: Annotated[str, "ticker symbol of the company"], ticker: Annotated[str, "ticker symbol of the company"],
freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly", freq: Annotated[str, "frequency of data: 'annual' or 'quarterly'"] = "quarterly",
curr_date: Annotated[str, "current date in YYYY-MM-DD format"] = None as_of_date: Annotated[str, "current date in YYYY-MM-DD format"] = None
): ):
"""Get income statement data from yfinance.""" """Get income statement data from yfinance."""
return _statement(ticker, freq, curr_date, "Income Statement", "quarterly_income_stmt", "income_stmt") return _statement(ticker, freq, as_of_date, "Income Statement", "quarterly_income_stmt", "income_stmt")
# Rows are dated by the transaction, which is when the insider traded, not when # Rows are dated by the transaction, which is when the insider traded, not when
@@ -157,7 +156,7 @@ _TRANSACTION_DATE_VINTAGE = (
def get_insider_transactions( def get_insider_transactions(
ticker: Annotated[str, "ticker symbol of the company"], ticker: Annotated[str, "ticker symbol of the company"],
curr_date: Annotated[str | None, "only transactions on or before this date, yyyy-mm-dd"] = None, as_of_date: Annotated[str | None, "only transactions on or before this date, yyyy-mm-dd"] = None,
): ):
"""Get insider transactions data from yfinance.""" """Get insider transactions data from yfinance."""
canonical = normalize_symbol(ticker) canonical = normalize_symbol(ticker)
@@ -172,12 +171,12 @@ def get_insider_transactions(
raise VendorRateLimitError("Yahoo Finance is unreachable; insider filings were not retrieved") raise VendorRateLimitError("Yahoo Finance is unreachable; insider filings were not retrieved")
return f"No insider transactions reported for symbol '{canonical}'" return f"No insider transactions reported for symbol '{canonical}'"
if curr_date: if as_of_date:
traded = data["Start Date"] traded = data["Start Date"]
kept = data[traded <= pd.Timestamp(curr_date)] kept = data[traded <= pd.Timestamp(as_of_date)]
if kept.empty: if kept.empty:
return ( return (
f"<insider transactions unavailable for {canonical} as of {curr_date}: " f"<insider transactions unavailable for {canonical} as of {as_of_date}: "
"Yahoo serves recent transactions only>" "Yahoo serves recent transactions only>"
) )
data = kept data = kept
@@ -199,15 +198,15 @@ def get_company_profile(ticker: str) -> dict:
raise NoMarketDataError(ticker, canonical, f"profile unavailable: {e}") from e raise NoMarketDataError(ticker, canonical, f"profile unavailable: {e}") from e
def filter_financials_by_date(data: pd.DataFrame, curr_date: str) -> pd.DataFrame: def filter_financials_by_date(data: pd.DataFrame, as_of_date: str) -> pd.DataFrame:
"""Drop financial statement columns (fiscal period timestamps) after curr_date. """Drop financial statement columns (fiscal period timestamps) after as_of_date.
yfinance financial statements use fiscal period end dates as columns. yfinance financial statements use fiscal period end dates as columns.
Columns after curr_date represent future data and are removed to Columns after as_of_date represent future data and are removed to
prevent look-ahead bias. prevent look-ahead bias.
""" """
if not curr_date or data.empty: if not as_of_date or data.empty:
return data return data
cutoff = pd.Timestamp(curr_date) cutoff = pd.Timestamp(as_of_date)
mask = pd.to_datetime(data.columns, errors="coerce") <= cutoff mask = pd.to_datetime(data.columns, errors="coerce") <= cutoff
return data.loc[:, mask] return data.loc[:, mask]
+22 -22
View File
@@ -73,7 +73,7 @@ def get_YFin_data_online(
def get_stock_stats_indicators_window( def get_stock_stats_indicators_window(
symbol: Annotated[str, "ticker symbol of the company"], symbol: Annotated[str, "ticker symbol of the company"],
indicator: Annotated[str, "technical indicator to get the analysis and report of"], indicator: Annotated[str, "technical indicator to get the analysis and report of"],
curr_date: Annotated[ as_of_date: Annotated[
str, "The current trading date you are trading on, YYYY-mm-dd" str, "The current trading date you are trading on, YYYY-mm-dd"
], ],
look_back_days: Annotated[int, "how many days to look back"], look_back_days: Annotated[int, "how many days to look back"],
@@ -157,16 +157,16 @@ def get_stock_stats_indicators_window(
f"Indicator {indicator} is not supported. Please choose from: {list(best_ind_params.keys())}" f"Indicator {indicator} is not supported. Please choose from: {list(best_ind_params.keys())}"
) )
end_date = curr_date end_date = as_of_date
curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") as_of_dt = datetime.strptime(as_of_date, "%Y-%m-%d")
before = curr_date_dt - relativedelta(days=look_back_days) before = as_of_dt - relativedelta(days=look_back_days)
# Optimized: Get stock data once and calculate indicators for all dates # Optimized: Get stock data once and calculate indicators for all dates
try: try:
indicator_data = _get_stock_stats_bulk(symbol, indicator, curr_date) indicator_data = _get_stock_stats_bulk(symbol, indicator, as_of_date)
# Generate the date range we need # Generate the date range we need
current_dt = curr_date_dt current_dt = as_of_dt
date_values = [] date_values = []
while current_dt >= before: while current_dt >= before:
@@ -191,13 +191,13 @@ def get_stock_stats_indicators_window(
logger.warning("Bulk stockstats fetch failed, falling back per-day: %s", e) logger.warning("Bulk stockstats fetch failed, falling back per-day: %s", e)
# Fallback to original implementation if bulk method fails # Fallback to original implementation if bulk method fails
ind_string = "" ind_string = ""
curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") as_of_dt = datetime.strptime(as_of_date, "%Y-%m-%d")
while curr_date_dt >= before: while as_of_dt >= before:
indicator_value = get_stockstats_indicator( indicator_value = get_stockstats_indicator(
symbol, indicator, curr_date_dt.strftime("%Y-%m-%d") symbol, indicator, as_of_dt.strftime("%Y-%m-%d")
) )
ind_string += f"{curr_date_dt.strftime('%Y-%m-%d')}: {indicator_value}\n" ind_string += f"{as_of_dt.strftime('%Y-%m-%d')}: {indicator_value}\n"
curr_date_dt = curr_date_dt - relativedelta(days=1) as_of_dt = as_of_dt - relativedelta(days=1)
result_str = ( result_str = (
f"## {indicator} values from {before.strftime('%Y-%m-%d')} to {end_date}:\n\n" f"## {indicator} values from {before.strftime('%Y-%m-%d')} to {end_date}:\n\n"
@@ -212,7 +212,7 @@ def get_stock_stats_indicators_window(
def _get_stock_stats_bulk( def _get_stock_stats_bulk(
symbol: Annotated[str, "ticker symbol of the company"], symbol: Annotated[str, "ticker symbol of the company"],
indicator: Annotated[str, "technical indicator to calculate"], indicator: Annotated[str, "technical indicator to calculate"],
curr_date: Annotated[str, "current date for reference"] as_of_date: Annotated[str, "current date for reference"]
) -> dict: ) -> dict:
""" """
Optimized bulk calculation of stock stats indicators. Optimized bulk calculation of stock stats indicators.
@@ -221,7 +221,7 @@ def _get_stock_stats_bulk(
""" """
from stockstats import wrap from stockstats import wrap
data = load_ohlcv(symbol, curr_date) data = load_ohlcv(symbol, as_of_date)
df = wrap(data) df = wrap(data)
df["Date"] = df["Date"].dt.strftime("%Y-%m-%d") df["Date"] = df["Date"].dt.strftime("%Y-%m-%d")
@@ -243,19 +243,19 @@ def _get_stock_stats_bulk(
def get_stockstats_indicator( def get_stockstats_indicator(
symbol: Annotated[str, "ticker symbol of the company"], symbol: Annotated[str, "ticker symbol of the company"],
indicator: Annotated[str, "technical indicator to get the analysis and report of"], indicator: Annotated[str, "technical indicator to get the analysis and report of"],
curr_date: Annotated[ as_of_date: Annotated[
str, "The current trading date you are trading on, YYYY-mm-dd" str, "The current trading date you are trading on, YYYY-mm-dd"
], ],
) -> str: ) -> str:
curr_date_dt = datetime.strptime(curr_date, "%Y-%m-%d") as_of_dt = datetime.strptime(as_of_date, "%Y-%m-%d")
curr_date = curr_date_dt.strftime("%Y-%m-%d") as_of_date = as_of_dt.strftime("%Y-%m-%d")
try: try:
indicator_value = get_stock_stats( indicator_value = get_stock_stats(
symbol, symbol,
indicator, indicator,
curr_date, as_of_date,
) )
except VendorError: except VendorError:
raise # Unknown/delisted symbol — let the router emit the sentinel raise # Unknown/delisted symbol — let the router emit the sentinel
@@ -264,7 +264,7 @@ def get_stockstats_indicator(
# reads as no value that day rather than a read that failed. Raise so the # reads as no value that day rather than a read that failed. Raise so the
# router can try the next vendor or report the series unavailable. # router can try the next vendor or report the series unavailable.
raise NoMarketDataError( raise NoMarketDataError(
symbol, symbol, f"{indicator} could not be read for {curr_date}: {e}" symbol, symbol, f"{indicator} could not be read for {as_of_date}: {e}"
) from e ) from e
return str(indicator_value) return str(indicator_value)
@@ -285,17 +285,17 @@ def get_stock_stats(
indicator: Annotated[ indicator: Annotated[
str, "quantitative indicators based off of the stock data for the company" str, "quantitative indicators based off of the stock data for the company"
], ],
curr_date: Annotated[ as_of_date: Annotated[
str, "curr date for retrieving stock price data, YYYY-mm-dd" str, "curr date for retrieving stock price data, YYYY-mm-dd"
], ],
): ):
data = load_ohlcv(symbol, curr_date) data = load_ohlcv(symbol, as_of_date)
df = wrap(data) df = wrap(data)
df["Date"] = df["Date"].dt.strftime("%Y-%m-%d") df["Date"] = df["Date"].dt.strftime("%Y-%m-%d")
curr_date_str = pd.to_datetime(curr_date).strftime("%Y-%m-%d") as_of_str = pd.to_datetime(as_of_date).strftime("%Y-%m-%d")
df[indicator] # trigger stockstats to calculate the indicator df[indicator] # trigger stockstats to calculate the indicator
matching_rows = df[df["Date"].str.startswith(curr_date_str)] matching_rows = df[df["Date"].str.startswith(as_of_str)]
if not matching_rows.empty: if not matching_rows.empty:
indicator_value = matching_rows[indicator].values[0] indicator_value = matching_rows[indicator].values[0]
+11 -7
View File
@@ -8,7 +8,7 @@ from dateutil.relativedelta import relativedelta
from tradingagents.dataflows.config import get_config from tradingagents.dataflows.config import get_config
from tradingagents.dataflows.date_window import coverage_gap, in_window from tradingagents.dataflows.date_window import coverage_gap, in_window
from tradingagents.dataflows.errors import NoMarketDataError from tradingagents.dataflows.errors import NoMarketDataError, VendorError
from tradingagents.dataflows.symbols import normalize_symbol from tradingagents.dataflows.symbols import normalize_symbol
from tradingagents.dataflows.vendors.yahoo.ohlcv import yf_retry from tradingagents.dataflows.vendors.yahoo.ohlcv import yf_retry
@@ -114,12 +114,14 @@ def get_news_yfinance(
return f"## {ticker}{resolved} News, from {start_date} to {end_date}:\n\n{news_str}" return f"## {ticker}{resolved} News, from {start_date} to {end_date}:\n\n{news_str}"
except VendorError:
raise
except Exception as e: except Exception as e:
raise NoMarketDataError(ticker, ticker, f"news unavailable: {e}") from e raise NoMarketDataError(ticker, ticker, f"news unavailable: {e}") from e
def get_global_news_yfinance( def get_global_news_yfinance(
curr_date: str, as_of_date: str,
look_back_days: int | None = None, look_back_days: int | None = None,
limit: int | None = None, limit: int | None = None,
) -> str: ) -> str:
@@ -127,7 +129,7 @@ def get_global_news_yfinance(
Retrieve global/macro economic news using yfinance Search. Retrieve global/macro economic news using yfinance Search.
Args: Args:
curr_date: Current date in yyyy-mm-dd format as_of_date: Current date in yyyy-mm-dd format
look_back_days: Number of days to look back. ``None`` falls back to look_back_days: Number of days to look back. ``None`` falls back to
``global_news_lookback_days`` from the active config. ``global_news_lookback_days`` from the active config.
limit: Maximum number of articles to return. ``None`` falls back to limit: Maximum number of articles to return. ``None`` falls back to
@@ -143,7 +145,7 @@ def get_global_news_yfinance(
limit = config["global_news_article_limit"] limit = config["global_news_article_limit"]
search_queries = config["global_news_queries"] search_queries = config["global_news_queries"]
curr_dt = datetime.strptime(curr_date, "%Y-%m-%d") curr_dt = datetime.strptime(as_of_date, "%Y-%m-%d")
start_dt = curr_dt - relativedelta(days=look_back_days) start_dt = curr_dt - relativedelta(days=look_back_days)
start_date = start_dt.strftime("%Y-%m-%d") start_date = start_dt.strftime("%Y-%m-%d")
@@ -187,10 +189,12 @@ def get_global_news_yfinance(
if not news_str: if not news_str:
# Results merge several fuzzy searches, so their timestamps prove no # Results merge several fuzzy searches, so their timestamps prove no
# continuous coverage; judge the window against the present only. # continuous coverage; judge the window against the present only.
gap = coverage_gap((), start_date, curr_date, "Yahoo Finance global news", "market news") gap = coverage_gap((), start_date, as_of_date, "Yahoo Finance global news", "market news")
return gap or f"No global news found between {start_date} and {curr_date}" return gap or f"No global news found between {start_date} and {as_of_date}"
return f"## Global Market News, from {start_date} to {curr_date}:\n\n{news_str}" return f"## Global Market News, from {start_date} to {as_of_date}:\n\n{news_str}"
except VendorError:
raise
except Exception as e: except Exception as e:
raise NoMarketDataError("global news", "global news", f"unavailable: {e}") from e raise NoMarketDataError("global news", "global news", f"unavailable: {e}") from e
+39 -24
View File
@@ -8,6 +8,7 @@ from yfinance.exceptions import YFRateLimitError
from tradingagents.dataflows.config import get_config from tradingagents.dataflows.config import get_config
from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError
from tradingagents.dataflows.files import replace_file
from tradingagents.dataflows.net import vendor_reachable from tradingagents.dataflows.net import vendor_reachable
from tradingagents.dataflows.symbols import normalize_symbol, safe_ticker_component from tradingagents.dataflows.symbols import normalize_symbol, safe_ticker_component
@@ -43,18 +44,24 @@ def yf_retry(func, max_retries=3, base_delay=2.0):
yfinance raises YFRateLimitError on HTTP 429 responses but does not yfinance raises YFRateLimitError on HTTP 429 responses but does not
retry them internally. This wrapper adds retry logic specifically retry them internally. This wrapper adds retry logic specifically
for rate limits. Other exceptions propagate immediately. for rate limits. Other exceptions propagate immediately. A rate limit
that outlasts the retries is raised as VendorRateLimitError, so the
router reports a throttled vendor rather than a symbol with no data.
``func`` should build its own Ticker: a Ticker keeps a failed ``info``
fetch as done, so asking the same one again reads an empty profile.
""" """
for attempt in range(max_retries + 1): for attempt in range(max_retries + 1):
try: try:
return func() return func()
except YFRateLimitError: except YFRateLimitError as exc:
if attempt < max_retries: if attempt < max_retries:
delay = base_delay * (2 ** attempt) delay = base_delay * (2 ** attempt)
logger.warning(f"Yahoo Finance rate limited, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries})") logger.warning(f"Yahoo Finance rate limited, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries})")
time.sleep(delay) time.sleep(delay)
else: else:
raise raise VendorRateLimitError(
f"Yahoo Finance rate limited after {max_retries} retries: {exc}"
) from exc
def _ensure_date_column(data: pd.DataFrame) -> pd.DataFrame: def _ensure_date_column(data: pd.DataFrame) -> pd.DataFrame:
@@ -87,7 +94,7 @@ def _local_midnight(value) -> pd.Timestamp:
def _normalize_dates(dates) -> pd.Series: def _normalize_dates(dates) -> pd.Series:
"""Parse to naive, midnight-normalized dates so tz-aware or intraday """Parse to naive, midnight-normalized dates so tz-aware or intraday
timestamps compare correctly against the naive ``curr_date`` cutoff (#1201). timestamps compare correctly against the naive ``as_of_date`` cutoff (#1201).
Normalized per element: 5 years of yfinance bars span daylight-saving 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 changes (and cache CSVs round-trip the offsets as strings), so the series can
@@ -141,13 +148,13 @@ def _coerce_ohlcv_dates(data: pd.DataFrame) -> pd.Series:
def _assert_ohlcv_not_stale( def _assert_ohlcv_not_stale(
data: pd.DataFrame, data: pd.DataFrame,
curr_date: str, as_of_date: str,
symbol: str, symbol: str,
canonical: str | None = None, canonical: str | None = None,
*, *,
max_stale_days: int = MAX_OHLCV_STALE_DAYS, max_stale_days: int = MAX_OHLCV_STALE_DAYS,
) -> None: ) -> None:
"""Reject OHLCV whose latest row is far older than curr_date. """Reject OHLCV whose latest row is far older than as_of_date.
Raises NoMarketDataError (with a stale-specific detail) so the router treats Raises NoMarketDataError (with a stale-specific detail) so the router treats
it like any other "no usable data from this vendor" — try the next vendor, it like any other "no usable data from this vendor" — try the next vendor,
@@ -158,7 +165,7 @@ def _assert_ohlcv_not_stale(
""" """
if data is None or data.empty: if data is None or data.empty:
return return
requested = pd.to_datetime(curr_date, errors="coerce") requested = pd.to_datetime(as_of_date, errors="coerce")
if pd.isna(requested): if pd.isna(requested):
return return
requested = requested.normalize() requested = requested.normalize()
@@ -176,7 +183,7 @@ def _assert_ohlcv_not_stale(
) )
def _cache_is_fresh(data_file, curr_date_dt, now) -> bool: def _cache_is_fresh(data_file, as_of_dt, now) -> bool:
"""Whether the symbol's cached download can serve this request. """Whether the symbol's cached download can serve this request.
The file holds the download made on the day it was written, so it serves The file holds the download made on the day it was written, so it serves
@@ -188,14 +195,14 @@ def _cache_is_fresh(data_file, curr_date_dt, now) -> bool:
written = pd.Timestamp.fromtimestamp(os.path.getmtime(data_file)) written = pd.Timestamp.fromtimestamp(os.path.getmtime(data_file))
if written.date() != now.date(): if written.date() != now.date():
return False return False
return curr_date_dt.date() < now.date() or (now - written).total_seconds() <= OHLCV_CACHE_TTL_SECONDS return as_of_dt.date() < now.date() or (now - written).total_seconds() <= OHLCV_CACHE_TTL_SECONDS
def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFrame: def load_ohlcv(symbol: str, as_of_date: str, fill_gaps: bool = True) -> pd.DataFrame:
"""Fetch OHLCV data with caching, filtered to prevent look-ahead bias. """Fetch OHLCV data with caching, filtered to prevent look-ahead bias.
Downloads 5 years of data up to today and caches per symbol. On Downloads 5 years of data up to today and caches per symbol. On
subsequent calls the cache is reused. Rows after curr_date are subsequent calls the cache is reused. Rows after as_of_date are
filtered out so backtests never see future prices. filtered out so backtests never see future prices.
``fill_gaps`` carries prices forward over gaps so indicators compute on a ``fill_gaps`` carries prices forward over gaps so indicators compute on a
@@ -209,15 +216,15 @@ def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFr
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).normalize() as_of_dt = pd.to_datetime(as_of_date).normalize()
# One cache file per symbol, holding the latest 5y-to-today download. # One cache file per symbol, holding the latest 5y-to-today download.
now = pd.Timestamp.today() now = pd.Timestamp.today()
start_date = now - pd.DateOffset(years=5) start_date = now - pd.DateOffset(years=5)
start_str = start_date.strftime("%Y-%m-%d") start_str = start_date.strftime("%Y-%m-%d")
# yfinance ``end`` is EXCLUSIVE; request tomorrow so today's row is included # yfinance ``end`` is EXCLUSIVE; request tomorrow so today's row is included
# when curr_date is the current day (#986). Look-ahead is still prevented by # when as_of_date is the current day (#986). Look-ahead is still prevented by
# the curr_date filter below. # the as_of_date filter below.
end_str = (now + pd.Timedelta(days=1)).strftime("%Y-%m-%d") end_str = (now + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
os.makedirs(config["data_cache_dir"], exist_ok=True) os.makedirs(config["data_cache_dir"], exist_ok=True)
@@ -235,30 +242,38 @@ def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFr
if ( if (
not cached.empty not cached.empty
and "Close" in cached.columns and "Close" in cached.columns
and _cache_is_fresh(data_file, curr_date_dt, now) and _cache_is_fresh(data_file, as_of_dt, now)
): ):
data = cached data = cached
if data is None: if data is None:
downloaded = yf_retry(lambda: yf.download( # yf.download catches every error, a rate limit included, and returns
canonical, # an empty frame. Ticker.history raises the rate limit, so it is retried.
try:
downloaded = yf_retry(lambda: yf.Ticker(canonical).history(
start=start_str, start=start_str,
end=end_str, end=end_str,
multi_level_index=False,
progress=False,
auto_adjust=True, auto_adjust=True,
actions=False,
)) ))
except VendorRateLimitError:
raise
except Exception as exc:
# Any other failure is an outage or an unknown symbol, which
# raise_for_empty tells apart by whether Yahoo answers at all.
logger.warning("Yahoo Finance price request for %s failed: %s", canonical, exc)
raise_for_empty(symbol, canonical, "price rows")
downloaded = _ensure_date_column(downloaded.reset_index()) downloaded = _ensure_date_column(downloaded.reset_index())
# Only cache real data — never persist an empty frame. # Only cache real data — never persist an empty frame.
if downloaded.empty or "Close" not in downloaded.columns: if downloaded.empty or "Close" not in downloaded.columns:
raise_for_empty(symbol, canonical, "price rows") raise_for_empty(symbol, canonical, "price rows")
downloaded.to_csv(data_file, index=False, encoding="utf-8") replace_file(data_file, lambda temp: downloaded.to_csv(temp, index=False, encoding="utf-8"))
data = downloaded data = downloaded
data = _clean_dataframe(data) data = _clean_dataframe(data)
# Filter to curr_date to prevent look-ahead bias in backtesting. # Filter to as_of_date to prevent look-ahead bias in backtesting.
data = data[data["Date"] <= curr_date_dt] data = data[data["Date"] <= as_of_dt]
# A closeless newest bar is an unsettled session, not a symbol without data. # A closeless newest bar is an unsettled session, not a symbol without data.
# _fill_price_gaps below drops it, here and mid-series alike, so the frame # _fill_price_gaps below drops it, here and mid-series alike, so the frame
@@ -281,9 +296,9 @@ def load_ohlcv(symbol: str, curr_date: str, fill_gaps: bool = True) -> pd.DataFr
# a filled cell is the previous session's price under this session's date. # a filled cell is the previous session's price under this session's date.
data = _fill_price_gaps(data) if fill_gaps else data.dropna(subset=["Close"]).copy() data = _fill_price_gaps(data) if fill_gaps else data.dropna(subset=["Close"]).copy()
# Reject a stale frame (latest row far older than curr_date) rather than # Reject a stale frame (latest row far older than as_of_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, as_of_date, symbol, canonical)
return data return data
+8 -8
View File
@@ -25,8 +25,8 @@ DEFAULT_SNAPSHOT_INDICATORS: tuple[str, ...] = (
) )
def _verified_rows(symbol: str, curr_date: str) -> pd.DataFrame: def _verified_rows(symbol: str, as_of_date: str) -> pd.DataFrame:
"""OHLCV on or before curr_date, date-sorted. Raises if nothing usable. """OHLCV on or before as_of_date, date-sorted. Raises if nothing usable.
``load_ohlcv`` already normalizes the Date column and filters out ``load_ohlcv`` already normalizes the Date column and filters out
look-ahead rows, but we re-apply the cutoff defensively — this is a look-ahead rows, but we re-apply the cutoff defensively — this is a
@@ -34,16 +34,16 @@ def _verified_rows(symbol: str, curr_date: str) -> pd.DataFrame:
""" """
# As reported: this snapshot is quoted by the agents as exact prices, so a # As reported: this snapshot is quoted by the agents as exact prices, so a
# gap-filled cell would put the previous session's number under this date. # gap-filled cell would put the previous session's number under this date.
data = load_ohlcv(symbol, curr_date, fill_gaps=False) data = load_ohlcv(symbol, as_of_date, fill_gaps=False)
if data is None or data.empty: if data is None or data.empty:
raise ValueError(f"No OHLCV data available for {symbol}.") raise ValueError(f"No OHLCV data available for {symbol}.")
df = data.copy() df = data.copy()
df["Date"] = pd.to_datetime(df["Date"], errors="coerce") df["Date"] = pd.to_datetime(df["Date"], errors="coerce")
df = df.dropna(subset=["Date"]) df = df.dropna(subset=["Date"])
df = df[df["Date"] <= pd.to_datetime(curr_date)].sort_values("Date") df = df[df["Date"] <= pd.to_datetime(as_of_date)].sort_values("Date")
if df.empty: if df.empty:
raise ValueError(f"No OHLCV rows on or before {curr_date} for {symbol}.") raise ValueError(f"No OHLCV rows on or before {as_of_date} for {symbol}.")
return df return df
@@ -63,7 +63,7 @@ def _fmt(value) -> str:
def build_verified_market_snapshot( def build_verified_market_snapshot(
symbol: str, symbol: str,
curr_date: str, as_of_date: str,
look_back_days: int = 30, look_back_days: int = 30,
indicators: Iterable[str] | None = None, indicators: Iterable[str] | None = None,
) -> str: ) -> str:
@@ -71,7 +71,7 @@ def build_verified_market_snapshot(
# `df` keeps the original capitalized OHLCV columns (Open/High/Low/Close/ # `df` keeps the original capitalized OHLCV columns (Open/High/Low/Close/
# Volume); stockstats `wrap()` lowercases columns and adds indicator # Volume); stockstats `wrap()` lowercases columns and adds indicator
# columns, so read raw prices from `df` and indicators from `stock_df`. # columns, so read raw prices from `df` and indicators from `stock_df`.
df = _verified_rows(symbol, curr_date) df = _verified_rows(symbol, as_of_date)
stock_df = wrap(df.copy()) stock_df = wrap(df.copy())
selected = tuple(indicators or DEFAULT_SNAPSHOT_INDICATORS) selected = tuple(indicators or DEFAULT_SNAPSHOT_INDICATORS)
@@ -91,7 +91,7 @@ def build_verified_market_snapshot(
lines = [ lines = [
f"## Verified market data snapshot for {symbol.upper()}", f"## Verified market data snapshot for {symbol.upper()}",
"", "",
f"- Requested analysis date: {curr_date}", f"- Requested analysis date: {as_of_date}",
f"- Latest trading row used: {latest_date}", f"- Latest trading row used: {latest_date}",
"- Rows after the requested analysis date are excluded before verification.", "- Rows after the requested analysis date are excluded before verification.",
"", "",
+10
View File
@@ -161,8 +161,18 @@ DEFAULT_CONFIG = _apply_env_overrides({
".NS": "^NSEI", # NSE India (Nifty 50) ".NS": "^NSEI", # NSE India (Nifty 50)
".BO": "^BSESN", # BSE India (Sensex) ".BO": "^BSESN", # BSE India (Sensex)
".T": "^N225", # Tokyo (Nikkei 225) ".T": "^N225", # Tokyo (Nikkei 225)
".TW": "^TWII", # Taiwan (TAIEX)
".TWO": "^TWII", # Taipei OTC (TPEx has no Yahoo index; TAIEX)
".KS": "^KS11", # Korea (KOSPI)
".KQ": "^KQ11", # Korea (KOSDAQ)
".HK": "^HSI", # Hong Kong (Hang Seng) ".HK": "^HSI", # Hong Kong (Hang Seng)
".SI": "^STI", # Singapore (Straits Times)
".L": "^FTSE", # London (FTSE 100) ".L": "^FTSE", # London (FTSE 100)
".DE": "^GDAXI", # Germany (DAX)
".PA": "^FCHI", # Paris (CAC 40)
".AS": "^AEX", # Amsterdam (AEX)
".SW": "^SSMI", # Switzerland (SMI)
".MI": "FTSEMIB.MI", # Milan (FTSE MIB)
".TO": "^GSPTSE", # Toronto (TSX Composite) ".TO": "^GSPTSE", # Toronto (TSX Composite)
".AX": "^AXJO", # Australia (ASX 200) ".AX": "^AXJO", # Australia (ASX 200)
".SS": "000001.SS", # Shanghai (SSE Composite) ".SS": "000001.SS", # Shanghai (SSE Composite)
-2
View File
@@ -1,6 +1,5 @@
from .conditional_logic import ConditionalLogic from .conditional_logic import ConditionalLogic
from .propagation import Propagator from .propagation import Propagator
from .reflection import Reflector
from .setup import GraphSetup from .setup import GraphSetup
from .trading_graph import TradingAgentsGraph from .trading_graph import TradingAgentsGraph
@@ -9,5 +8,4 @@ __all__ = [
"ConditionalLogic", "ConditionalLogic",
"GraphSetup", "GraphSetup",
"Propagator", "Propagator",
"Reflector",
] ]
-10
View File
@@ -8,15 +8,9 @@ from tradingagents.agents.analysts import fundamentals_analyst, market_analyst,
class AnalystNodeSpec: class AnalystNodeSpec:
key: str key: str
agent_node: str agent_node: str
clear_node: str
report_key: str report_key: str
tools: tuple = () tools: tuple = ()
@property
def tool_node(self) -> str | None:
"""The node that runs this analyst's tool calls; None when it has no tools."""
return f"tools_{self.key}" if self.tools else None
@dataclass(frozen=True) @dataclass(frozen=True)
class AnalystExecutionPlan: class AnalystExecutionPlan:
@@ -27,7 +21,6 @@ ANALYST_NODE_SPECS: dict[str, AnalystNodeSpec] = {
"market": AnalystNodeSpec( "market": AnalystNodeSpec(
key="market", key="market",
agent_node="Market Analyst", agent_node="Market Analyst",
clear_node="Msg Clear Market",
report_key="market_report", report_key="market_report",
tools=market_analyst.TOOLS, tools=market_analyst.TOOLS,
), ),
@@ -36,20 +29,17 @@ ANALYST_NODE_SPECS: dict[str, AnalystNodeSpec] = {
# sources before calling the model, so it has no tools. # sources before calling the model, so it has no tools.
key="social", key="social",
agent_node="Sentiment Analyst", agent_node="Sentiment Analyst",
clear_node="Msg Clear Sentiment",
report_key="sentiment_report", report_key="sentiment_report",
), ),
"news": AnalystNodeSpec( "news": AnalystNodeSpec(
key="news", key="news",
agent_node="News Analyst", agent_node="News Analyst",
clear_node="Msg Clear News",
report_key="news_report", report_key="news_report",
tools=news_analyst.TOOLS, tools=news_analyst.TOOLS,
), ),
"fundamentals": AnalystNodeSpec( "fundamentals": AnalystNodeSpec(
key="fundamentals", key="fundamentals",
agent_node="Fundamentals Analyst", agent_node="Fundamentals Analyst",
clear_node="Msg Clear Fundamentals",
report_key="fundamentals_report", report_key="fundamentals_report",
tools=fundamentals_analyst.TOOLS, tools=fundamentals_analyst.TOOLS,
), ),
+2 -2
View File
@@ -14,7 +14,7 @@ class ConditionalLogic:
if ( if (
state["investment_debate_state"]["count"] >= 2 * self.max_debate_rounds state["investment_debate_state"]["count"] >= 2 * self.max_debate_rounds
): # 3 rounds of back-and-forth between 2 agents ): # max_debate_rounds turns each for bull and bear
return "Research Manager" return "Research Manager"
if state["investment_debate_state"]["current_response"].startswith("Bull"): if state["investment_debate_state"]["current_response"].startswith("Bull"):
return "Bear Researcher" return "Bear Researcher"
@@ -24,7 +24,7 @@ class ConditionalLogic:
"""Determine if risk analysis should continue.""" """Determine if risk analysis should continue."""
if ( if (
state["risk_debate_state"]["count"] >= 3 * self.max_risk_discuss_rounds state["risk_debate_state"]["count"] >= 3 * self.max_risk_discuss_rounds
): # 3 rounds of back-and-forth between 3 agents ): # max_risk_discuss_rounds turns each for the three risk analysts
return "Portfolio Manager" return "Portfolio Manager"
if state["risk_debate_state"]["latest_speaker"].startswith("Aggressive"): if state["risk_debate_state"]["latest_speaker"].startswith("Aggressive"):
return "Conservative Analyst" return "Conservative Analyst"
-2
View File
@@ -41,7 +41,6 @@ class Propagator:
"bear_history": "", "bear_history": "",
"history": "", "history": "",
"current_response": "", "current_response": "",
"judge_decision": "",
"count": 0, "count": 0,
} }
), ),
@@ -55,7 +54,6 @@ class Propagator:
"current_aggressive_response": "", "current_aggressive_response": "",
"current_conservative_response": "", "current_conservative_response": "",
"current_neutral_response": "", "current_neutral_response": "",
"judge_decision": "",
"count": 0, "count": 0,
} }
), ),
+30 -25
View File
@@ -1,4 +1,4 @@
from typing import Any from typing import Any, TypedDict
from langgraph.graph import END, START, StateGraph from langgraph.graph import END, START, StateGraph
from langgraph.prebuilt import ToolNode from langgraph.prebuilt import ToolNode
@@ -10,7 +10,6 @@ from tradingagents.agents import (
create_conservative_debator, create_conservative_debator,
create_fundamentals_analyst, create_fundamentals_analyst,
create_market_analyst, create_market_analyst,
create_msg_delete,
create_neutral_debator, create_neutral_debator,
create_news_analyst, create_news_analyst,
create_portfolio_manager, create_portfolio_manager,
@@ -40,11 +39,28 @@ RISK_ANALYSIS_PATH_MAP = {
} }
def _tools_or_clear(spec): def _tools_or_done(state) -> str:
"""Route an analyst's turn: run its tool calls, or finish its report.""" """Route an analyst's turn: run its tool calls, or finish with its report."""
def route(state) -> str: return "tools" if state["messages"][-1].tool_calls else END
return spec.tool_node if state["messages"][-1].tool_calls else spec.clear_node
return route
def _analyst_graph(spec, agent):
"""One analyst as a graph of its own: the model and its tools, on a private message history.
It returns only its report, so analysts running side by side never write the
same key, and its tool calls never reach the other analysts' messages.
"""
output = TypedDict(f"{spec.key.capitalize()}Report", {spec.report_key: str})
graph = StateGraph(AgentState, output_schema=output)
graph.add_node("agent", agent)
graph.add_edge(START, "agent")
if spec.tools:
graph.add_node("tools", ToolNode(list(spec.tools)))
graph.add_conditional_edges("agent", _tools_or_done, ["tools", END])
graph.add_edge("tools", "agent")
else:
graph.add_edge("agent", END)
return graph.compile()
class GraphSetup: class GraphSetup:
@@ -95,10 +111,7 @@ class GraphSetup:
workflow = StateGraph(AgentState) workflow = StateGraph(AgentState)
for spec in plan.specs: for spec in plan.specs:
workflow.add_node(spec.agent_node, analyst_factories[spec.key]()) workflow.add_node(spec.agent_node, _analyst_graph(spec, analyst_factories[spec.key]()))
workflow.add_node(spec.clear_node, create_msg_delete())
if spec.tools:
workflow.add_node(spec.tool_node, ToolNode(list(spec.tools)))
workflow.add_node("Bull Researcher", bull_researcher_node) workflow.add_node("Bull Researcher", bull_researcher_node)
workflow.add_node("Bear Researcher", bear_researcher_node) workflow.add_node("Bear Researcher", bear_researcher_node)
@@ -109,20 +122,12 @@ class GraphSetup:
workflow.add_node("Conservative Analyst", conservative_analyst) workflow.add_node("Conservative Analyst", conservative_analyst)
workflow.add_node("Portfolio Manager", portfolio_manager_node) workflow.add_node("Portfolio Manager", portfolio_manager_node)
workflow.add_edge(START, plan.specs[0].agent_node) # The analysts work at the same time; the research debate starts once
# every one of them has filed its report.
for i, spec in enumerate(plan.specs): analysts = [spec.agent_node for spec in plan.specs]
if spec.tools: for node in analysts:
workflow.add_conditional_edges( workflow.add_edge(START, node)
spec.agent_node, _tools_or_clear(spec), [spec.tool_node, spec.clear_node] workflow.add_edge(analysts, "Bull Researcher")
)
workflow.add_edge(spec.tool_node, spec.agent_node)
else:
workflow.add_edge(spec.agent_node, spec.clear_node)
# The last analyst hands over to the research debate.
following = plan.specs[i + 1].agent_node if i < len(plan.specs) - 1 else "Bull Researcher"
workflow.add_edge(spec.clear_node, following)
# Both research-debate edges share the complete DEBATE_PATH_MAP (#1088). # Both research-debate edges share the complete DEBATE_PATH_MAP (#1088).
for debate_node in ("Bull Researcher", "Bear Researcher"): for debate_node in ("Bull Researcher", "Bear Researcher"):
+68 -36
View File
@@ -6,21 +6,21 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import tradingagents
from tradingagents.agents.context import build_instrument_context, resolve_instrument_identity from tradingagents.agents.context import build_instrument_context, resolve_instrument_identity
from tradingagents.agents.rating import parse_rating from tradingagents.agents.rating import run_rating
from tradingagents.dataflows.config import run_config, set_config from tradingagents.dataflows.config import run_config, set_config
from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.date_window import get_current_date
from tradingagents.dataflows.symbols import safe_ticker_component from tradingagents.dataflows.symbols import safe_ticker_component
from tradingagents.decision_log import TradingMemoryLog
from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.default_config import DEFAULT_CONFIG
from tradingagents.llm_clients import build_llm_kwargs, create_llm_client from tradingagents.llm_clients import build_llm_kwargs, create_llm_client
from tradingagents.memory import TradingMemoryLog, settlement
from tradingagents.memory.reflection import Reflector
from tradingagents.reporting import write_report_tree from tradingagents.reporting import write_report_tree
from . import settlement
from .checkpointer import checkpoint_step, clear_checkpoint, get_checkpointer, thread_id from .checkpointer import checkpoint_step, clear_checkpoint, get_checkpointer, thread_id
from .conditional_logic import ConditionalLogic from .conditional_logic import ConditionalLogic
from .propagation import Propagator from .propagation import Propagator
from .reflection import Reflector
from .setup import GraphSetup from .setup import GraphSetup
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -115,7 +115,7 @@ class TradingAgentsGraph:
self._resuming = False self._resuming = False
def resolve_instrument_context(self, ticker: str, asset_type: str = "stock", def resolve_instrument_context(self, ticker: str, asset_type: str = "stock",
curr_date: str | None = None) -> str: trade_date: str | None = None) -> str:
"""Resolve ticker identity once and return the full instrument context. """Resolve ticker identity once and return the full instrument context.
Deterministic yfinance lookup (cached, fail-open) injected into a Deterministic yfinance lookup (cached, fail-open) injected into a
@@ -125,7 +125,7 @@ class TradingAgentsGraph:
graph regardless of entry point. graph regardless of entry point.
""" """
identity = resolve_instrument_identity(ticker) identity = resolve_instrument_identity(ticker)
return build_instrument_context(ticker, asset_type, identity, curr_date) return build_instrument_context(ticker, asset_type, identity, trade_date)
def _memory_as_of(self, trade_date) -> str | None: def _memory_as_of(self, trade_date) -> str | None:
"""Point-in-time cutoff for past-context lessons (#1251). """Point-in-time cutoff for past-context lessons (#1251).
@@ -152,6 +152,9 @@ class TradingAgentsGraph:
f"asset={asset_type}", f"asset={asset_type}",
# None, an empty book and a changed book are three different runs. # None, an empty book and a changed book are three different runs.
f"portfolio={portfolio.fingerprint() if portfolio is not None else 'none'}", f"portfolio={portfolio.fingerprint() if portfolio is not None else 'none'}",
# The layout itself: a checkpoint saved when analysts ran one after
# another has pending nodes this graph no longer has.
"analysts=parallel",
]) ])
def propagate(self, company_name, trade_date, asset_type: str = "stock", portfolio=None): def propagate(self, company_name, trade_date, asset_type: str = "stock", portfolio=None):
@@ -242,6 +245,26 @@ class TradingAgentsGraph:
self._run_signature(asset_type, portfolio), self._run_signature(asset_type, portfolio),
) )
def run_settings(self) -> dict:
"""What produces this graph's runs, for the saved report and state log.
An allowlist: endpoints (a backend_url can carry credentials), keys and
local paths are never recorded.
"""
cfg = self.config
return {
"version": tradingagents.__version__,
"llm_provider": cfg.get("llm_provider"),
"deep_think_llm": cfg.get("deep_think_llm"),
"quick_think_llm": cfg.get("quick_think_llm"),
"analysts": list(self.selected_analysts),
"max_debate_rounds": cfg.get("max_debate_rounds"),
"max_risk_discuss_rounds": cfg.get("max_risk_discuss_rounds"),
"output_language": cfg.get("output_language"),
"data_vendors": dict(cfg.get("data_vendors") or {}),
"tool_vendors": dict(cfg.get("tool_vendors") or {}),
}
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.
@@ -255,7 +278,7 @@ class TradingAgentsGraph:
/ "reports" / "reports"
/ f"{safe_ticker_component(ticker)}_{stamp}" / f"{safe_ticker_component(ticker)}_{stamp}"
) )
return write_report_tree(final_state, ticker, save_path) return write_report_tree(final_state, ticker, save_path, settings=self.run_settings())
def create_run_state(self, company_name, trade_date, asset_type: str = "stock", portfolio=None): def create_run_state(self, company_name, trade_date, asset_type: str = "stock", portfolio=None):
"""Build a run's initial state; propagate() and the CLI both start here. """Build a run's initial state; propagate() and the CLI both start here.
@@ -263,7 +286,7 @@ class TradingAgentsGraph:
Settles this ticker's pending decisions first, then injects the lessons Settles this ticker's pending decisions first, then injects the lessons
known by the trade date for the Portfolio Manager (#1251) and the known by the trade date for the Portfolio Manager (#1251) and the
resolved instrument identity for every agent (#814). An entry point that resolved instrument identity for every agent (#814). An entry point that
assembled the state itself would skip the decision log. assembled the state itself would skip the memory log.
""" """
self.settle_pending(company_name) self.settle_pending(company_name)
return self.propagator.create_initial_state( return self.propagator.create_initial_state(
@@ -295,7 +318,8 @@ class TradingAgentsGraph:
logger.warning("No final decision for %s on %s; nothing logged", company_name, trade_date) logger.warning("No final decision for %s on %s; nothing logged", company_name, trade_date)
return return
self.memory_log.store_decision( self.memory_log.store_decision(
ticker=company_name, trade_date=trade_date, final_trade_decision=decision ticker=company_name, trade_date=trade_date, final_trade_decision=decision,
rating=run_rating(final_state),
) )
def _run_graph(self, company_name, trade_date, asset_type: str = "stock", def _run_graph(self, company_name, trade_date, asset_type: str = "stock",
@@ -312,24 +336,16 @@ class TradingAgentsGraph:
# None resumes an existing checkpoint; init_agent_state starts fresh (#1249). # None resumes an existing checkpoint; init_agent_state starts fresh (#1249).
graph_input = self.checkpoint_input(init_agent_state) graph_input = self.checkpoint_input(init_agent_state)
if self.debug: if self.debug:
trace = [] # A state repeats the messages before it, so each prints once (#1027).
last_printed = None final_state, printed = {}, set()
for chunk in self.graph.stream(graph_input, **args): for messages, state in self.stream_run(graph_input, **args):
if chunk["messages"]: for msg in messages:
msg = chunk["messages"][-1] key = getattr(msg, "id", None) or (type(msg).__name__, getattr(msg, "content", None))
# Nodes after the trader don't append to messages, so the if key not in printed:
# same trailing message repeats across chunks. Print it only printed.add(key)
# when it changes (#1027); the trace/state merge is unchanged.
signature = (type(msg).__name__, getattr(msg, "content", None))
if signature != last_printed:
msg.pretty_print() msg.pretty_print()
last_printed = signature if state is not None:
trace.append(chunk) final_state.update(state)
# Streamed chunks are per-node deltas. Merge them so the returned
# state matches what graph.invoke() yields in the non-debug path.
final_state = {}
for chunk in trace:
final_state.update(chunk)
else: else:
final_state = self.graph.invoke(graph_input, **args) final_state = self.graph.invoke(graph_input, **args)
@@ -341,7 +357,29 @@ class TradingAgentsGraph:
# Clear checkpoint on successful completion to avoid stale state. # Clear checkpoint on successful completion to avoid stale state.
self.clear_checkpoint_on_success(company_name, trade_date, asset_type, portfolio) self.clear_checkpoint_on_success(company_name, trade_date, asset_type, portfolio)
return final_state, self.process_signal(final_state["final_trade_decision"]) return final_state, run_rating(final_state)
def stream_run(self, graph_input, **args):
"""Stream a run as ``(messages, state)`` pairs.
``messages`` are the agents' messages, the analysts' included. ``state``
is the run's state after a top-level step; for a step inside an analyst's
graph it is that analyst's report once filed, else None.
Each analyst works in a graph of its own, and the run's state takes the
analysts' reports only when the slowest has finished, so their messages
and reports come from their own finished steps ("tasks") as they happen.
"""
args = {**args, "stream_mode": ["values", "tasks"]}
for namespace, mode, chunk in self.graph.stream(graph_input, subgraphs=True, **args):
if namespace:
result = chunk.get("result") if mode == "tasks" else None
if isinstance(result, dict):
report = {k: v for k, v in result.items() if k != "messages" and v}
if result.get("messages") or report:
yield result.get("messages", []), report or None
elif mode == "values":
yield chunk.get("messages", []), chunk
def _log_state(self, trade_date, final_state): def _log_state(self, trade_date, final_state):
"""Write a run's final state to JSON under the run's own ticker.""" """Write a run's final state to JSON under the run's own ticker."""
@@ -359,20 +397,18 @@ class TradingAgentsGraph:
"current_response": final_state["investment_debate_state"][ "current_response": final_state["investment_debate_state"][
"current_response" "current_response"
], ],
"judge_decision": final_state["investment_debate_state"][
"judge_decision"
],
}, },
"trader_investment_decision": final_state["trader_investment_plan"], "trader_investment_plan": final_state["trader_investment_plan"],
"risk_debate_state": { "risk_debate_state": {
"aggressive_history": final_state["risk_debate_state"]["aggressive_history"], "aggressive_history": final_state["risk_debate_state"]["aggressive_history"],
"conservative_history": final_state["risk_debate_state"]["conservative_history"], "conservative_history": final_state["risk_debate_state"]["conservative_history"],
"neutral_history": final_state["risk_debate_state"]["neutral_history"], "neutral_history": final_state["risk_debate_state"]["neutral_history"],
"history": final_state["risk_debate_state"]["history"], "history": final_state["risk_debate_state"]["history"],
"judge_decision": final_state["risk_debate_state"]["judge_decision"],
}, },
"investment_plan": final_state["investment_plan"], "investment_plan": final_state["investment_plan"],
"final_trade_decision": final_state["final_trade_decision"], "final_trade_decision": final_state["final_trade_decision"],
"final_rating": run_rating(final_state),
"run_settings": self.run_settings(),
} }
# A ticker that would escape the results directory is rejected. # A ticker that would escape the results directory is rejected.
@@ -384,7 +420,3 @@ class TradingAgentsGraph:
with open(log_path, "w", encoding="utf-8") as f: with open(log_path, "w", encoding="utf-8") as f:
# Reports can be in any language and this file is read by a person. # Reports can be in any language and this file is read by a person.
json.dump(entry, f, indent=4, ensure_ascii=False) json.dump(entry, f, indent=4, ensure_ascii=False)
def process_signal(self, full_signal):
"""The decision's 5-tier rating, or REVIEW when it has none."""
return parse_rating(full_signal)
+6 -1
View File
@@ -94,6 +94,7 @@ _DEFAULT = ModelCapabilities(
_BY_ID: dict[str, ModelCapabilities] = { _BY_ID: dict[str, ModelCapabilities] = {
"deepseek-chat": _DEEPSEEK_CHAT, "deepseek-chat": _DEEPSEEK_CHAT,
"deepseek-reasoner": _DEEPSEEK_THINKING, "deepseek-reasoner": _DEEPSEEK_THINKING,
"deepseek-flash": _DEEPSEEK_THINKING,
"deepseek-v4-flash": _DEEPSEEK_THINKING, "deepseek-v4-flash": _DEEPSEEK_THINKING,
"deepseek-v4-pro": _DEEPSEEK_THINKING, "deepseek-v4-pro": _DEEPSEEK_THINKING,
# MiniMax — full official model lineup per # MiniMax — full official model lineup per
@@ -108,10 +109,14 @@ _BY_ID: dict[str, ModelCapabilities] = {
} }
# Forward-compat patterns. New ``deepseek-v5-*`` / ``deepseek-reasoner-*`` # Forward-compat patterns. New ``deepseek-v5-*`` / ``deepseek-reasoner-*``
# or ``MiniMax-M3*`` variants inherit the thinking-mode quirks automatically. # / ``deepseek-flash-*`` or ``MiniMax-M3*`` variants inherit the thinking-mode
# quirks automatically. ``deepseek-flash`` is the unversioned alias the model
# picker offers for V4.1 Flash; it serves thinking mode and so rejects
# ``tool_choice`` exactly like the versioned ``deepseek-v4-flash`` ID does.
_BY_PATTERN: list[tuple[re.Pattern[str], ModelCapabilities]] = [ _BY_PATTERN: list[tuple[re.Pattern[str], ModelCapabilities]] = [
(re.compile(r"^deepseek-v\d"), _DEEPSEEK_THINKING), (re.compile(r"^deepseek-v\d"), _DEEPSEEK_THINKING),
(re.compile(r"^deepseek-reasoner"), _DEEPSEEK_THINKING), (re.compile(r"^deepseek-reasoner"), _DEEPSEEK_THINKING),
(re.compile(r"^deepseek-flash"), _DEEPSEEK_THINKING),
(re.compile(r"^MiniMax-M\d"), _MINIMAX_THINKING), (re.compile(r"^MiniMax-M\d"), _MINIMAX_THINKING),
] ]
+10
View File
@@ -0,0 +1,10 @@
"""The memory log: each decision recorded as made, settled against the market, and reflected on.
``log`` keeps the entries, ``settlement`` measures a decision's return once its
holding window has traded, and ``reflection`` turns that outcome into a lesson
the next run of the same ticker reads.
"""
from tradingagents.memory.log import TradingMemoryLog
__all__ = ["TradingMemoryLog"]
@@ -1,4 +1,4 @@
"""Append-only markdown decision log for TradingAgents.""" """The memory log: an append-only markdown record of each decision and, once settled, its outcome."""
import re import re
from pathlib import Path from pathlib import Path
@@ -25,15 +25,20 @@ class TradingMemoryLog:
# Optional cap on resolved entries. None disables rotation. # Optional cap on resolved entries. None disables rotation.
self._max_entries = cfg.get("memory_log_max_entries") self._max_entries = cfg.get("memory_log_max_entries")
# --- Write path (Phase A) --- # --- Write: a run records its decision ---
def store_decision( def store_decision(
self, self,
ticker: str, ticker: str,
trade_date: str, trade_date: str,
final_trade_decision: str, final_trade_decision: str,
rating: str | None = None,
) -> None: ) -> None:
"""Append pending entry at end of propagate(). No LLM call.""" """Append pending entry at end of propagate(). No LLM call.
``rating`` is the decision's own rating when the caller has it; without
one it is read from the decision text.
"""
if not self._log_path: if not self._log_path:
return return
# Idempotency guard: fast raw-text scan instead of full parse. Any entry # Idempotency guard: fast raw-text scan instead of full parse. Any entry
@@ -45,13 +50,13 @@ class TradingMemoryLog:
for line in raw.splitlines(): for line in raw.splitlines():
if line.startswith(f"[{trade_date} | {ticker} |") and line.endswith("]"): if line.startswith(f"[{trade_date} | {ticker} |") and line.endswith("]"):
return return
rating = parse_rating(final_trade_decision) rating = rating or parse_rating(final_trade_decision)
tag = f"[{trade_date} | {ticker} | {rating} | pending]" tag = f"[{trade_date} | {ticker} | {rating} | pending]"
entry = f"{tag}\n\nDECISION:\n{final_trade_decision}{self._SEPARATOR}" entry = f"{tag}\n\nDECISION:\n{final_trade_decision}{self._SEPARATOR}"
with open(self._log_path, "a", encoding="utf-8") as f: with open(self._log_path, "a", encoding="utf-8") as f:
f.write(entry) f.write(entry)
# --- Read path (Phase A) --- # --- Read ---
def load_entries(self) -> list[dict]: def load_entries(self) -> list[dict]:
"""Parse all entries from log. Returns list of dicts.""" """Parse all entries from log. Returns list of dicts."""
@@ -67,7 +72,7 @@ class TradingMemoryLog:
return entries return entries
def get_pending_entries(self) -> list[dict]: def get_pending_entries(self) -> list[dict]:
"""Return entries with outcome:pending (for Phase B).""" """Return entries with outcome:pending, for settlement."""
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( def get_past_context(
@@ -109,7 +114,7 @@ class TradingMemoryLog:
parts.extend(self._format_reflection_only(e) for e in cross) parts.extend(self._format_reflection_only(e) for e in cross)
return "\n\n".join(parts) return "\n\n".join(parts)
# --- Update path (Phase B) --- # --- Settle: record a decision's outcome and reflection ---
def update_with_outcome( def update_with_outcome(
self, self,
@@ -1,3 +1,5 @@
"""Reflection: a settled decision's outcome turned into a short lesson for later runs."""
from typing import Any from typing import Any
@@ -9,7 +11,7 @@ class Reflector:
self.quick_thinking_llm = quick_thinking_llm self.quick_thinking_llm = quick_thinking_llm
def _system_prompt(self, holding_days: int) -> str: def _system_prompt(self, holding_days: int) -> str:
"""Concise prompt for reflect_on_final_decision (Phase B log entries). """Concise prompt for reflect_on_final_decision (settled memory log entries).
Produces 2-4 sentences of plain prose, compact enough to be re-injected Produces 2-4 sentences of plain prose, compact enough to be re-injected
into future agent prompts without bloating the context window. The into future agent prompts without bloating the context window. The
@@ -27,7 +29,7 @@ class Reflector:
"and say so plainly if the window is too short to judge the thesis.\n" "and say so plainly if the window is too short to judge the thesis.\n"
"2. Which part of the investment thesis this window supports or undercuts.\n" "2. Which part of the investment thesis this window supports or undercuts.\n"
"3. One concrete lesson to apply to the next similar analysis.\n\n" "3. One concrete lesson to apply to the next similar analysis.\n\n"
"Be specific and terse. Your output will be stored verbatim in a decision log " "Be specific and terse. Your output will be stored verbatim in a memory log "
"and re-read by future analysts, so every word must earn its place." "and re-read by future analysts, so every word must earn its place."
) )
@@ -41,7 +43,7 @@ class Reflector:
) -> str: ) -> str:
"""Single reflection call on the final trade decision with outcome context. """Single reflection call on the final trade decision with outcome context.
Used by Phase B deferred reflection. The final_trade_decision already Used when a pending decision is settled. The final_trade_decision already
synthesises all analyst insights, so no separate market context is needed. synthesises all analyst insights, so no separate market context is needed.
``benchmark_name`` is the label used for the alpha line (e.g. ``"SPY"`` ``benchmark_name`` is the label used for the alpha line (e.g. ``"SPY"``
for US tickers, ``"^N225"`` for ``.T`` listings); defaults to SPY for for US tickers, ``"^N225"`` for ``.T`` listings); defaults to SPY for
@@ -1,5 +1,5 @@
"""Settling past decisions: once a decision's holding window has traded, score """Settling past decisions: once a decision's holding window has traded, score
it against its benchmark and record a reflection on it in the decision log.""" it against its benchmark and record a reflection on it in the memory log."""
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -16,10 +16,9 @@ def resolve_benchmark(ticker: str, config: dict) -> str:
``config["benchmark_ticker"]`` overrides everything when set; otherwise ``config["benchmark_ticker"]`` overrides everything when set; otherwise
the suffix map matches the ticker's exchange suffix (e.g. ``.T`` for the suffix map matches the ticker's exchange suffix (e.g. ``.T`` for
Tokyo). US-listed tickers without a dotted suffix fall through to the Tokyo). US-listed tickers without a dotted suffix fall through to the
empty-suffix entry (SPY by default). Unrecognised suffixes (including empty-suffix entry (SPY by default). Unrecognised suffixes, including
US tickers with dots like ``BRK.B``) also fall back to the empty-suffix US tickers with dots like ``BRK.B``, also take the empty-suffix entry.
entry, which is the right default because the alpha calculation works Returns are compared as percentages, each in its own currency.
in USD.
""" """
explicit = config.get("benchmark_ticker") explicit = config.get("benchmark_ticker")
+34 -10
View File
@@ -10,8 +10,31 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
def write_report_tree(final_state: dict, ticker: str, save_path) -> Path: def _header(ticker: str, final_state: dict, settings: dict | None) -> str:
"""Save a completed run's reports to ``save_path``; return the complete-report path.""" """The report's title and what produced it: analysis date, version, models, analysts, vendors."""
lines = [f"# Trading Analysis Report: {ticker}", ""]
if final_state.get("trade_date"):
lines.append(f"- Analysis date: {final_state['trade_date']}")
lines.append(f"- Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
if settings:
s = settings.get
lines.append(f"- TradingAgents {s('version', '?')}: {s('llm_provider', '?')}, "
f"deep {s('deep_think_llm', '?')}, quick {s('quick_think_llm', '?')}")
lines.append(f"- Analysts: {', '.join(s('analysts') or [])}; "
f"research debate rounds {s('max_debate_rounds', '?')}, "
f"risk debate rounds {s('max_risk_discuss_rounds', '?')}")
vendors = {**(s("data_vendors") or {}), **(s("tool_vendors") or {})}
if vendors:
lines.append("- Data vendors: " + ", ".join(f"{k} {v}" for k, v in vendors.items()))
return "\n".join(lines) + "\n\n"
def write_report_tree(final_state: dict, ticker: str, save_path, settings: dict | None = None) -> Path:
"""Save a completed run's reports to ``save_path``; return the complete-report path.
``settings`` (``TradingAgentsGraph.run_settings()``) adds what produced the run
to the report's header.
"""
save_path = Path(save_path) save_path = Path(save_path)
save_path.mkdir(parents=True, exist_ok=True) save_path.mkdir(parents=True, exist_ok=True)
sections = [] sections = []
@@ -52,10 +75,10 @@ def write_report_tree(final_state: dict, ticker: str, save_path) -> Path:
research_dir.mkdir(exist_ok=True) research_dir.mkdir(exist_ok=True)
(research_dir / "bear.md").write_text(debate["bear_history"], encoding="utf-8") (research_dir / "bear.md").write_text(debate["bear_history"], encoding="utf-8")
research_parts.append(("Bear Researcher", debate["bear_history"])) research_parts.append(("Bear Researcher", debate["bear_history"]))
if debate.get("judge_decision"): if final_state.get("investment_plan"):
research_dir.mkdir(exist_ok=True) research_dir.mkdir(exist_ok=True)
(research_dir / "manager.md").write_text(debate["judge_decision"], encoding="utf-8") (research_dir / "manager.md").write_text(final_state["investment_plan"], encoding="utf-8")
research_parts.append(("Research Manager", debate["judge_decision"])) research_parts.append(("Research Manager", final_state["investment_plan"]))
if research_parts: if research_parts:
content = "\n\n".join(f"### {name}\n{text}" for name, text in research_parts) content = "\n\n".join(f"### {name}\n{text}" for name, text in research_parts)
sections.append(f"## II. Research Team Decision\n\n{content}") sections.append(f"## II. Research Team Decision\n\n{content}")
@@ -89,13 +112,14 @@ def write_report_tree(final_state: dict, ticker: str, save_path) -> Path:
sections.append(f"## IV. Risk Management Team Decision\n\n{content}") sections.append(f"## IV. Risk Management Team Decision\n\n{content}")
# 5. Portfolio Manager # 5. Portfolio Manager
if risk.get("judge_decision"): if final_state.get("final_trade_decision"):
portfolio_dir = save_path / "5_portfolio" portfolio_dir = save_path / "5_portfolio"
portfolio_dir.mkdir(exist_ok=True) portfolio_dir.mkdir(exist_ok=True)
(portfolio_dir / "decision.md").write_text(risk["judge_decision"], encoding="utf-8") (portfolio_dir / "decision.md").write_text(final_state["final_trade_decision"], encoding="utf-8")
sections.append(f"## V. Portfolio Manager Decision\n\n### Portfolio Manager\n{risk['judge_decision']}") sections.append(f"## V. Portfolio Manager Decision\n\n### Portfolio Manager\n{final_state['final_trade_decision']}")
# Write consolidated report # Write consolidated report
header = f"# Trading Analysis Report: {ticker}\n\nGenerated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n" (save_path / "complete_report.md").write_text(
(save_path / "complete_report.md").write_text(header + "\n\n".join(sections), encoding="utf-8") _header(ticker, final_state, settings) + "\n\n".join(sections), encoding="utf-8"
)
return save_path / "complete_report.md" return save_path / "complete_report.md"