mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-09-27 15:02:39 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9968bd8dd1 | ||
|
|
fc1ab1db07 | ||
|
|
0c602846ba | ||
|
|
a1b3b5bab8 | ||
|
|
d91d1f4cb4 | ||
|
|
45391d0962 | ||
|
|
cf960d6382 | ||
|
|
1d20780095 | ||
|
|
3f6ad5dc12 | ||
|
|
674f1087d1 | ||
|
|
5ac5786d0d | ||
|
|
c50420fc5c | ||
|
|
b690dc7988 | ||
|
|
a94a411b0e | ||
|
|
05878c96a8 | ||
|
|
f9d6495dca | ||
|
|
f4269bf193 | ||
|
|
47ac1f2d1e | ||
|
|
51066935b8 | ||
|
|
73e0aa0fa6 | ||
|
|
6ac7c6f017 | ||
|
|
ecd3404213 | ||
|
|
35543d0248 |
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+18
-36
@@ -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:
|
||||||
@@ -431,10 +431,10 @@ def display_complete_report(final_state):
|
|||||||
for title, content in risk_reports:
|
for title, content in risk_reports:
|
||||||
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))
|
tracker.mark_started(spec.key, started_at=current_time)
|
||||||
|
if chunk.get(spec.report_key):
|
||||||
if has_report:
|
|
||||||
tracker.mark_started(spec.key, started_at=current_time)
|
|
||||||
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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
+67
-35
@@ -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,14 +121,18 @@ def _prompt_selections(prefs):
|
|||||||
return prompt_fn()
|
return prompt_fn()
|
||||||
|
|
||||||
# Step 1: Ticker symbol
|
# Step 1: Ticker symbol
|
||||||
console.print(
|
if flags.get("ticker") is not None:
|
||||||
create_question_box(
|
selected_ticker = _from_flag(parse_ticker, flags["ticker"])
|
||||||
"Step 1: Ticker Symbol",
|
console.print(f"[green]✓ Ticker from --ticker:[/green] {selected_ticker}")
|
||||||
"Enter the ticker, with exchange suffix when needed (e.g. SPY, 0700.HK, BTC-USD)",
|
else:
|
||||||
"SPY",
|
console.print(
|
||||||
|
create_question_box(
|
||||||
|
"Step 1: Ticker Symbol",
|
||||||
|
"Enter the ticker, with exchange suffix when needed (e.g. SPY, 0700.HK, BTC-USD)",
|
||||||
|
"SPY",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
selected_ticker = get_ticker()
|
||||||
selected_ticker = get_ticker()
|
|
||||||
asset_type = detect_asset_type(selected_ticker)
|
asset_type = detect_asset_type(selected_ticker)
|
||||||
# Only announce when it's not the default stock path, to avoid printing
|
# Only announce when it's not the default stock path, to avoid printing
|
||||||
# "stock" on every run.
|
# "stock" on every run.
|
||||||
@@ -110,15 +142,19 @@ def _prompt_selections(prefs):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Step 2: Analysis date
|
# Step 2: Analysis date
|
||||||
default_date = datetime.datetime.now().strftime("%Y-%m-%d")
|
if flags.get("date") is not None:
|
||||||
console.print(
|
analysis_date = _from_flag(parse_analysis_date, flags["date"])
|
||||||
create_question_box(
|
console.print(f"[green]✓ Analysis date from --date:[/green] {analysis_date}")
|
||||||
"Step 2: Analysis Date",
|
else:
|
||||||
"Enter the analysis date (YYYY-MM-DD)",
|
default_date = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||||
default_date,
|
console.print(
|
||||||
|
create_question_box(
|
||||||
|
"Step 2: Analysis Date",
|
||||||
|
"Enter the analysis date (YYYY-MM-DD)",
|
||||||
|
default_date,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
analysis_date = get_analysis_date()
|
||||||
analysis_date = get_analysis_date()
|
|
||||||
|
|
||||||
# Step 3: Output language (skipped when set via TRADINGAGENTS_OUTPUT_LANGUAGE)
|
# Step 3: Output language (skipped when set via TRADINGAGENTS_OUTPUT_LANGUAGE)
|
||||||
if os.environ.get("TRADINGAGENTS_OUTPUT_LANGUAGE"):
|
if os.environ.get("TRADINGAGENTS_OUTPUT_LANGUAGE"):
|
||||||
@@ -136,13 +172,16 @@ 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
|
||||||
console.print(
|
|
||||||
create_question_box(
|
|
||||||
"Step 4: Analysts Team", "Select your LLM analyst agents for the analysis"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
prefs = sanitize(prefs, asset_type.value)
|
prefs = sanitize(prefs, asset_type.value)
|
||||||
selected_analysts = select_analysts(asset_type, prefs.get("analysts"))
|
if flags.get("analysts") is not None:
|
||||||
|
selected_analysts = _from_flag(parse_analysts, flags["analysts"], asset_type)
|
||||||
|
else:
|
||||||
|
console.print(
|
||||||
|
create_question_box(
|
||||||
|
"Step 4: Analysts Team", "Select your LLM analyst agents for the analysis"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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
@@ -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
@@ -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*"]
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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 ---------------------------------
|
||||||
|
|||||||
@@ -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,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
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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__)
|
||||||
@@ -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)
|
||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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", ""),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -56,6 +56,31 @@ def bind_structured(llm: Any, schema: type[T], agent_name: str) -> Any | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def invoke_structured(structured_llm: Any | None, prompt: Any, agent_name: str) -> T | None:
|
||||||
|
"""Run the structured call; ``None`` when there is none or it fails.
|
||||||
|
|
||||||
|
``prompt`` is whatever the underlying LLM accepts (a string for chat
|
||||||
|
invocations, a list of message dicts for chat models that take that
|
||||||
|
shape), so a caller can forward the same value to its free-text fallback.
|
||||||
|
"""
|
||||||
|
if structured_llm is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
result = structured_llm.invoke(prompt)
|
||||||
|
if result is None:
|
||||||
|
# A thinking model can answer in plain text instead of calling
|
||||||
|
# the tool, leaving the parser with nothing to return. Treat it
|
||||||
|
# as a structured miss and fall back, with a clear reason.
|
||||||
|
raise ValueError("structured output returned no parsed result")
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"%s: structured-output invocation failed (%s); retrying once as free text",
|
||||||
|
agent_name, exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def invoke_structured_or_freetext(
|
def invoke_structured_or_freetext(
|
||||||
structured_llm: Any | None,
|
structured_llm: Any | None,
|
||||||
plain_llm: Any,
|
plain_llm: Any,
|
||||||
@@ -63,27 +88,8 @@ def invoke_structured_or_freetext(
|
|||||||
render: Callable[[T], str],
|
render: Callable[[T], str],
|
||||||
agent_name: str,
|
agent_name: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Run the structured call and render to markdown; fall back to free-text on any failure.
|
"""Run the structured call and render to markdown; fall back to free-text on any failure."""
|
||||||
|
result = invoke_structured(structured_llm, prompt, agent_name)
|
||||||
``prompt`` is whatever the underlying LLM accepts (a string for chat
|
if result is not None:
|
||||||
invocations, a list of message dicts for chat models that take that
|
return render(result)
|
||||||
shape). The same value is forwarded to the free-text path so the
|
return plain_llm.invoke(prompt).content
|
||||||
fallback sees the same input the structured call did.
|
|
||||||
"""
|
|
||||||
if structured_llm is not None:
|
|
||||||
try:
|
|
||||||
result = structured_llm.invoke(prompt)
|
|
||||||
if result is None:
|
|
||||||
# A thinking model can answer in plain text instead of calling
|
|
||||||
# the tool, leaving the parser with nothing to return. Treat it
|
|
||||||
# as a structured miss and fall back, with a clear reason.
|
|
||||||
raise ValueError("structured output returned no parsed result")
|
|
||||||
return render(result)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"%s: structured-output invocation failed (%s); retrying once as free text",
|
|
||||||
agent_name, exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = plain_llm.invoke(prompt)
|
|
||||||
return response.content
|
|
||||||
|
|||||||
@@ -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.
|
||||||
"""
|
"""
|
||||||
return build_verified_market_snapshot(symbol, as_of(curr_date, trade_date), look_back_days)
|
# 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)
|
||||||
|
except VendorRateLimitError as exc:
|
||||||
|
return vendor_unavailable("get_verified_market_snapshot", exc)
|
||||||
|
|
||||||
|
|
||||||
@tool
|
@tool
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
+43
-28
@@ -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.
|
||||||
start=start_str,
|
try:
|
||||||
end=end_str,
|
downloaded = yf_retry(lambda: yf.Ticker(canonical).history(
|
||||||
multi_level_index=False,
|
start=start_str,
|
||||||
progress=False,
|
end=end_str,
|
||||||
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
@@ -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.",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
+37
-13
@@ -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}")
|
||||||
@@ -88,14 +111,15 @@ def write_report_tree(final_state: dict, ticker: str, save_path) -> Path:
|
|||||||
content = "\n\n".join(f"### {name}\n{text}" for name, text in risk_parts)
|
content = "\n\n".join(f"### {name}\n{text}" for name, text in risk_parts)
|
||||||
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user