Compare commits

..
34 Commits
Author SHA1 Message Date
Yijia Xiao c4544913a1 Merge pull request #1613 from TauricResearch/v0.7.1
TradingAgents v0.7.1 release
2026-10-27 01:38:40 -07:00
Yijia-Xiao 3e44faafa0 release: 0.7.1 2026-10-27 08:01:34 +00:00
Yijia-Xiao bf7f4b55a3 fix(tools): accept only the statement frequencies the vendors serve (#1593)
- freq is annual or quarterly in the tool schema; any other word is sent back to the model instead of being served annual figures under that word
2026-10-27 08:01:21 +00:00
Yijia-Xiao d46c052278 test: fix tests that failed on Windows and macOS (#1506, #1485, #1541)
- the module-help subprocess is read as UTF-8 under a cp1252 locale
- the yfinance cache check names the suite's directory, which may sit under the home on Windows
- POSIX file modes and shell upgrade commands are checked only off Windows
- file locking names errno.EDEADLK, which macOS also defines
2026-10-27 08:01:08 +00:00
Yijia-Xiao ec4e2c8e5a build(docker): keep every .env file out of the image
- .dockerignore excludes .env files at any depth, so a local .env.enterprise with Azure keys is not copied into image layers
- the README says to put Azure keys in .env for Docker
2026-10-27 08:00:55 +00:00
Yijia-Xiao a334a1f878 fix(cli): check a ticker with the data layer's own rule (#1604)
- the prompt and --ticker accept what the run accepts: XAUUSD+ resolves to GC=F, while ".." and full-width letters are refused at entry instead of after the graph is built
2026-10-27 08:00:43 +00:00
Yijia-Xiao ae0424745e fix(llm): read a dated Claude snapshot's version without its date (#1585)
- claude-sonnet-4-20250514 no longer receives the effort setting it rejects, and claude-opus-4-5-20251101 now receives it
2026-10-27 08:00:30 +00:00
Yijia-Xiao 347974a000 fix(llm): pass the output-token cap and a configured endpoint to Azure OpenAI (#1584, #1600)
- max_tokens reaches the Azure deployment
- a tier's backend_url is used as the Azure endpoint, else AZURE_OPENAI_ENDPOINT
2026-10-27 08:00:17 +00:00
Yijia-Xiao b78f136ba3 fix(polymarket): keep a long shot's odds from printing as 0% or 100% (#1598)
- within 1% of 0 or 100, the distance keeps two significant digits: 0.05%, 99.95%
2026-10-27 08:00:04 +00:00
Yijia-Xiao caf6792bba fix(memory): take the most recent lessons by decision date (#1603)
- get_past_context orders a ticker's settled decisions by their date, so a backfill of older dates no longer pushes out the latest lesson
- rotation drops the oldest-dated settled entries, not the first written
2026-10-27 07:59:51 +00:00
Yijia-Xiao ee06dcb961 fix(tools): send a date window that ends before it starts back to the model (#1595)
- as_of_window refuses a start after the end, as it does an unreadable start, instead of passing it to the vendor
2026-10-27 07:59:39 +00:00
Yijia-Xiao 21bc5d8fe7 fix(reporting): remove a previous run's sections when a report is saved to the same folder (#1566)
- write_report_tree deletes the section files and HTML page this run did not write; other files in the folder are kept
2026-10-27 07:59:26 +00:00
Yijia-Xiao 2717033ad8 fix(sec_edgar): serve a statement one row per period (#1517, #1542)
- a row per period with the line items as columns, so each figure sits beside its own date across a filer's whole history
- lines the filer never tagged are named once in the header
2026-10-27 07:59:13 +00:00
Yijia-Xiao 50bf38ee92 fix(sec_edgar): print a figure under one million instead of 0 (#1589)
- a USD figure under one million keeps three significant digits in the statement table
2026-10-27 07:59:01 +00:00
Yijia-Xiao 6c23d7551c fix(yahoo): print prices with their significant digits (#1567)
- the price table and the verified snapshot keep six significant digits and at least two decimals, so a sub-cent close is no longer 0.00 and a currency pair keeps its pips
2026-10-27 07:58:48 +00:00
Yijia-Xiao 3225a3c054 fix(portfolio): refuse a portfolio with a number that is not finite (#1509)
- cash, quantities and average prices reject NaN and infinity when the file loads
2026-10-27 07:58:35 +00:00
Yijia-Xiao 62a06edb38 fix(portfolio): match a holding listed under a broker alias to the analyzed instrument (#1556)
- a holding matches when its symbol resolves to the one the run prices (BTCUSDT -> BTC-USD, 700.HK -> 0700.HK); holdings keep the spelling they were given
- every matching holding is shown, labelled with its listed spelling
2026-10-27 07:58:23 +00:00
Yijia-Xiao 28d32653d3 fix(backtest): give each sweep started without a run_id its own run (#1549)
- the stamped run directory is claimed atomically; a second sweep in the same second takes the next suffix instead of resuming the first
2026-10-27 07:58:10 +00:00
Yijia-Xiao 1c0e3e37fc fix(backtest): report the settlement failures a sweep's last pass records (#1557)
- run_backtest adds settle_pending's failed entries to settlement_failures, each reason prefixed with its date
2026-10-27 07:57:57 +00:00
Yijia-Xiao eb4a486092 fix(memory): settle decisions on final closes, across long market closures (#1548, #1539)
- the bar of a session still trading, by its market's date, is not scored
- a holding window that has passed without the stock or its benchmark trading through is fetched again through today
- a window that cannot have traded yet asks for no prices
2026-10-27 07:57:45 +00:00
Yijia-Xiao a5b0f217e3 fix(tools): hand vendors the model's dates as YYYY-MM-DD (#1488)
- as_of and as_of_window return canonical dates, so an unpadded date such as 2024-7-5 no longer compares as later than a filing dated 2024-07-24
- create_run_state validates the run date, as propagate does
2026-10-27 07:57:32 +00:00
Yijia-Xiao 2fe04cc771 chore: start 0.7.1 development (0.7.1.dev0) 2026-10-27 07:57:19 +00:00
Tauric-Research e8f5503e9b Merge pull request #1536 from TauricResearch/v0.7.0
TradingAgents v0.7.0 release
2026-10-16 16:38:41 -05:00
Yijia-Xiao f802271fb3 release: 0.7.0 2026-10-16 21:13:16 +00:00
Yijia-Xiao bc55fe3ed7 docs: ChatGPT plan sign-in and the start-up update check (#1475)
- README and .env.example cover the chatgpt provider, `tradingagents login chatgpt` (also in Docker) and logout
- README and .env.example name TRADINGAGENTS_NO_UPDATE_CHECK
2026-10-16 21:13:04 +00:00
Yijia-Xiao f5e3eae3a8 fix(memory): score a decision from the first close after its date (#1518)
- settlement enters at the first session after the decision date, so news published after that day's close is not scored against the same day's close
2026-10-16 21:12:51 +00:00
Yijia-Xiao 7142746bde fix(cli): keep a tier's configured thinking setting when the main provider has none (#1440)
- the run config takes reasoning effort, thinking level and Claude effort from the selections only when one was chosen
2026-10-16 21:12:39 +00:00
Yijia-Xiao 36479ae1fa feat(llm): chatgpt provider runs on the signed-in ChatGPT plan (#1475)
- ChatGPTPlanChatOpenAI streams unstored Responses, sends system instructions as developer messages, drops refused settings with a warning, and sends no API organization or project
- the CLI offers it under "Subscription" after Anthropic, checks the sign-in before a run (signing in or stopping with the login command), and lists the plan's models
- reasoning effort applies to chatgpt as to openai; remembered plan models are offered again
2026-10-16 21:12:26 +00:00
Yijia-Xiao 69042d6d33 feat(llm): sign in with a ChatGPT plan (#1475)
- chatgpt_auth signs in through OpenAI's flow for open-source apps (PKCE, loopback or pasted redirect, verified ID token, plan scope) and keeps the sign-in in ~/.tradingagents/chatgpt-auth.json, readable only by its owner
- access tokens renew under a lock shared across threads and processes, from the refresh token on disk
- `tradingagents login chatgpt` and `tradingagents logout chatgpt`; logout revokes the sign-in at OpenAI
2026-10-16 21:12:14 +00:00
Yijia-Xiao 071a6cea2a refactor(llm): let a provider sign in with its own credential at a pinned endpoint
- a provider entry can supply a credential, a token callable per request, and pin its base URL
- such a provider never sends an API key, and refuses another backend_url or an explicit api_key
2026-10-16 21:12:05 +00:00
Yijia-Xiao c3cdaeb4eb feat(cli): tell people at a terminal about a newer release and Tauric's notices
- once a day a background thread records the latest GitHub release and the notices for each version; the welcome screen shows them from that cache, so no run waits on the network
- the release notice names the upgrade command for how TradingAgents was installed
- TRADINGAGENTS_NO_UPDATE_CHECK=1 turns the check off; unattended and CI runs never make it
- notices are read from a new `notices` key, so earlier versions keep reading `announcements`
2026-10-16 21:11:55 +00:00
Yijia-Xiao c9be8b6047 ci: attach the source archive and wheel to each GitHub release
- on a published release, or by hand for an earlier tag, build both from the tag, install the wheel in a clean environment, and upload them to the release
2026-10-16 21:11:45 +00:00
Yijia-Xiao 609c8e80a1 chore: start 0.7.0 development (0.7.0.dev0) 2026-10-16 21:11:35 +00:00
Tauric-Research 1394a3f72a Merge pull request #1478 from TauricResearch/v0.6.0
TradingAgents v0.6.0 release
2026-10-03 16:30:07 -05:00
64 changed files with 2863 additions and 297 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
.git
.venv
.env
**/.env*
.claude
.idea
.vscode
+6
View File
@@ -35,6 +35,9 @@ NVIDIA_API_KEY=
#AWS_DEFAULT_REGION=us-west-2
#AWS_PROFILE=
# ChatGPT Plus or Pro plan: no key; sign in once with `tradingagents login chatgpt`.
#TRADINGAGENTS_LLM_PROVIDER=chatgpt
# Remote Ollama server. Unset uses http://localhost:11434/v1.
#OLLAMA_BASE_URL=http://your-ollama-host:11434/v1
@@ -54,6 +57,9 @@ NVIDIA_API_KEY=
#TRADINGAGENTS_MAX_TOOL_ROUNDS=20
#TRADINGAGENTS_CHECKPOINT_ENABLED=false
# Turn off the start-up note about a newer release and Tauric Research notices.
#TRADINGAGENTS_NO_UPDATE_CHECK=1
# Docker: a host folder for results, reports and the memory log (unset: the tradingagents_data volume).
#TRADINGAGENTS_DATA_DIR=./data
+59
View File
@@ -0,0 +1,59 @@
name: Release assets
# Builds the source archive and wheel from the release tag and attaches them to
# the GitHub release, so a version can be installed without cloning:
# pip install https://github.com/TauricResearch/TradingAgents/releases/download/<tag>/<wheel>
# Run it by hand with a tag to attach builds to an earlier release.
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Release tag to build and attach, for example v0.6.0"
required: true
permissions: {}
concurrency:
group: release-assets-${{ inputs.tag || github.event.release.tag_name }}
cancel-in-progress: false
jobs:
build:
name: build and attach
runs-on: ubuntu-latest
permissions:
contents: write
env:
TAG: ${{ inputs.tag || github.event.release.tag_name }}
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v7
with:
# Release branches share their tag's name, so name the tag explicitly.
ref: refs/tags/${{ env.TAG }}
- uses: actions/setup-python@v7
with:
python-version: "3.13"
- name: Check the package version matches the tag
run: |
version=$(python -c "import re, pathlib; print(re.search(r'__version__ = \"([^\"]+)\"', pathlib.Path('tradingagents/__init__.py').read_text()).group(1))")
if [ "v$version" != "$TAG" ]; then
echo "tradingagents/__init__.py says $version, but the tag is $TAG"
exit 1
fi
- name: Build the source archive and wheel
run: |
python -m pip install --upgrade build
python -m build
- name: Install the wheel in a clean environment
run: |
python -m venv /tmp/smoke
/tmp/smoke/bin/pip install dist/*.whl
# From another directory, so the imports come from the wheel, not the checkout.
cd /tmp
/tmp/smoke/bin/python -c "import importlib.resources as r, tradingagents, cli.main; assert (r.files('cli') / 'static' / 'welcome.txt').is_file(); print('wheel install OK', tradingagents.__version__)"
- name: Attach them to the release
run: gh release upload "$TAG" dist/* --clobber --repo "$GITHUB_REPOSITORY"
+56
View File
@@ -6,6 +6,62 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Changes that need action when upgrading are listed under "Upgrading from" in their release.
## [0.7.1] — 2026-10-27
Decisions are scored only on final closes and settle across long market holidays, a model's unpadded date no longer reads filings made after the run date, and fixes to portfolios, prices, SEC statements, reports, Azure and Claude settings.
### Upgrading from 0.7.0
- Docker images no longer include `.env.enterprise`. For Azure OpenAI in Docker, put its keys in `.env`.
### Fixed
- A date a model passes without zero padding, such as `2024-7-5`, is read as that date. It could compare as later than a filing made after the run date, and SEC EDGAR then served that filing. (#1488)
- A decision is no longer scored on the bar of a session still trading, and one made before a market closure longer than a week, such as a national holiday, settles once trading resumes instead of staying pending. (#1548, #1539)
- A backtest reports the failures of its last settlement pass, and two started in the same second no longer share one run. (#1557, #1549)
- A holding listed under a broker's spelling (`BTCUSDT`, `700.HK`) is matched to the instrument analyzed, and a portfolio number that is not finite is refused when the file loads. (#1556, #1509)
- Prices keep their significant digits: a sub-cent close no longer prints as 0.00, and a currency pair keeps its pips. (#1567)
- SEC EDGAR statements list one row per period, so a model no longer reads a figure against the wrong quarter across a long history, and a figure under one million no longer prints as 0. (#1517, #1589)
- Saving a report to a folder used before removes the earlier run's sections. (#1566)
- A date window that ends before it starts is sent back to the model instead of returning no data. (#1595)
- A statement frequency other than annual or quarterly is sent back to the model instead of being served annual figures under that word. (#1593)
- The memory's most recent lessons, and the entries rotation keeps, follow decision dates, so a backfill of older dates no longer displaces them. (#1603)
- Prediction-market odds near 0% or 100% show their distance from it to two significant digits. (#1598)
- Azure OpenAI receives `max_tokens` and a configured endpoint. (#1584, #1600)
- Claude's effort setting is decided correctly for dated model IDs such as `claude-sonnet-4-20250514`. (#1585)
- The CLI checks a ticker with the same rule as the run, so `XAUUSD+` is accepted and `..` is refused at the prompt. (#1604)
- Docker images no longer include `.env` files from any folder.
- Tests that failed on Windows and macOS for platform reasons are fixed. (#1506, #1485, #1541)
### Contributors
[@alanfate3](https://github.com/alanfate3), [@aranlucas](https://github.com/aranlucas), [@BichengWang](https://github.com/BichengWang), [@Bortlesboat](https://github.com/Bortlesboat), [@hoffman988](https://github.com/hoffman988), [@jhill-cmd](https://github.com/jhill-cmd), [@muhib-karim](https://github.com/muhib-karim), [@pei711](https://github.com/pei711), [@peiji1981](https://github.com/peiji1981), [@rudycelekli](https://github.com/rudycelekli), [@Shubham-Padkonde](https://github.com/Shubham-Padkonde), [@stevexyz2003](https://github.com/stevexyz2003), [@SuperMarioYL](https://github.com/SuperMarioYL), [@sytfly](https://github.com/sytfly), [@yuv009](https://github.com/yuv009).
## [0.7.0] — 2026-10-16
Runs on your ChatGPT plan, backtests scored from the first close after each decision, a note at start-up when a newer release is out, and a wheel attached to each GitHub release.
### Added
- **Your ChatGPT plan.** The `chatgpt` provider runs TradingAgents on a ChatGPT Plus or Pro plan instead of an API key, through OpenAI's sign-in for open-source apps. Choose "Subscription" in the CLI's provider menu; for unattended runs, Docker and Python (`llm_provider="chatgpt"`), sign in once with `tradingagents login chatgpt`. `tradingagents logout chatgpt` revokes the sign-in. The CLI lists the plan's models, and reasoning effort applies as it does for OpenAI. (#1194, #1475)
- **What's new at start-up.** The interactive CLI mentions a newer release, with the command that upgrades this install when it can tell how TradingAgents was installed, and notices from Tauric Research for the running version. It checks at most once a day in the background, so a run never waits on it; runs without a terminal never check, and `TRADINGAGENTS_NO_UPDATE_CHECK=1` turns it off.
- **Release downloads.** Each GitHub release carries the source archive and a wheel, so a version installs with `pip install` from its release page without cloning.
### Upgrading from 0.6.0
- `cryptography` 42 and `packaging` 24 or later are installed.
- Decisions are now scored from the first close after their date. Outcomes already settled in a memory log keep the earlier entry, the close of the decision date, so a backtest resumed with `run_id` mixes the two; start a new run to compare like with like.
### Fixed
- A decision is no longer scored against news it could not have traded on: a run dated *d* reads news published after the close on *d*, so the memory log and backtests enter it at the first close after *d*, not the close of *d*. (#1518)
- A run without a terminal, such as a scheduled job or CI, no longer contacts the announcements server before it starts.
- A model tier on another provider keeps its configured reasoning effort, thinking level or Claude effort when the main provider has none. (#1440)
### Contributors
[@LeonWTW](https://github.com/LeonWTW), [@OleksanderSS](https://github.com/OleksanderSS), [@sovorn-c](https://github.com/sovorn-c).
## [0.6.0] — 2026-10-03
HTML reports, a provider per model tier, past decisions settled for every ticker while the analysts work, and company news read while Yahoo's news feed is down.
+3 -1
View File
@@ -12,8 +12,10 @@ RUN pip install --no-cache-dir .
FROM python:3.13-slim
# TRADINGAGENTS_IN_DOCKER lets an update notice name the rebuild command.
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
PYTHONUNBUFFERED=1 \
TRADINGAGENTS_IN_DOCKER=1
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
+11 -5
View File
@@ -32,15 +32,17 @@
## News
<!-- news:start -->
- [2026-10] **TradingAgents v0.7.1** released with decisions scored only on final closes and settled across long market holidays, a fix for model dates that could read filings made after the run date, and fixes to portfolios, prices, SEC statements and reports.
- [2026-10] **TradingAgents v0.7.0** released with runs on your own ChatGPT Plus or Pro plan, backtests scored from the first close after each decision, a note at start-up when a newer release is out, and a wheel attached to each GitHub release.
- [2026-10] **TradingAgents v0.6.0** released with reports saved as one HTML page, a provider per model tier so the managers and analysts can run on different models, past decisions settled for every ticker while the analysts work, and company news read from Yahoo search while Yahoo's news feed is down.
- [2026-09] **TradingAgents v0.5.2** released with parallel analysts for a faster analysis, a CLI that runs without prompts from flags such as `--ticker` and `--date`, the run's settings recorded in every report, and backtests that see only data published by each analysis date.
- [2026-09] **TradingAgents v0.5.1** released with a package layout organised by what each module holds (import paths moved), optional Jev screening of social posts, GPT-6 Sol and Luna as the default models, and fixes to run isolation and SEC EDGAR statements.
Full release notes are in [CHANGELOG.md](CHANGELOG.md).
<details>
<summary>Earlier news</summary>
- [2026-09] **TradingAgents v0.5.2** released with parallel analysts for a faster analysis, a CLI that runs without prompts from flags such as `--ticker` and `--date`, the run's settings recorded in every report, and backtests that see only data published by each analysis date.
- [2026-09] **TradingAgents v0.5.1** released with a package layout organised by what each module holds (import paths moved), optional Jev screening of social posts, GPT-6 Sol and Luna as the default models, and fixes to run isolation and SEC EDGAR statements.
- [2026-09] **TradingAgents v0.5.0** released with point-in-time integrity across every dated path, SEC EDGAR fundamentals served as filed, backtesting over a ticker and date grid, portfolio-aware runs, and current model lineups across every provider.
- [2026-08] **TradingAgents v0.4.0** released with look-ahead / point-in-time fixes across FRED macro, social sentiment, and the decision-log memory; clearer decision signals; working CLI checkpoint resume; Trader price grounding; and the GPT-5.6 and GLM-5.3 models.
- [2026-07] **TradingAgents v0.3.1** released with correctness and stability fixes: Alpha Vantage look-ahead filtering, graph-router crash-safety, graph-shape-aware checkpoint resume, working crypto sentiment sources, a configurable LLM retry budget, Bedrock API-key auth, and Claude Sonnet 5 / Fable 5 support.
@@ -181,7 +183,9 @@ export ALPHA_VANTAGE_API_KEY=... # Alpha Vantage
export TYPESAFE_API_KEY=... # Jev social-post screening (optional)
```
For Azure OpenAI, copy `.env.enterprise.example` to `.env.enterprise` and fill in your credentials.
For Azure OpenAI, copy `.env.enterprise.example` to `.env.enterprise` and fill in your credentials. With Docker, put these keys in `.env` instead: the image does not include `.env` files.
To run on your ChatGPT Plus or Pro plan instead of an API key, choose "Subscription" in the CLI's provider menu, or set `llm_provider: "chatgpt"`. Sign in once with `tradingagents login chatgpt`: it prints an address to open in your browser, which returns to this machine on `127.0.0.1`. When the browser runs on another machine, or TradingAgents runs in Docker (`docker compose run --rm tradingagents login chatgpt`), paste the address the browser ends up on. The sign-in is kept in `~/.tradingagents/chatgpt-auth.json`, and `tradingagents logout chatgpt` revokes it. Runs count toward your plan's usage limits, and the plan does not accept `temperature` or `max_tokens`.
For AWS Bedrock, install the extra with `pip install ".[bedrock]"`, set `llm_provider: "bedrock"`, configure AWS credentials (environment variables, `~/.aws/credentials`, or an IAM role) and `AWS_DEFAULT_REGION`, and use a Bedrock model ID, e.g. `us.anthropic.claude-opus-5-5`.
@@ -213,6 +217,8 @@ tradingagents --ticker NVDA --date 2026-09-23 --analysts market,news,fundamental
```
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.
At start-up the interactive CLI mentions a newer release and notices from Tauric Research. It checks at most once a day, in the background, and sends nothing about your runs; set `TRADINGAGENTS_NO_UPDATE_CHECK=1` to turn it off. Runs without a terminal never check.
A saved report also includes `complete_report.html`, the report as one page with its sections listed beside the text, for reading in a browser, on a phone or in print. Answering the save question at the prompt also asks about the page and can open it in your browser; `--no-html` skips it, and so does `ta.save_reports(state, "NVDA", html=False)` from Python.
### Markets and tickers
@@ -243,7 +249,7 @@ An interface will appear showing results as they load, letting you track the age
### Implementation Details
We built TradingAgents with LangGraph to ensure flexibility and modularity. The framework supports multiple LLM providers: OpenAI, Google, Anthropic, xAI, DeepSeek, Qwen (Alibaba DashScope, international and China endpoints), GLM (Zhipu), MiniMax (global + China), OpenRouter, Ollama for local models, and Azure OpenAI for enterprise.
We built TradingAgents with LangGraph to ensure flexibility and modularity. The framework supports multiple LLM providers: OpenAI, Google, Anthropic, xAI, DeepSeek, Qwen (Alibaba DashScope, international and China endpoints), GLM (Zhipu), MiniMax (global + China), OpenRouter, Ollama for local models, Azure OpenAI for enterprise, and your own ChatGPT plan.
### Python Usage
@@ -374,7 +380,7 @@ From the CLI:
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 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.
Each cell is scored on realized alpha against the instrument's regional benchmark, grouped by rating. A decision is entered at the first close after its date, because a run reads news published after that day's close. 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
-53
View File
@@ -1,53 +0,0 @@
import getpass
import sys
import requests
from rich.console import Console
from rich.panel import Panel
from cli.config import CLI_CONFIG
def fetch_announcements(url: str = None, timeout: float = None) -> dict:
"""Fetch announcements from endpoint. Returns dict with announcements and settings."""
endpoint = url or CLI_CONFIG["announcements_url"]
timeout = timeout or CLI_CONFIG["announcements_timeout"]
fallback = CLI_CONFIG["announcements_fallback"]
try:
response = requests.get(endpoint, timeout=timeout)
response.raise_for_status()
data = response.json()
return {
"announcements": data.get("announcements", [fallback]),
"require_attention": data.get("require_attention", False),
}
except Exception:
return {
"announcements": [fallback],
"require_attention": False,
}
def display_announcements(console: Console, data: dict) -> None:
"""Display announcements panel. Prompts for Enter if require_attention is True."""
announcements = data.get("announcements", [])
require_attention = data.get("require_attention", False)
if not announcements:
return
content = "\n".join(announcements)
panel = Panel(
content,
border_style="cyan",
padding=(1, 2),
title="Announcements",
)
console.print(panel)
if require_attention and sys.stdin.isatty():
getpass.getpass("Press Enter to continue...")
else:
console.print()
-6
View File
@@ -1,6 +0,0 @@
CLI_CONFIG = {
# Announcements
"announcements_url": "https://api.tauric.ai/v1/announcements",
"announcements_timeout": 1.0,
"announcements_fallback": "[cyan]For more information, please visit[/cyan] [link=https://github.com/TauricResearch]https://github.com/TauricResearch[/link]",
}
+38
View File
@@ -1,6 +1,9 @@
import sys
from enum import StrEnum
from typing import Annotated
import typer
from rich.markup import escape
from cli.display import console
from cli.models import AnalystType, AssetType
@@ -8,6 +11,7 @@ from cli.prompts import filter_analysts_for_asset_type, parse_analysts
from cli.run import run_analysis
from tradingagents.backtest import iter_grid, run_backtest, summarize
from tradingagents.default_config import DEFAULT_CONFIG
from tradingagents.llm_clients.openai_client import SIGNED_IN_PROVIDERS
from tradingagents.portfolio import load_portfolio
# prompt_toolkit's win32 output module is importable only on Windows (it asserts
@@ -156,5 +160,39 @@ def backtest(
console.print(f"[yellow]unsettled:[/yellow] {ticker}: {reason}")
# The plans `login` and `logout` take: the providers signed in to, not keyed.
Plan = StrEnum("Plan", {name: name for name in SIGNED_IN_PROVIDERS})
@app.command()
def login(plan: Annotated[Plan, typer.Argument(help="The plan to sign in with")]):
"""Sign in with your ChatGPT Plus or Pro plan, for the chatgpt provider."""
from tradingagents.llm_clients import chatgpt_auth
try:
# One unwrapped line, so the sign-in address can be copied whole.
email = chatgpt_auth.sign_in(
say=lambda line: console.print(line, soft_wrap=True, markup=False, highlight=False))
except chatgpt_auth.ChatGPTAuthError as exc:
console.print(f"[red]{escape(str(exc))}[/red]")
raise typer.Exit(code=1) from None
console.print(f"[green]Signed in to ChatGPT as {escape(email)}.[/green]")
@app.command()
def logout(plan: Annotated[Plan, typer.Argument(help="The plan to sign out of")]):
"""Sign out of your ChatGPT plan: revoke this machine's access and forget it."""
from tradingagents.llm_clients import chatgpt_auth
outcome = chatgpt_auth.sign_out()
if outcome == "revoked":
console.print("Signed out of ChatGPT.")
elif outcome == "forgotten":
console.print("[yellow]Signed out here, but OpenAI could not be reached to revoke the sign-in; "
"it stays valid at OpenAI until it expires.[/yellow]")
else:
console.print("Not signed in to ChatGPT.")
if __name__ == "__main__":
app()
+4 -2
View File
@@ -21,7 +21,7 @@ import os
from pathlib import Path
from cli.models import AnalystType, AssetType
from cli.prompts import _llm_provider_table, filter_analysts_for_asset_type
from cli.prompts import LISTED_LIVE, _llm_provider_table, filter_analysts_for_asset_type
from tradingagents.dataflows.files import replace_file
from tradingagents.llm_clients.model_catalog import get_model_options
@@ -86,6 +86,8 @@ def sanitize(prefs: dict, asset_type) -> dict:
offered = {model for _, model in get_model_options(base, mode)}
except KeyError:
continue
if prefs.get(field) in offered:
# Models listed live are not in the catalog, so any model ID is kept.
listed_live = base in LISTED_LIVE and isinstance(prefs.get(field), str)
if prefs.get(field) in offered or listed_live:
kept[field] = prefs[field]
return kept
+116 -52
View File
@@ -1,16 +1,20 @@
import datetime
import os
import subprocess
import sys
from pathlib import Path
import questionary
import typer
from dotenv import find_dotenv, set_key
from rich.markup import escape
from cli.display import console
from cli.models import AnalystType, AssetType
from tradingagents.dataflows.symbols import safe_ticker_component
from tradingagents.llm_clients.api_key_env import get_api_key_env
from tradingagents.llm_clients.model_catalog import get_model_options
from tradingagents.llm_clients.openai_client import OPENAI_COMPATIBLE_PROVIDERS, SIGNED_IN_PROVIDERS
TICKER_INPUT_EXAMPLES = "SPY, 0700.HK, BTC-USD"
@@ -24,15 +28,28 @@ ANALYST_CHOICES = [
CRYPTO_SUFFIXES = ("-USD", "-USDT", "-USDC", "-BTC", "-ETH")
def is_valid_ticker_input(value: str) -> bool:
"""Whether a ticker entry is acceptable (charset + length).
# The style of every selection menu.
_MENU_STYLE = questionary.Style([
("selected", "fg:magenta noinherit"),
("highlighted", "fg:magenta noinherit"),
("pointer", "fg:magenta noinherit"),
])
Allows the characters Yahoo symbols use, including ``=`` for futures/forex
like ``GC=F`` and ``EURUSD=X`` (#980), and ``^`` for indices. Empty input is
allowed (it defaults to SPY downstream).
def is_valid_ticker_input(value: str) -> bool:
"""Whether a ticker entry names a symbol the run can use.
The symbol it resolves to (``XAUUSD+`` -> ``GC=F``) must pass the data
layer's own check, so the prompt refuses exactly what the run would.
Empty input is allowed (it defaults to SPY downstream).
"""
v = value.strip()
return not v or (all(ch.isalnum() or ch in "._-^=" for ch in v) and len(v) <= 32)
if not value.strip():
return True
try:
safe_ticker_component(normalize_ticker_symbol(value))
except ValueError:
return False
return True
def get_ticker() -> str:
@@ -238,6 +255,35 @@ def _fetch_openrouter_models() -> list[tuple[str, str]]:
return []
def select_chatgpt_model(mode: str, default=None) -> str:
"""Select one of the ChatGPT plan's models, or enter a model ID."""
from tradingagents.llm_clients import chatgpt_auth
try:
models = chatgpt_auth.list_models()
except Exception as e:
console.print(f"\n[yellow]Could not list your ChatGPT plan's models: {escape(str(e))}[/yellow]")
models = []
return _select_listed_model(f"Select Your [{mode.title()}-Thinking] ChatGPT Plan Model:",
models, default, _prompt_custom_model_id)
def _select_listed_model(title: str, models, default, custom) -> str:
"""Select from ``models`` ((name, model ID) pairs, listed live) or enter one with ``custom``."""
choices = [questionary.Choice(name, value=mid) for name, mid in models]
choices.append(questionary.Choice("Custom model ID", value="custom"))
choice = questionary.select(
title,
choices=choices,
default=default if any(c.value == default for c in choices) else None,
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=_MENU_STYLE,
).ask()
if choice is None:
console.print("\n[red]No model selected. Exiting...[/red]")
exit(1)
return custom() if choice == "custom" else choice
def _require_text(message: str, hint: str) -> str:
"""Prompt for a required value; exit cleanly if the user cancels.
@@ -255,7 +301,7 @@ def _require_text(message: str, hint: str) -> str:
return response.strip()
def select_openrouter_model(mode: str) -> str:
def select_openrouter_model(mode: str, default=None) -> str:
"""Select an OpenRouter model from the newest available, or enter a custom ID.
``mode`` ("quick"/"deep") labels the prompt so the two consecutive
@@ -270,30 +316,14 @@ def select_openrouter_model(mode: str) -> str:
and mid.split("/", 1)[0] in _OPENROUTER_MAINSTREAM
]
top = (mainstream or models)[:5]
return _select_listed_model(
f"Select Your [{mode.title()}-Thinking] OpenRouter Model (latest available):", top, default,
lambda: _require_text("Enter OpenRouter model ID (e.g. google/gemma-4-26b-a4b-it):",
"Please enter a model ID."))
choices = [questionary.Choice(name, value=mid) for name, mid in top]
choices.append(questionary.Choice("Custom model ID", value="custom"))
choice = questionary.select(
f"Select Your [{mode.title()}-Thinking] OpenRouter Model (latest available):",
choices=choices,
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=questionary.Style([
("selected", "fg:magenta noinherit"),
("highlighted", "fg:magenta noinherit"),
("pointer", "fg:magenta noinherit"),
]),
).ask()
if choice is None:
console.print("\n[red]No model selected. Exiting...[/red]")
exit(1)
if choice == "custom":
return _require_text(
"Enter OpenRouter model ID (e.g. google/gemma-4-26b-a4b-it):",
"Please enter a model ID.",
)
return choice
# Providers whose models are listed live, with the menu that offers them.
LISTED_LIVE = {"openrouter": select_openrouter_model, "chatgpt": select_chatgpt_model}
def _prompt_custom_model_id() -> str:
@@ -303,8 +333,9 @@ def _prompt_custom_model_id() -> str:
def _select_model(provider: str, mode: str, default=None) -> str:
"""Select a model for the given provider and mode (quick/deep)."""
if provider.lower() == "openrouter":
return select_openrouter_model(mode)
listed = LISTED_LIVE.get(provider.lower())
if listed is not None:
return listed(mode, default)
if provider.lower() == "azure":
return _require_text(
@@ -320,13 +351,7 @@ def _select_model(provider: str, mode: str, default=None) -> str:
],
default=_matching_choice(get_model_options(provider, mode), default),
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=questionary.Style(
[
("selected", "fg:magenta noinherit"),
("highlighted", "fg:magenta noinherit"),
("pointer", "fg:magenta noinherit"),
]
),
style=_MENU_STYLE,
).ask()
if choice is None:
@@ -363,6 +388,7 @@ def _llm_provider_table() -> list[tuple[str, str, str | None]]:
("OpenAI", "openai", "https://api.openai.com/v1"),
("Google", "google", None),
("Anthropic", "anthropic", "https://api.anthropic.com/"),
("ChatGPT (Plus or Pro plan)", "chatgpt", "https://api.openai.com/v1"),
("xAI", "xai", "https://api.x.ai/v1"),
("DeepSeek", "deepseek", "https://api.deepseek.com"),
("Qwen", "qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"),
@@ -419,37 +445,50 @@ def prompt_openai_compatible_url(default=None) -> str:
return url.strip()
# The menu entry that groups the providers used through a personal plan.
_SUBSCRIPTION = ("subscription", None)
def select_llm_provider(default=None) -> tuple[str, str | None]:
"""Select the LLM provider and its API endpoint."""
PROVIDERS = _llm_provider_table()
# A region-specific key (qwen-cn) is chosen in a later prompt; the menu
# lists the base provider.
base = (default or "").split("-cn")[0]
preselected = next(
choices = []
for display, provider_key, url in PROVIDERS:
if provider_key in SIGNED_IN_PROVIDERS:
continue
choices.append(questionary.Choice(display, value=(provider_key, url)))
if provider_key == "anthropic":
choices.append(questionary.Choice("Subscription (use your ChatGPT plan)", value=_SUBSCRIPTION))
preselected = _SUBSCRIPTION if base in SIGNED_IN_PROVIDERS else next(
((key, url) for _, key, url in PROVIDERS if key == base), None
)
choice = questionary.select(
"Select your LLM Provider:",
choices=[
questionary.Choice(display, value=(provider_key, url))
for display, provider_key, url in PROVIDERS
],
choices=choices,
default=preselected,
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=questionary.Style(
[
("selected", "fg:magenta noinherit"),
("highlighted", "fg:magenta noinherit"),
("pointer", "fg:magenta noinherit"),
]
),
style=_MENU_STYLE,
).ask()
if choice is None:
console.print("\n[red]No LLM provider selected. Exiting...[/red]")
exit(1)
if choice == _SUBSCRIPTION:
choice = questionary.select(
"Select your subscription:",
choices=[questionary.Choice(display, value=(key, url))
for display, key, url in PROVIDERS if key in SIGNED_IN_PROVIDERS],
style=_MENU_STYLE,
).ask()
if choice is None:
console.print("\n[red]No subscription selected. Exiting...[/red]")
exit(1)
provider, url = choice
return provider, url
@@ -624,6 +663,32 @@ def confirm_ollama_endpoint(url: str) -> None:
)
def ensure_signed_in(provider: str) -> None:
"""Make sure a plan provider's sign-in works before the run, offering to sign in now.
Signing in runs as its own process (``tradingagents login chatgpt``), so its
read of a pasted address ends with it and never takes a later answer."""
if provider.lower() not in SIGNED_IN_PROVIDERS:
return
from tradingagents.llm_clients import chatgpt_auth # the one signed-in provider
try:
chatgpt_auth.access_token()
except chatgpt_auth.ChatGPTUnreachable as exc:
console.print(f"[red]{escape(str(exc))}[/red]")
raise typer.Exit(code=1) from None
except chatgpt_auth.ChatGPTAuthError as exc:
if not sys.stdin.isatty():
console.print(f"[red]{escape(str(exc))}[/red]")
raise typer.Exit(code=1) from None
console.print(f"\n[yellow]{escape(str(exc))}[/yellow]")
# -P keeps a cli/ package in the working directory from shadowing ours.
login = [sys.executable, "-P", "-m", "cli.main", "login", "chatgpt"]
if subprocess.run(login).returncode != 0:
raise typer.Exit(code=1) from None
console.print(f"[green]✓ Using your ChatGPT plan:[/green] {escape(chatgpt_auth.signed_in_as() or '')}")
def ensure_api_key(provider: str) -> str | None:
"""Make sure the API key for `provider` is available in the environment.
@@ -641,7 +706,6 @@ def ensure_api_key(provider: str) -> str | None:
# Key-optional providers (generic OpenAI-compatible / local servers) read the
# key when present but must never force an interactive prompt.
from tradingagents.llm_clients.openai_client import OPENAI_COMPATIBLE_PROVIDERS
spec = OPENAI_COMPATIBLE_PROVIDERS.get(provider.lower())
if spec is not None and spec.key_optional:
return os.environ.get(env_var)
+5 -4
View File
@@ -83,10 +83,11 @@ def _build_run_config(selections: dict, checkpoint: bool | None) -> dict:
config["deep_think_llm"] = selections["deep_think_llm"]
config["backend_url"] = selections["backend_url"]
config["llm_provider"] = selections["llm_provider"].lower()
# Provider-specific thinking configuration
config["google_thinking_level"] = selections.get("google_thinking_level")
config["openai_reasoning_effort"] = selections.get("openai_reasoning_effort")
config["anthropic_effort"] = selections.get("anthropic_effort")
# Provider-specific thinking configuration. Only the main provider's is
# asked; a tier on another provider keeps its configured value (#1440).
for key in ("google_thinking_level", "openai_reasoning_effort", "anthropic_effort"):
if selections.get(key) is not None:
config[key] = selections[key]
config["output_language"] = selections.get("output_language", "English")
# --checkpoint/--no-checkpoint overrides only when explicitly given; omitting
# the flag preserves TRADINGAGENTS_CHECKPOINT_ENABLED / the default (#976).
+25 -11
View File
@@ -8,7 +8,7 @@ import typer
from rich.align import Align
from rich.panel import Panel
from cli.announcements import display_announcements, fetch_announcements
from cli import updates
from cli.display import (
console,
)
@@ -24,6 +24,7 @@ from cli.prompts import (
confirm_ollama_endpoint,
detect_asset_type,
ensure_api_key,
ensure_signed_in,
get_ticker,
parse_analysis_date,
parse_analysts,
@@ -37,6 +38,8 @@ from cli.prompts import (
select_shallow_thinking_agent,
)
from tradingagents.default_config import DEFAULT_CONFIG
from tradingagents.llm_clients.factory import REASONING_EFFORT_PROVIDERS
from tradingagents.llm_clients.openai_client import check_endpoint
def get_user_selections(flags=None):
@@ -68,26 +71,35 @@ def unattended_gaps(flags) -> list[str]:
return gaps
def _check_tier_providers(main_provider: str) -> None:
def _check_tier_providers(main_provider: str, backend_url: str | None = None) -> None:
"""Check each provider the model tiers use (#1440) before the run.
A tier on another provider needs its model from its variable, since the
model question offers the main provider's models. Each provider a tier uses
has its key checked now, prompting for a missing one, rather than at its
first call, which for the deep tier comes after everything else has been
paid for; a provider no tier uses needs no key.
has its endpoint, sign-in and key checked now, prompting for a missing key,
rather than at its first call, which for the deep tier comes after everything
else has been paid for; a provider no tier uses needs no key.
"""
used = []
for tier in ("quick", "deep"):
provider = (DEFAULT_CONFIG.get(f"{tier}_think_provider") or main_provider).lower()
if provider != main_provider.lower():
on_main = provider == main_provider.lower()
if not on_main:
variable = f"TRADINGAGENTS_{tier.upper()}_THINK_LLM"
if not os.environ.get(variable):
console.print(f"[red]The {tier} tier runs on {provider}; set {variable} to one of its models.[/red]")
raise typer.Exit(code=1)
# The tier's endpoint, as create_tier_client resolves it.
url = DEFAULT_CONFIG.get(f"{tier}_think_backend_url") or (backend_url if on_main else None)
try:
check_endpoint(provider, url)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(code=1) from None
if provider not in used:
used.append(provider)
for provider in used:
ensure_signed_in(provider)
ensure_api_key(provider)
@@ -124,9 +136,11 @@ def _prompt_selections(prefs, flags):
console.print()
console.print() # Add vertical space before announcements
# Fetch and display announcements (silent on failure)
announcements = fetch_announcements()
display_announcements(console, announcements)
# What's new comes from a cache refreshed in the background, and only for
# someone at a terminal: a run never waits on it and unattended runs skip it.
if updates.enabled():
updates.show(console)
updates.start_background_refresh()
def create_question_box(title, prompt, default=None):
box_content = f"[bold]{title}[/bold]\n"
@@ -283,7 +297,7 @@ def _prompt_selections(prefs, flags):
confirm_ollama_endpoint(backend_url)
_check_tier_providers(selected_llm_provider)
_check_tier_providers(selected_llm_provider, backend_url)
# Step 7: Thinking agents (skipped when either model is set via environment)
if os.environ.get("TRADINGAGENTS_QUICK_THINK_LLM") or os.environ.get("TRADINGAGENTS_DEEP_THINK_LLM"):
@@ -327,7 +341,7 @@ def _prompt_selections(prefs, flags):
"Gemini thinking mode", "Step 8: Thinking Mode",
"Configure Gemini thinking mode", ask_gemini_thinking_config,
)
elif provider_lower == "openai":
elif provider_lower in REASONING_EFFORT_PROVIDERS:
reasoning_effort = thinking_value_or_prompt(
"TRADINGAGENTS_OPENAI_REASONING_EFFORT", "openai_reasoning_effort",
"Reasoning effort", "Step 8: Reasoning Effort",
+220
View File
@@ -0,0 +1,220 @@
"""What's new, for the person at the terminal: a newer release, and notices from Tauric Research.
At most once a day a background thread asks GitHub for the latest release and
the announcements server for notices, and writes the answers to a small cache.
The lines shown at start-up are read from that cache, so a run never waits on
the network and news shows up on the next run. Nothing identifying is sent.
Unattended, scripted and CI runs contact no server; set
TRADINGAGENTS_NO_UPDATE_CHECK=1 to turn the check off.
"""
import importlib.metadata
import json
import os
import re
import shlex
import sys
import threading
import time
from collections.abc import Callable
from datetime import UTC, date, datetime
from pathlib import Path
from urllib.parse import unquote, urlparse
import requests
from packaging.version import InvalidVersion, Version
from tradingagents.dataflows.files import replace_file
from tradingagents.default_config import _TRADINGAGENTS_HOME
RELEASES_URL = "https://api.github.com/repos/TauricResearch/TradingAgents/releases/latest"
NOTICES_URL = "https://api.tauric.ai/v1/announcements"
CACHE = Path(_TRADINGAGENTS_HOME) / "update_check.json"
CHECK_EVERY = 24 * 3600
TIMEOUT = 2.0
SUMMARY_LIMIT = 160
OPT_OUT = "TRADINGAGENTS_NO_UPDATE_CHECK"
# Control characters, C0 and C1, read as spaces; invisible spaces and direction marks are dropped.
CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]")
INVISIBLE = re.compile(r"[\u061c\u200b\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]")
DATE = re.compile(r"\d{4}-\d{2}-\d{2}")
def enabled() -> bool:
"""Whether a person is at the terminal and has not turned the check off."""
at_a_terminal = bool(sys.stdin and sys.stdin.isatty() and sys.stdout and sys.stdout.isatty())
return at_a_terminal and not os.environ.get("CI") and not os.environ.get(OPT_OUT)
# --- the cache and the check -------------------------------------------------------
def read_cache() -> dict:
"""The cache, keeping only fields of the shape this version writes."""
try:
data = json.loads(CACHE.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
if not isinstance(data, dict):
return {}
shapes = {"checked_at": (int, float), "latest": dict, "notices": list}
return {key: data[key] for key, shape in shapes.items() if isinstance(data.get(key), shape)}
def due(cache: dict, now: float) -> bool:
checked = cache.get("checked_at")
return not isinstance(checked, (int, float)) or not 0 <= now - checked < CHECK_EVERY
def summary_of(body: str) -> str:
"""The release's one-line summary: its first paragraph on one line (sanitised when shown)."""
first = re.split(r"\n\s*\n", (body or "").strip(), maxsplit=1)[0]
text = " ".join(first.split())
if len(text) > SUMMARY_LIMIT:
text = text[: SUMMARY_LIMIT - 3].rsplit(" ", 1)[0] + "..."
return text
def parse_notices(payload) -> list | None:
"""The targeted notices in an announcements answer; None when it has no notices list."""
notices = payload.get("notices") if isinstance(payload, dict) else None
return notices if isinstance(notices, list) else None
def _get_json(url: str):
response = requests.get(url, timeout=TIMEOUT)
return response.json() if response.ok else None
def refresh(now: float | None = None) -> None:
"""Ask for the latest release and notices and record the answers. Never raises or prints;
a failed check keeps what was known and is not retried for a day."""
now = time.time() if now is None else now
cache = read_cache()
cache["checked_at"] = now
try:
release = _get_json(RELEASES_URL)
if isinstance(release, dict) and release.get("tag_name"):
cache["latest"] = {
"version": str(release["tag_name"]).removeprefix("v"),
"url": release.get("html_url"),
"summary": summary_of(release.get("body") or ""),
}
except Exception:
pass
try:
notices = parse_notices(_get_json(NOTICES_URL))
if notices is not None:
cache["notices"] = notices
except Exception:
pass
try:
CACHE.parent.mkdir(parents=True, exist_ok=True)
replace_file(CACHE, lambda temp: Path(temp).write_text(json.dumps(cache), encoding="utf-8"))
except Exception:
pass
def start_background_refresh() -> None:
"""Refresh the cache in a daemon thread when a check is due."""
if due(read_cache(), time.time()):
threading.Thread(target=refresh, name="tradingagents-update-check", daemon=True).start()
# --- what is shown ---------------------------------------------------------------
def _version(text) -> Version | None:
try:
return Version(str(text))
except (InvalidVersion, TypeError):
return None
def _plain(text) -> str:
return " ".join(CONTROL.sub(" ", INVISIBLE.sub("", str(text or ""))).split())
def _applies(notice: dict, running: Version, today: date) -> bool:
"""Whether a notice is for this version and unexpired; a bound or date that cannot be read
keeps it hidden. A notice is shown through its ``expires`` date (UTC)."""
low, high = notice.get("min_version"), notice.get("max_version")
if low is not None and ((floor := _version(low)) is None or running < floor):
return False
if high is not None and ((ceiling := _version(high)) is None or running > ceiling):
return False
expires = notice.get("expires")
if expires is None:
return True
try:
return isinstance(expires, str) and bool(DATE.fullmatch(expires)) and date.fromisoformat(expires) >= today
except ValueError:
return False
def messages(cache: dict, running: str, upgrade: Callable[[], str | None] = lambda: None) -> list[str]:
"""The lines to show: a newer release with its upgrade command (``upgrade()``, asked only
when there is one), then the notices for this version."""
current = _version(running)
if current is None:
return []
lines = []
latest = cache.get("latest") or {}
newest = _version(latest.get("version"))
if newest is not None and newest > current:
lines.append(f"TradingAgents {_plain(latest['version'])} is available (you have {_plain(running)}): "
f"{_plain(latest.get('summary'))}")
if latest.get("url"):
lines.append(_plain(latest["url"]))
command = upgrade()
if command:
lines.append(f"Upgrade: {_plain(command)}")
today = datetime.now(UTC).date()
for notice in cache.get("notices") or []:
if not (isinstance(notice, dict) and isinstance(notice.get("message"), str) and _plain(notice["message"])
and _applies(notice, current, today)):
continue
url = notice.get("url")
link = url if isinstance(url, str) and url.lower().startswith(("http://", "https://")) else None
lines.append(_plain(notice["message"]) + (f" {_plain(link)}" if link else ""))
return lines
def upgrade_command(dist=None) -> str | None:
"""The command that upgrades this install, from how pip or uv recorded it; None when unsure."""
if os.environ.get("TRADINGAGENTS_IN_DOCKER"):
return "git pull && docker compose build (on the host)"
if os.name == "nt": # the commands below are POSIX shell
return None
try:
dist = dist or importlib.metadata.distribution("tradingagents")
direct = json.loads(dist.read_text("direct_url.json") or "null")
installer = (dist.read_text("INSTALLER") or "").strip()
except (importlib.metadata.PackageNotFoundError, OSError, ValueError):
return None
if not isinstance(direct, dict) or not direct.get("url"):
return None
python = shlex.quote(sys.executable)
install = f"uv pip install --python {python}" if installer == "uv" else f"{python} -m pip install"
if "vcs_info" in direct:
return f"{install} -U git+{direct['url']}"
parsed = urlparse(direct["url"])
if parsed.scheme != "file":
return None
clone = Path(unquote(parsed.path))
if not (clone / ".git").exists():
return None
editable = (direct.get("dir_info") or {}).get("editable")
target = shlex.quote(str(clone))
return f"git -C {target} pull && {install} {'-e ' if editable else ''}{target}"
def show(console) -> None:
"""Print what's new from the cache, under the welcome panel."""
from rich.panel import Panel
from rich.text import Text
from tradingagents import __version__
lines = messages(read_cache(), running=__version__, upgrade=upgrade_command)
if lines:
console.print(Panel(Text("\n".join(lines)), border_style="cyan", padding=(1, 2), title="What's new"))
console.print()
+2
View File
@@ -9,6 +9,7 @@ description = "TradingAgents: Multi-Agents LLM Financial Trading Framework"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"cryptography>=42.0",
"langchain-core>=1.6.5",
"langchain-anthropic>=1.7.4",
"langchain-google-genai>=4.4.0",
@@ -16,6 +17,7 @@ dependencies = [
"langgraph>=1.2.12",
"langgraph-checkpoint-sqlite>=3.1.1",
"markdown-it-py>=4.0",
"packaging>=24.0",
"pandas>=3.0.6",
"python-dotenv>=1.0.0",
"pytz>=2025.2",
+3 -1
View File
@@ -28,6 +28,8 @@ class TestEffortGate:
"claude-haiku-4-5", "claude-haiku-5-0", "claude-haiku-4-7-preview",
# Sonnet 4.5 (and earlier) 400 on effort — only Sonnet 4.6+ supports it.
"claude-sonnet-4-5", "claude-sonnet-4-0",
# A dated snapshot's date is not its minor version (#1585).
"claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-sonnet-4-5-20250929",
],
)
def test_unsupported_models_do_not_receive_effort(self, monkeypatch, model):
@@ -39,7 +41,7 @@ class TestEffortGate:
"model",
[
"claude-opus-4-5", "claude-opus-4-6", "claude-opus-4-7",
"claude-sonnet-4-6",
"claude-sonnet-4-6", "claude-opus-4-5-20251101",
],
)
def test_current_opus_and_sonnet_receive_effort(self, monkeypatch, model):
+28
View File
@@ -0,0 +1,28 @@
"""Azure OpenAI takes the run's settings like every other provider."""
from __future__ import annotations
import pytest
from tradingagents.llm_clients.factory import create_llm_client
@pytest.fixture(autouse=True)
def _azure_env(monkeypatch):
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-key")
monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://from-env.openai.azure.com/")
monkeypatch.setenv("OPENAI_API_VERSION", "2025-03-01-preview")
monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False)
@pytest.mark.unit
def test_the_output_token_cap_reaches_the_deployment():
llm = create_llm_client("azure", "gpt-5.5", max_tokens=1234).get_llm()
assert llm.max_tokens == 1234
@pytest.mark.unit
def test_an_explicit_endpoint_wins_over_the_environment():
llm = create_llm_client("azure", "gpt-5.5", base_url="https://deep.openai.azure.com/").get_llm()
assert llm.azure_endpoint == "https://deep.openai.azure.com/"
assert create_llm_client("azure", "gpt-5.5").get_llm().azure_endpoint == "https://from-env.openai.azure.com/"
+60
View File
@@ -12,6 +12,7 @@ import pytest
from tradingagents.backtest import iter_grid, run_backtest, summarize
from tradingagents.memory import TradingMemoryLog
from tradingagents.memory.settlement import Settlement
DECISION = "Rating: Buy\n\nbuy it"
@@ -58,6 +59,7 @@ class _FakeGraph:
def settle_pending(self, ticker):
self.settled.append(ticker)
return Settlement()
@pytest.fixture(autouse=True)
@@ -153,6 +155,23 @@ def test_the_analyst_set_under_test_is_the_one_that_runs(tmp_path):
assert _FakeGraph.instances[-1].analysts == ["market", "news"]
@pytest.mark.unit
def test_sweeps_started_in_the_same_second_get_their_own_runs(tmp_path, monkeypatch):
import tradingagents.backtest as backtest
class _Frozen(backtest.datetime):
@classmethod
def now(cls, tz=None):
return cls(2026, 1, 20, 9, 30, 0)
monkeypatch.setattr(backtest, "datetime", _Frozen)
first = run_backtest(["NVDA"], ["2026-01-05"], _config(tmp_path))
second = run_backtest(["NVDA"], ["2026-01-05"], _config(tmp_path))
assert first.run_id == "20260120_093000" and second.run_id == "20260120_093000_2"
assert first.cells_run == second.cells_run == 1
@pytest.mark.unit
def test_a_run_id_cannot_escape_the_results_directory(tmp_path):
"""run_id becomes a path segment, so it is validated like a ticker is."""
@@ -172,6 +191,7 @@ def test_a_failed_settlement_does_not_lose_the_remaining_tickers(tmp_path, monke
if ticker == "NVDA":
raise RuntimeError("reflector timed out")
settled.append(ticker)
return Settlement()
monkeypatch.setattr(_FakeGraph, "settle_pending", _settle, raising=False)
result = run_backtest(["NVDA", "AAPL"], ["2026-01-05"], _config(tmp_path))
@@ -181,6 +201,46 @@ def test_a_failed_settlement_does_not_lose_the_remaining_tickers(tmp_path, monke
assert result.settlement_failures == [("NVDA", "reflector timed out")]
@pytest.mark.unit
@pytest.mark.parametrize("failure", ["prices", "reflection"])
def test_handled_settlement_failures_reach_the_backtest_result(tmp_path, monkeypatch, failure):
from types import SimpleNamespace
from tradingagents.memory import settlement
def returns(ticker, *args, **kwargs):
if ticker == "AAPL":
return None, None, None, None # an incomplete window is not a failure
if failure == "prices":
raise RuntimeError("vendor unavailable")
return 0.1, 0.02, 5, "2026-02-01"
def reflect(**kwargs):
raise RuntimeError("reflector timed out")
def settle(self, ticker):
self.settled.append(ticker)
return settlement.settle_pending(
ticker, self.memory_log, SimpleNamespace(reflect_on_final_decision=reflect), self.config,
)
monkeypatch.setattr(settlement, "fetch_returns", returns)
monkeypatch.setattr(_FakeGraph, "settle_pending", settle)
result = run_backtest(
["NVDA", "AAPL"], ["2026-01-05", "2026-01-12"], _config(tmp_path),
)
reason = ("prices unavailable: vendor unavailable" if failure == "prices"
else "reflection failed: reflector timed out")
assert result.settlement_failures == [
("NVDA", f"2026-01-05: {reason}"),
("NVDA", f"2026-01-12: {reason}"),
]
assert _FakeGraph.instances[-1].settled == ["NVDA", "AAPL"]
assert result.cells_run == 4 and not result.failures
assert len(_FakeGraph.instances[-1].memory_log.get_pending_entries()) == 4
@pytest.mark.unit
def test_pending_note_appears_only_when_something_is_pending(tmp_path):
settled = [("NVDA", "2026-01-05", DECISION, (0.1, 0.05))]
+1 -1
View File
@@ -106,7 +106,7 @@ def test_on_windows_the_lock_is_waited_for_while_another_writer_holds_it(tmp_pat
import errno
import sys
fake, calls = _fake_msvcrt([OSError(errno.EDEADLOCK, "held"), None])
fake, calls = _fake_msvcrt([OSError(errno.EDEADLK, "held"), None])
monkeypatch.setitem(sys.modules, "msvcrt", fake)
with open(tmp_path / "log.md.lock", "a+b") as handle, files._hold_windows(handle):
+427
View File
@@ -0,0 +1,427 @@
"""Sign in with ChatGPT: the sign-in is checked, kept private, renewed once, and revoked.
OpenAI is replaced by a fake that signs ID tokens with a key made here, so every
check of the sign-in (signature, issuer, audience, nonce, plan scope) runs for real.
"""
import base64
import json
import os
import socket
import stat
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import pytest
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from tradingagents.llm_clients import chatgpt_auth as auth
# The suite refuses all connections; the loopback test lets 127.0.0.1 through only.
_CONNECT, _GETADDRINFO = socket.socket.connect, socket.getaddrinfo
KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
CLIENT = "oaiapp_test"
SUBJECT = "user-123"
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def _id_token(key=KEY, **claims) -> str:
payload = {"iss": auth.ISSUER, "aud": CLIENT, "sub": SUBJECT, "email": "a@example.com",
"exp": time.time() + 3600, **claims}
head = _b64(json.dumps({"alg": "RS256", "kid": "k1"}).encode())
body = _b64(json.dumps(payload).encode())
signature = key.sign(f"{head}.{body}".encode(), padding.PKCS1v15(), hashes.SHA256())
return f"{head}.{body}.{_b64(signature)}"
def _jwks():
numbers = KEY.public_key().public_numbers()
return {"keys": [{"kid": "k1", "kty": "RSA", "e": _b64(numbers.e.to_bytes(3, "big")),
"n": _b64(numbers.n.to_bytes(256, "big"))}]}
class _Response:
def __init__(self, body, status=200):
self.body, self.status_code = body, status
def json(self):
return self.body
class FakeOpenAI:
"""Answers the JWKS, token and revoke endpoints and records what was sent."""
def __init__(self, token_body=None, status=200):
self.posts = []
self.token_body = token_body
self.status = status
self.lock = threading.Lock()
self.keys_reachable = True
self.revoke_status = 200
self.post_error = None
def get(self, url, timeout):
assert url == auth.JWKS_URL
if not self.keys_reachable:
raise auth.requests.ConnectionError("down")
return _Response(_jwks())
def post(self, url, timeout, data):
with self.lock:
self.posts.append((url, data))
if url == auth.REVOKE_URL:
return _Response({}, self.revoke_status)
if self.post_error is not None:
raise self.post_error
time.sleep(0.05) # let a racing refresher reach the lock
body = self.token_body(data) if callable(self.token_body) else self.token_body
return _Response(body, self.status)
@pytest.fixture
def home(tmp_path, monkeypatch):
monkeypatch.setattr(auth, "AUTH_FILE", tmp_path / "home" / "chatgpt-auth.json")
monkeypatch.setattr(auth, "_retry_after", 0.0)
return tmp_path
@pytest.fixture
def openai(monkeypatch):
fake = FakeOpenAI()
monkeypatch.setattr(auth.requests, "get", fake.get)
monkeypatch.setattr(auth.requests, "post", fake.post)
return fake
def _signed_in(expires_at, earliest=None, refresh_token="r1"):
auth.AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
auth._write({"host_id": "urn:uuid:h", "account": {"client_id": CLIENT, "email": "a@example.com",
"subject": SUBJECT},
"tokens": {"access_token": "a1", "refresh_token": refresh_token,
"expires_at": expires_at, "earliest_refresh_at": earliest}})
def _renewal(data):
return {"access_token": "a2", "refresh_token": "r2", "expires_in": 3600,
"id_token": _id_token(), "scope": auth.SCOPE}
# --- the ID token ----------------------------------------------------------------
@pytest.mark.unit
def test_a_genuine_id_token_is_accepted():
assert auth._validated_claims(_id_token(nonce="n"), CLIENT, "n", _jwks()["keys"])["sub"] == SUBJECT
@pytest.mark.unit
@pytest.mark.parametrize("token, nonce", [
(lambda: _id_token(nonce="other"), "n"),
(lambda: _id_token(aud="oaiapp_someone_else", nonce="n"), "n"),
(lambda: _id_token(iss="https://evil.example", nonce="n"), "n"),
(lambda: _id_token(exp=time.time() - 600, nonce="n"), "n"),
])
def test_an_id_token_for_another_sign_in_is_refused(token, nonce):
with pytest.raises(auth.ChatGPTAuthError):
auth._validated_claims(token(), CLIENT, nonce, _jwks()["keys"])
@pytest.mark.unit
def test_an_id_token_signed_by_another_key_is_refused():
stranger = rsa.generate_private_key(public_exponent=65537, key_size=2048)
with pytest.raises(auth.ChatGPTAuthError, match="do not verify"):
auth._validated_claims(_id_token(key=stranger, nonce="n"), CLIENT, "n", _jwks()["keys"])
# --- tokens ----------------------------------------------------------------------
@pytest.mark.unit
def test_nobody_signed_in_is_told_how_to_sign_in(home, openai):
with pytest.raises(auth.ChatGPTAuthError, match="tradingagents login chatgpt"):
auth.credential()
with pytest.raises(auth.ChatGPTAuthError, match="tradingagents login chatgpt"):
auth.access_token()
assert openai.posts == []
@pytest.mark.unit
def test_a_fresh_token_is_used_without_asking_openai(home, openai):
_signed_in(expires_at=time.time() + 3000)
assert auth.credential()() == "a1"
assert openai.posts == []
@pytest.mark.unit
def test_a_token_near_expiry_is_renewed_with_the_stored_refresh_token(home, openai):
_signed_in(expires_at=time.time() + 60)
openai.token_body = _renewal
assert auth.access_token() == "a2"
(url, sent), = openai.posts
assert url == auth.TOKEN_URL and sent["refresh_token"] == "r1" and sent["client_id"] == CLIENT
stored = json.loads(auth.AUTH_FILE.read_text())
assert stored["tokens"]["refresh_token"] == "r2" and stored["account"]["subject"] == SUBJECT
if os.name != "nt": # Windows keeps no POSIX mode; the profile's ACL guards it
assert stat.S_IMODE(auth.AUTH_FILE.stat().st_mode) == 0o600
@pytest.mark.unit
def test_renewal_waits_for_the_time_openai_allows_while_the_token_still_works(home, openai):
now = time.time()
_signed_in(expires_at=now + 200, earliest=now + 100)
assert auth.access_token() == "a1"
assert openai.posts == []
@pytest.mark.unit
def test_concurrent_runs_renew_once_and_never_replay_a_used_refresh_token(home, openai):
_signed_in(expires_at=time.time() + 60)
openai.token_body = _renewal
tokens = []
threads = [threading.Thread(target=lambda: tokens.append(auth.access_token())) for _ in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert tokens == ["a2"] * 4
assert [sent["refresh_token"] for _, sent in openai.posts] == ["r1"]
@pytest.mark.unit
def test_a_refused_renewal_asks_to_sign_in_again_and_keeps_the_file(home, openai):
_signed_in(expires_at=time.time() + 60)
openai.token_body, openai.status = {"error": "invalid_grant"}, 400
with pytest.raises(auth.ChatGPTAuthError, match="invalid_grant.*login chatgpt"):
auth.access_token()
assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1"
@pytest.mark.unit
def test_a_renewal_for_another_account_is_refused(home, openai):
_signed_in(expires_at=time.time() + 60)
openai.token_body = lambda data: {**_renewal(data), "id_token": _id_token(sub="someone-else")}
with pytest.raises(auth.ChatGPTAuthError, match="another account"):
auth.access_token()
# --- signing in and out ------------------------------------------------------------
def _nonce_of(printed):
url = next(line for line in printed if line.startswith(auth.AUTHORIZE_URL))
return dict(urllib.parse.parse_qsl(urllib.parse.urlparse(url).query))["nonce"]
def _paster(printed, edit=lambda query: query):
calls = []
def read_line():
calls.append(1)
if len(calls) > 1:
threading.Event().wait()
url = next(line for line in printed if line.startswith(auth.AUTHORIZE_URL))
asked = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(url).query))
query = edit({"code": "c1", "state": asked["state"], "client_id": CLIENT})
return f"{asked['redirect_uri']}?{urllib.parse.urlencode(query)}"
return read_line
@pytest.mark.unit
def test_signing_in_by_pasting_the_redirect_saves_a_private_sign_in(home, openai):
printed = []
def token(data):
assert data["grant_type"] == "authorization_code" and data["client_id"] == CLIENT
assert data["code"] == "c1" and data["code_verifier"]
return {"access_token": "a1", "refresh_token": "r1", "expires_in": 3600,
"id_token": _id_token(nonce=_nonce_of(printed)), "scope": auth.SCOPE}
openai.token_body = token
email = auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0)
assert email == "a@example.com"
stored = json.loads(auth.AUTH_FILE.read_text())
assert stored["account"] == {"client_id": CLIENT, "email": "a@example.com", "subject": SUBJECT}
assert stored["host_id"].startswith("urn:uuid:")
if os.name != "nt": # Windows keeps no POSIX mode; the profile's ACL guards it
assert stat.S_IMODE(auth.AUTH_FILE.stat().st_mode) == 0o600
asked = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(printed[1]).query))
assert asked["client_id"] == auth.DYNAMIC_CLIENT_ID and asked["agent_name_hint"] == "TradingAgents"
assert asked["code_challenge_method"] == "S256" and asked["redirect_uri"].startswith("http://127.0.0.1:")
@pytest.mark.unit
def test_an_answer_for_another_sign_in_attempt_is_ignored(home, openai):
printed = []
with pytest.raises(auth.ChatGPTAuthError, match="timed out"):
auth.sign_in(say=printed.append, read_line=_paster(printed, lambda q: {**q, "state": "forged"}),
timeout=1, port=0)
assert any("not the address" in line for line in printed)
assert openai.posts == [] and not auth.AUTH_FILE.exists()
def _status(url):
try:
return urllib.request.urlopen(url, timeout=5).status
except urllib.error.HTTPError as exc:
return exc.code
@pytest.mark.unit
def test_the_browser_answer_reaches_the_loopback_and_strays_are_ignored(home, openai, monkeypatch):
refused_connect, refused_lookup = socket.socket.connect, socket.getaddrinfo
monkeypatch.setattr(socket.socket, "connect", lambda sock, address: (
_CONNECT if address[0] == "127.0.0.1" else refused_connect)(sock, address))
monkeypatch.setattr(socket, "getaddrinfo", lambda host, *a, **k: (
_GETADDRINFO if host == "127.0.0.1" else refused_lookup)(host, *a, **k))
printed = []
openai.token_body = lambda data: {"access_token": "a1", "refresh_token": "r1", "expires_in": 3600,
"id_token": _id_token(nonce=_nonce_of(printed)), "scope": auth.SCOPE}
def browser():
while not any(line.startswith(auth.AUTHORIZE_URL) for line in printed):
time.sleep(0.01)
asked = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(printed[1]).query))
results.append(_status(f"{asked['redirect_uri']}?error=access_denied"))
results.append(_status(f"{asked['redirect_uri']}?code=c1&state={asked['state']}&client_id={CLIENT}"))
results = []
threading.Thread(target=browser, daemon=True).start()
email = auth.sign_in(say=printed.append, read_line=threading.Event().wait, timeout=10, port=0)
assert email == "a@example.com" and results == [400, 200]
@pytest.mark.unit
def test_a_sign_in_without_plan_usage_is_refused(home, openai):
printed = []
openai.token_body = lambda data: {"access_token": "a1", "refresh_token": "r1", "expires_in": 3600,
"id_token": _id_token(nonce=_nonce_of(printed)),
"scope": "openid email offline_access"}
with pytest.raises(auth.ChatGPTAuthError, match="approve plan usage"):
auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0)
assert not auth.AUTH_FILE.exists()
@pytest.mark.unit
def test_signing_out_revokes_the_sign_in_and_keeps_only_the_host_id(home, openai):
_signed_in(expires_at=time.time() + 3000)
assert auth.sign_out() == "revoked"
(url, sent), = openai.posts
assert url == auth.REVOKE_URL and sent["token"] == "r1" and sent["client_id"] == CLIENT
assert json.loads(auth.AUTH_FILE.read_text()) == {"host_id": "urn:uuid:h"}
assert auth.signed_in_as() is None
assert auth.sign_out() is None
@pytest.mark.unit
def test_unreachable_keys_stop_a_renewal_before_the_refresh_token_is_spent(home, openai):
_signed_in(expires_at=time.time() - 1)
openai.token_body, openai.keys_reachable = _renewal, False
with pytest.raises(auth.ChatGPTUnreachable, match="Could not reach OpenAI"):
auth.access_token()
assert openai.posts == []
assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1"
@pytest.mark.unit
@pytest.mark.parametrize("failure", ["keys", "network", 429, 503])
def test_a_temporary_failure_keeps_a_token_that_still_works(home, openai, failure):
_signed_in(expires_at=time.time() + 60)
openai.token_body = _renewal
if failure == "keys":
openai.keys_reachable = False
elif failure == "network":
openai.post_error = auth.requests.ConnectionError("reset")
else:
openai.token_body, openai.status = {"error": "busy"}, failure
assert auth.access_token() == "a1"
assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1"
if failure == "keys":
assert openai.posts == []
@pytest.mark.unit
@pytest.mark.parametrize("failure", ["network", 408, 503])
def test_a_temporary_failure_after_expiry_says_to_try_again_not_to_sign_in(home, openai, failure):
_signed_in(expires_at=time.time() - 1)
if failure == "network":
openai.post_error = auth.requests.Timeout("slow")
else:
openai.token_body, openai.status = {"error": "busy"}, failure
with pytest.raises(auth.ChatGPTUnreachable) as raised:
auth.access_token()
assert "try again" in str(raised.value) and "login" not in str(raised.value)
@pytest.mark.unit
def test_signing_in_again_revokes_the_replaced_sign_in(home, openai):
_signed_in(expires_at=time.time() + 3000, refresh_token="old")
printed = []
openai.token_body = lambda data: {"access_token": "a2", "refresh_token": "r2", "expires_in": 3600,
"id_token": _id_token(nonce=_nonce_of(printed)), "scope": auth.SCOPE}
auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0)
assert openai.posts[-1][0] == auth.REVOKE_URL and openai.posts[-1][1]["token"] == "old"
assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r2"
@pytest.mark.unit
def test_another_account_cannot_replace_the_signed_in_one(home, openai):
_signed_in(expires_at=time.time() + 3000)
printed = []
openai.token_body = lambda data: {"access_token": "a2", "refresh_token": "r2", "expires_in": 3600,
"id_token": _id_token(nonce=_nonce_of(printed), sub="someone-else"),
"scope": auth.SCOPE}
with pytest.raises(auth.ChatGPTAuthError, match="logout chatgpt"):
auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0)
assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1"
@pytest.mark.unit
def test_signing_out_while_openai_is_unreachable_still_forgets_the_sign_in(home, openai):
_signed_in(expires_at=time.time() + 3000)
openai.revoke_status = 503
assert auth.sign_out() == "forgotten"
assert auth.signed_in_as() is None
@pytest.mark.unit
def test_after_a_temporary_failure_renewal_waits_a_minute_before_trying_again(home, openai):
_signed_in(expires_at=time.time() + 200)
openai.post_error = auth.requests.ConnectionError("reset")
clock = [time.time()]
assert auth.access_token(now=lambda: clock[0]) == "a1"
assert auth.access_token(now=lambda: clock[0] + 30) == "a1"
assert len(openai.posts) == 1
openai.post_error, openai.token_body = None, _renewal
assert auth.access_token(now=lambda: clock[0] + 61) == "a2"
@pytest.mark.unit
def test_after_expiry_a_failed_renewal_is_not_retried_within_the_minute(home, openai):
_signed_in(expires_at=time.time() - 1)
openai.post_error = auth.requests.ConnectionError("reset")
for _ in range(3):
with pytest.raises(auth.ChatGPTUnreachable, match="try again"):
auth.access_token()
assert len(openai.posts) == 1
@pytest.mark.unit
def test_an_unreadable_renewal_answer_keeps_a_token_that_still_works(home, openai):
_signed_in(expires_at=time.time() + 60)
openai.token_body = lambda data: ["not", "a", "token"]
assert auth.access_token() == "a1"
assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1"
@pytest.mark.unit
def test_a_network_failure_while_signing_in_is_reported_not_raised_raw(home, openai):
printed = []
openai.post_error = auth.requests.ConnectionError("reset")
with pytest.raises(auth.ChatGPTUnreachable, match="sign you in to ChatGPT.*try again"):
auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0)
assert not auth.AUTH_FILE.exists()
+245
View File
@@ -0,0 +1,245 @@
"""The chatgpt provider runs on the person's ChatGPT plan.
It sends their sign-in and nothing else, only to OpenAI, in the shape plan usage
accepts (streamed, unstored, no ``system`` role); the CLI offers it under
"Subscription", checks the sign-in before a run, and lists the plan's models.
"""
import logging
from types import SimpleNamespace
from unittest import mock
import pytest
import typer
from langchain_core.messages import HumanMessage, SystemMessage
import cli.prompts as prompts
import cli.selections as selections
from tradingagents import default_config
from tradingagents.llm_clients import chatgpt_auth, factory
from tradingagents.llm_clients.openai_client import ChatGPTPlanChatOpenAI
@pytest.fixture
def signed_in(monkeypatch):
monkeypatch.setattr(chatgpt_auth, "credential", lambda: (lambda: "plan-token"))
monkeypatch.setattr(chatgpt_auth, "access_token", lambda: "plan-token")
monkeypatch.setattr(chatgpt_auth, "signed_in_as", lambda: "a@example.com")
def _llm(**kwargs):
config = {**default_config.DEFAULT_CONFIG, "llm_provider": "chatgpt", "openai_reasoning_effort": "high", **kwargs}
return factory.create_llm_client("chatgpt", "gpt-5.5", **factory.build_llm_kwargs(config)).get_llm()
# --- the client ------------------------------------------------------------------
@pytest.mark.unit
def test_the_plan_is_reached_with_the_sign_in_never_an_api_key(signed_in, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-should-not-be-used")
llm = _llm()
assert isinstance(llm, ChatGPTPlanChatOpenAI)
assert llm.openai_api_base == "https://api.openai.com/v1"
assert llm.openai_api_key() == "plan-token"
assert (llm.use_responses_api, llm.streaming, llm.store, llm.reasoning_effort) == (True, True, False, "high")
@pytest.mark.unit
def test_system_instructions_go_as_developer_and_refused_settings_are_dropped(signed_in, caplog):
with caplog.at_level(logging.WARNING):
llm = _llm(temperature=0.3, max_tokens=500)
payload = llm._get_request_payload([SystemMessage("Be brief."), HumanMessage("Hi")])
assert [item["role"] for item in payload["input"]] == ["developer", "user"]
assert not {"temperature", "max_output_tokens"} & payload.keys()
assert payload["stream"] is True and payload["store"] is False
assert "does not accept temperature" in caplog.text and "does not accept max_tokens" in caplog.text
@pytest.mark.unit
def test_a_run_without_a_sign_in_stops_before_any_request(monkeypatch, tmp_path):
monkeypatch.setattr(chatgpt_auth, "AUTH_FILE", tmp_path / "chatgpt-auth.json")
with pytest.raises(chatgpt_auth.ChatGPTAuthError, match="tradingagents login chatgpt"):
_llm()
@pytest.mark.unit
def test_a_chatgpt_tier_beside_an_openai_tier_keeps_each_credential(signed_in, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai")
config = {**default_config.DEFAULT_CONFIG, "llm_provider": "openai", "backend_url": "https://api.openai.com/v1",
"quick_think_provider": "chatgpt", "quick_think_llm": "gpt-5.5", "deep_think_llm": "gpt-6-sol",
"openai_reasoning_effort": "low"}
quick = factory.create_tier_client(config, "quick").get_llm()
deep = factory.create_tier_client(config, "deep").get_llm()
assert isinstance(quick, ChatGPTPlanChatOpenAI) and quick.reasoning_effort == "low"
assert quick.openai_api_key() == "plan-token"
assert not isinstance(deep, ChatGPTPlanChatOpenAI)
assert deep.openai_api_key.get_secret_value() == "sk-openai"
# --- the CLI ---------------------------------------------------------------------
def _menu(answers):
"""questionary.select stand-in: records each menu and answers in turn."""
shown = []
def select(message, choices, **kwargs):
shown.append((message, [c.title for c in choices], kwargs.get("default")))
return SimpleNamespace(ask=lambda: answers.pop(0)(choices))
return shown, select
@pytest.mark.unit
def test_chatgpt_is_offered_under_subscription_after_anthropic():
shown, select = _menu([lambda cs: next(c.value for c in cs if c.title.startswith("Subscription")),
lambda cs: cs[0].value])
with mock.patch("cli.prompts.questionary.select", select):
provider, url = prompts.select_llm_provider()
titles = shown[0][1]
assert titles.index("Anthropic") + 1 == titles.index(next(t for t in titles if t.startswith("Subscription")))
assert not any(t.startswith("ChatGPT") for t in titles)
assert shown[1][1] == ["ChatGPT (Plus or Pro plan)"]
assert (provider, url) == ("chatgpt", "https://api.openai.com/v1")
@pytest.mark.unit
def test_a_remembered_chatgpt_choice_preselects_subscription():
shown, select = _menu([lambda cs: ("openai", "https://api.openai.com/v1")])
with mock.patch("cli.prompts.questionary.select", select):
prompts.select_llm_provider(default="chatgpt")
assert shown[0][2] == ("subscription", None)
@pytest.mark.unit
def test_a_signed_in_plan_is_named_before_the_run(signed_in, capsys):
prompts.ensure_signed_in("chatgpt")
assert "Using your ChatGPT plan" in capsys.readouterr().out
def _expired(monkeypatch):
def refuse():
raise chatgpt_auth.ChatGPTAuthError("Your ChatGPT sign-in could not be renewed (invalid_grant); "
"run `tradingagents login chatgpt` again.")
monkeypatch.setattr(chatgpt_auth, "access_token", refuse)
@pytest.mark.unit
def test_an_unattended_run_with_a_dead_sign_in_says_how_to_sign_in(monkeypatch, capsys):
_expired(monkeypatch)
monkeypatch.setattr("sys.stdin", SimpleNamespace(isatty=lambda: False))
with pytest.raises(typer.Exit):
prompts.ensure_signed_in("chatgpt")
assert "tradingagents login chatgpt" in " ".join(capsys.readouterr().out.split())
@pytest.mark.unit
def test_an_interactive_run_signs_in_again_through_the_login_command(monkeypatch, capsys):
_expired(monkeypatch)
monkeypatch.setattr(chatgpt_auth, "signed_in_as", lambda: "a@example.com")
monkeypatch.setattr("sys.stdin", SimpleNamespace(isatty=lambda: True))
ran = []
monkeypatch.setattr(prompts.subprocess, "run", lambda argv: ran.append(argv) or SimpleNamespace(returncode=0))
prompts.ensure_signed_in("chatgpt")
assert ran[0][1:] == ["-P", "-m", "cli.main", "login", "chatgpt"]
assert "Using your ChatGPT plan" in capsys.readouterr().out
@pytest.mark.unit
def test_each_tier_s_provider_is_signed_in_or_keyed(monkeypatch):
monkeypatch.setitem(selections.DEFAULT_CONFIG, "quick_think_provider", "chatgpt")
monkeypatch.setitem(selections.DEFAULT_CONFIG, "deep_think_provider", None)
monkeypatch.setenv("TRADINGAGENTS_QUICK_THINK_LLM", "gpt-5.5")
signed, keyed = [], []
monkeypatch.setattr(selections, "ensure_signed_in", signed.append)
monkeypatch.setattr(selections, "ensure_api_key", keyed.append)
selections._check_tier_providers("openai")
assert signed == ["chatgpt", "openai"] and keyed == ["chatgpt", "openai"]
@pytest.mark.unit
def test_the_plan_s_listed_models_are_offered_in_openai_s_order(signed_in, monkeypatch):
monkeypatch.setattr(chatgpt_auth, "access_token", lambda: "plan-token")
body = {"models": [
{"slug": "gpt-5.5", "display_name": "GPT-5.5", "visibility": "list", "priority": 9},
{"slug": "internal", "display_name": "Internal", "visibility": "hide", "priority": 0},
{"slug": "gpt-6-astra", "display_name": "GPT-6-Astra", "visibility": "list", "priority": 2},
]}
response = SimpleNamespace(raise_for_status=lambda: None, json=lambda: body)
with mock.patch("requests.get", return_value=response) as get:
assert chatgpt_auth.list_models() == [("GPT-6-Astra", "gpt-6-astra"), ("GPT-5.5", "gpt-5.5")]
assert get.call_args.kwargs["headers"] == {"Authorization": "Bearer plan-token"}
@pytest.mark.unit
def test_api_organization_and_project_do_not_travel_with_the_sign_in(signed_in, monkeypatch):
monkeypatch.setenv("OPENAI_ORG_ID", "org-for-api-keys")
monkeypatch.setenv("OPENAI_PROJECT_ID", "proj-for-api-keys")
llm = _llm()
for client in (llm.root_client, llm.root_async_client):
sent = {k: v for k, v in client.default_headers.items() if isinstance(v, str)}
assert "OpenAI-Organization" not in sent and "OpenAI-Project" not in sent
@pytest.mark.unit
def test_a_remembered_plan_model_is_offered_again():
from cli.prefs import sanitize
kept = sanitize({"llm_provider": "chatgpt", "quick_think_llm": "gpt-5.5", "deep_think_llm": "gpt-6-astra"}, "stock")
assert (kept["quick_think_llm"], kept["deep_think_llm"]) == ("gpt-5.5", "gpt-6-astra")
@pytest.mark.unit
def test_a_malformed_model_entry_does_not_hide_the_others(signed_in):
body = {"models": [{"display_name": "No slug", "visibility": "list"},
{"slug": "gpt-5.5", "display_name": "GPT-5.5", "visibility": "list"}]}
response = SimpleNamespace(raise_for_status=lambda: None, json=lambda: body)
with mock.patch("requests.get", return_value=response):
assert chatgpt_auth.list_models() == [("GPT-5.5", "gpt-5.5")]
@pytest.mark.unit
def test_an_unreachable_openai_stops_the_run_without_a_new_sign_in(monkeypatch, capsys):
def unreachable():
raise chatgpt_auth.ChatGPTUnreachable("Could not reach OpenAI to renew your ChatGPT sign-in; try again.")
monkeypatch.setattr(chatgpt_auth, "access_token", unreachable)
monkeypatch.setattr("sys.stdin", SimpleNamespace(isatty=lambda: True))
monkeypatch.setattr(prompts.subprocess, "run", lambda argv: pytest.fail("offered a new sign-in"))
with pytest.raises(typer.Exit):
prompts.ensure_signed_in("chatgpt")
assert "try again" in capsys.readouterr().out
@pytest.mark.unit
@pytest.mark.parametrize("main, tier_provider, tier_url, backend_url", [
("chatgpt", None, None, "http://localhost:8000/v1"), # the run's own endpoint
("openai", "chatgpt", "http://relay.example/v1", None), # a chatgpt tier's endpoint
])
def test_a_plan_pointed_at_another_endpoint_stops_before_the_run(monkeypatch, capsys, main, tier_provider,
tier_url, backend_url):
monkeypatch.setitem(selections.DEFAULT_CONFIG, "quick_think_provider", tier_provider)
monkeypatch.setitem(selections.DEFAULT_CONFIG, "quick_think_backend_url", tier_url)
monkeypatch.setitem(selections.DEFAULT_CONFIG, "deep_think_provider", None)
monkeypatch.setenv("TRADINGAGENTS_QUICK_THINK_LLM", "gpt-6-luna")
monkeypatch.setattr(selections, "ensure_signed_in", lambda p: pytest.fail("checked the sign-in first"))
with pytest.raises(typer.Exit):
selections._check_tier_providers(main, backend_url)
assert "only connects to https://api.openai.com/v1" in " ".join(capsys.readouterr().out.split())
@pytest.mark.unit
def test_a_remembered_openrouter_model_is_preselected(monkeypatch):
monkeypatch.setattr(prompts, "_fetch_openrouter_models",
lambda: [("A", "openai/a"), ("B", "anthropic/b")])
shown, select = _menu([lambda cs: cs[1].value])
with mock.patch("cli.prompts.questionary.select", select):
assert prompts.select_openrouter_model("deep", default="anthropic/b") == "anthropic/b"
assert shown[0][2] == "anthropic/b"
@pytest.mark.unit
def test_the_chatgpt_plan_is_the_only_provider_signed_in_to():
# `login`, `logout` and the pre-run check are wired to the ChatGPT sign-in; a
# second signed-in provider needs its own sign-in wired into them first.
from tradingagents.llm_clients.openai_client import SIGNED_IN_PROVIDERS
assert SIGNED_IN_PROVIDERS == ("chatgpt",)
+54 -1
View File
@@ -83,12 +83,15 @@ class _Summary:
def test_every_command_is_registered_when_run_as_a_module():
"""README documents `python -m cli.main`, which executes the file top to
bottom, so a command defined after the __main__ block would not exist."""
import os
import re
import subprocess
import sys
# Help draws boxes in UTF-8; read it as such whatever the locale (cp1252 on Windows).
out = subprocess.run([sys.executable, "-m", "cli.main", "backtest", "--help"],
capture_output=True, text=True, timeout=120)
capture_output=True, text=True, encoding="utf-8", errors="replace",
env={**os.environ, "PYTHONIOENCODING": "utf-8"}, timeout=120)
assert out.returncode == 0, out.stderr[-400:]
# Where the terminal takes colour, help styles each option and splits
# "--start" across escape sequences, so read the text without them.
@@ -180,3 +183,53 @@ def test_backtest_refuses_a_selection_before_the_sweep(runner, monkeypatch, args
result = runner.invoke(m.app, ["backtest", "NVDA", "--start", "2026-06-01", "--end", "2026-06-01", *args])
assert result.exit_code == 1
@pytest.mark.unit
def test_login_and_logout_chatgpt_do_not_run_an_analysis(runner, monkeypatch):
from tradingagents.llm_clients import chatgpt_auth
monkeypatch.setattr(chatgpt_auth, "sign_in", lambda say: "a@example.com")
monkeypatch.setattr(chatgpt_auth, "sign_out", lambda: "revoked")
signed_in = runner.invoke(m.app, ["login", "chatgpt"])
assert signed_in.exit_code == 0 and "Signed in to ChatGPT as a@example.com" in signed_in.output
signed_out = runner.invoke(m.app, ["logout", "chatgpt"])
assert signed_out.exit_code == 0 and "Signed out of ChatGPT" in signed_out.output
assert runner.invoke(m.app, ["login", "claude"]).exit_code != 0
assert calls == []
@pytest.mark.unit
def test_a_failed_login_exits_with_the_reason(runner, monkeypatch):
from tradingagents.llm_clients import chatgpt_auth
def refuse(say):
raise chatgpt_auth.ChatGPTAuthError("Signing in with ChatGPT timed out")
monkeypatch.setattr(chatgpt_auth, "sign_in", refuse)
result = runner.invoke(m.app, ["login", "chatgpt"])
assert result.exit_code == 1 and "timed out" in result.output
@pytest.mark.unit
@pytest.mark.parametrize("outcome, said", [
(None, "Not signed in"), ("forgotten", "could not be reached to revoke"),
])
def test_logout_says_whether_openai_revoked_the_sign_in(runner, monkeypatch, outcome, said):
from tradingagents.llm_clients import chatgpt_auth
monkeypatch.setattr(chatgpt_auth, "sign_out", lambda: outcome)
result = runner.invoke(m.app, ["logout", "chatgpt"])
assert result.exit_code == 0 and said in " ".join(result.output.split())
@pytest.mark.unit
def test_a_failure_message_with_brackets_is_printed_as_text(runner, monkeypatch):
from tradingagents.llm_clients import chatgpt_auth
def refuse(say):
raise chatgpt_auth.ChatGPTAuthError("ChatGPT sign-in failed ([/bogus]); sign in again.")
monkeypatch.setattr(chatgpt_auth, "sign_in", refuse)
result = runner.invoke(m.app, ["login", "chatgpt"])
assert result.exit_code == 1 and "[/bogus]" in result.output
+12
View File
@@ -105,3 +105,15 @@ def test_a_half_set_round_count_says_which_value_won(capsys, monkeypatch):
assert config["max_risk_discuss_rounds"] == 5
assert any("TRADINGAGENTS_MAX_DEBATE_ROUNDS" in line for line in printed), printed
@pytest.mark.unit
def test_a_tier_s_thinking_setting_survives_a_main_provider_that_has_none(monkeypatch):
"""A run on Google with an OpenAI tier (#1440): the run's selections carry no
OpenAI reasoning effort, so the configured one must reach that tier."""
monkeypatch.setitem(cli_run.DEFAULT_CONFIG, "openai_reasoning_effort", "high")
monkeypatch.setitem(cli_run.DEFAULT_CONFIG, "anthropic_effort", "low")
cfg = cli_run._build_run_config({**SELECTIONS, "llm_provider": "google", "google_thinking_level": "high"},
checkpoint=None)
assert (cfg["openai_reasoning_effort"], cfg["anthropic_effort"], cfg["google_thinking_level"]) == \
("high", "low", "high")
+3 -6
View File
@@ -54,8 +54,7 @@ class TestCliSkipsPromptsFromEnv(unittest.TestCase):
with mock.patch.dict(os.environ, env, clear=False), \
mock.patch.object(cli_selections, "DEFAULT_CONFIG", fake_cfg), \
mock.patch.object(cli_selections, "fetch_announcements", return_value=None), \
mock.patch.object(cli_selections, "display_announcements"), \
mock.patch.object(cli_selections.updates, "enabled", return_value=False), \
mock.patch.object(cli_selections, "get_ticker", return_value="AAPL"), \
mock.patch.object(cli_selections, "get_analysis_date", return_value="2026-05-29"), \
mock.patch.object(cli_selections, "select_analysts", return_value=[]), \
@@ -96,8 +95,7 @@ class TestResearchDepthSkippedFromEnv(unittest.TestCase):
with mock.patch.dict(os.environ, env, clear=False), \
mock.patch.object(cli_selections, "DEFAULT_CONFIG", fake_cfg), \
mock.patch.object(cli_selections, "fetch_announcements", return_value=None), \
mock.patch.object(cli_selections, "display_announcements"), \
mock.patch.object(cli_selections.updates, "enabled", return_value=False), \
mock.patch.object(cli_selections, "get_ticker", return_value="AAPL"), \
mock.patch.object(cli_selections, "get_analysis_date", return_value="2026-05-29"), \
mock.patch.object(cli_selections, "select_analysts", return_value=[]), \
@@ -125,8 +123,7 @@ class TestReasoningEffortSkippedFromEnv(unittest.TestCase):
with mock.patch.dict(os.environ, env, clear=False), \
mock.patch.object(cli_selections, "DEFAULT_CONFIG", fake_cfg), \
mock.patch.object(cli_selections, "fetch_announcements", return_value=None), \
mock.patch.object(cli_selections, "display_announcements"), \
mock.patch.object(cli_selections.updates, "enabled", return_value=False), \
mock.patch.object(cli_selections, "get_ticker", return_value="AAPL"), \
mock.patch.object(cli_selections, "get_analysis_date", return_value="2026-05-29"), \
mock.patch.object(cli_selections, "select_analysts", return_value=[]), \
+2 -17
View File
@@ -56,8 +56,7 @@ def test_flags_skip_exactly_their_own_steps(monkeypatch):
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)
monkeypatch.setattr(selections.updates, "enabled", lambda: False)
chosen = selections._prompt_selections({}, FLAGS)
@@ -119,19 +118,6 @@ def test_save_and_show_answer_the_questions_after_the_run(monkeypatch, tmp_path)
@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)
@@ -200,8 +186,7 @@ def test_an_unknown_analyst_error_names_the_analysts_as_users_know_them():
def _selections_with_env(monkeypatch, keys=None):
for name, value in UNATTENDED_ENV.items():
monkeypatch.setenv(name, value)
monkeypatch.setattr(selections, "fetch_announcements", lambda: None)
monkeypatch.setattr(selections, "display_announcements", lambda *a: None)
monkeypatch.setattr(selections.updates, "enabled", lambda: False)
checked = keys if keys is not None else []
monkeypatch.setattr(selections, "ensure_api_key", lambda provider: checked.append(provider))
return checked
+2 -4
View File
@@ -95,8 +95,7 @@ def _answer_every_prompt(monkeypatch):
"""Drive the real selection flow, answering each prompt with a fixed value."""
import cli.main as m
monkeypatch.setattr(cli_selections, "fetch_announcements", lambda: [])
monkeypatch.setattr(cli_selections, "display_announcements", lambda *a: None)
monkeypatch.setattr(cli_selections.updates, "enabled", lambda: False)
monkeypatch.setattr(cli_selections, "get_ticker", lambda: "NVDA")
monkeypatch.setattr(cli_selections, "get_analysis_date", lambda: "2026-09-01")
monkeypatch.setattr(cli_selections, "ask_output_language", lambda default=None: "English")
@@ -148,8 +147,7 @@ def test_a_remembered_endpoint_is_offered_back(monkeypatch):
monkeypatch.setattr(cli_selections, "select_llm_provider", lambda default=None: ("openai_compatible", None))
monkeypatch.setattr(cli_selections, "prompt_openai_compatible_url",
lambda default=None: offered.setdefault("default", default) or "http://x/v1")
monkeypatch.setattr(cli_selections, "fetch_announcements", lambda: [])
monkeypatch.setattr(cli_selections, "display_announcements", lambda *a: None)
monkeypatch.setattr(cli_selections.updates, "enabled", lambda: False)
monkeypatch.setattr(cli_selections, "get_ticker", lambda: "NVDA")
monkeypatch.setattr(cli_selections, "get_analysis_date", lambda: "2026-09-01")
monkeypatch.setattr(cli_selections, "ask_output_language", lambda default=None: "English")
+3
View File
@@ -38,6 +38,9 @@ def test_normalize_symbol_crypto_and_passthrough(raw, expected):
("", True), # empty -> defaults to SPY downstream
("bad symbol!", False), # space + '!' rejected
("A" * 40, False), # too long
("XAUUSD+", True), # a broker's CFD spelling of gold, priced as GC=F
("..", False), # names a directory, not an instrument
("\uff21\uff21\uff30\uff2c", False), # full-width letters
])
def test_ticker_input_validation(value, ok):
assert is_valid_ticker_input(value) is ok
+339
View File
@@ -0,0 +1,339 @@
"""What's new reaches the person at a terminal: a newer release and Tauric's notices.
The check runs at most daily in a background thread that only writes a cache; the
notice shown is read from that cache, so a run never waits on the network, and
unattended runs contact no server at all.
"""
import io
import json
import os
from datetime import date
from types import SimpleNamespace
from unittest import mock
import pytest
from packaging.version import Version
from rich.console import Console
import cli.selections as cli_selections
import cli.updates as updates
class _Tty(io.StringIO):
def isatty(self):
return True
# --- who sees it -----------------------------------------------------------------
@pytest.mark.unit
@pytest.mark.parametrize("stdin_tty, stdout_tty, ci, opt_out, expected", [
(True, True, None, None, True),
(False, True, None, None, False),
(True, False, None, None, False),
(True, True, "true", None, False),
(True, True, None, "1", False),
])
def test_only_a_person_at_a_terminal_is_told(monkeypatch, stdin_tty, stdout_tty, ci, opt_out, expected):
monkeypatch.setattr("sys.stdin", _Tty() if stdin_tty else io.StringIO())
monkeypatch.setattr("sys.stdout", _Tty() if stdout_tty else io.StringIO())
for name, value in (("CI", ci), ("TRADINGAGENTS_NO_UPDATE_CHECK", opt_out)):
if value is None:
monkeypatch.delenv(name, raising=False)
else:
monkeypatch.setenv(name, value)
assert updates.enabled() is expected
ENV = {
"TRADINGAGENTS_LLM_PROVIDER": "openai",
"TRADINGAGENTS_DEEP_THINK_LLM": "gpt-6-sol",
"TRADINGAGENTS_QUICK_THINK_LLM": "gpt-6-luna",
"TRADINGAGENTS_OUTPUT_LANGUAGE": "English",
"TRADINGAGENTS_MAX_DEBATE_ROUNDS": "1",
"TRADINGAGENTS_MAX_RISK_ROUNDS": "1",
}
@pytest.mark.unit
def test_an_unattended_run_contacts_no_server(monkeypatch):
monkeypatch.setattr("sys.stdin", io.StringIO()) # no terminal
calls = []
monkeypatch.setattr(updates.requests, "get", lambda *a, **k: calls.append(a))
started = []
monkeypatch.setattr(updates.threading, "Thread", lambda *a, **k: started.append(1))
with mock.patch.dict(os.environ, ENV, clear=False), \
mock.patch.object(cli_selections, "ensure_api_key"):
cli_selections.get_user_selections({"ticker": "AAPL", "date": "2026-09-01", "analysts": "market"})
assert calls == [] and started == []
# --- what it says -----------------------------------------------------------------
LATEST = {"version": "0.7.0", "url": "https://github.com/TauricResearch/TradingAgents/releases/tag/v0.7.0",
"summary": "Update notices and a ChatGPT plan provider."}
@pytest.mark.unit
@pytest.mark.parametrize("running, shown", [
("0.6.0", True), ("0.7.0.dev0", True), # a development build of the release is older than it
("0.7.0", False), ("0.8.0.dev0", False), ("not-a-version", False),
])
def test_a_newer_release_is_announced_once_in_the_notice(running, shown):
lines = updates.messages({"latest": LATEST}, running=running, upgrade=lambda: None)
assert any("0.7.0 is available" in line for line in lines) is shown
if shown:
text = "\n".join(lines)
assert f"you have {running}" in text and LATEST["url"] in text and LATEST["summary"] in text
@pytest.mark.unit
def test_the_notice_names_the_upgrade_command():
lines = updates.messages({"latest": LATEST}, running="0.6.0", upgrade=lambda: "git -C /src pull && pip install /src")
assert "Upgrade: git -C /src pull && pip install /src" in "\n".join(lines)
@pytest.mark.unit
def test_remote_text_cannot_style_or_break_the_terminal():
body = "[bold red]Grab it[/bold red] now\x1b[2J with `--html`, a very long line " + "x" * 400 + "\n\nSecond paragraph."
summary = updates.summary_of(body)
assert "\n" not in summary and "Second paragraph" not in summary and len(summary) <= 160
lines = updates.messages({"latest": {**LATEST, "summary": summary}}, running="0.6.0", upgrade=lambda: None)
shown = "\n".join(lines)
assert "[bold red]Grab it" in shown and "\x1b" not in shown # shown as plain text, controls stripped
@pytest.mark.unit
def test_notices_reach_only_the_versions_they_name():
cache = {"notices": [
{"id": "a", "message": "For everyone", "url": None},
{"id": "b", "message": "Old installs: news is fixed", "max_version": "0.6.0"},
{"id": "c", "message": "New installs only", "min_version": "0.7.0"},
{"id": "d", "message": "Expired", "expires": "2020-01-01"},
{"id": "e", "message": "[link=x]markup[/link]"},
"a bare string is not a notice",
]}
text = "\n".join(updates.messages(cache, running="0.6.0", upgrade=lambda: None))
assert "For everyone" in text and "Old installs" in text
assert "New installs only" not in text and "Expired" not in text
assert "[link=x]markup[/link]" in text and "bare string" not in text
@pytest.mark.unit
def test_a_notice_shows_through_its_bounds_and_date_and_hides_when_they_cannot_be_read():
today = date(2026, 10, 16)
applies = lambda **notice: updates._applies(notice, Version("0.7.0"), today) # noqa: E731
assert applies(min_version="0.7.0", max_version="0.7.0", expires="2026-10-16")
assert applies(expires=None) and applies(min_version=None, max_version=None)
assert not applies(expires="2026-10-15") and not applies(min_version="0.7.1") and not applies(max_version="0.6.9")
for unreadable in ("", "seven", [], False):
assert not applies(min_version=unreadable) and not applies(max_version=unreadable)
for unreadable in ("", "2026-10-16junk", "20261020", "2026-W42-5", "2026-10-16T23:00-08:00", 20261016):
assert not applies(expires=unreadable)
assert not updates._applies({"min_version": "0.7.0"}, Version("0.7.0.dev0"), today)
@pytest.mark.unit
def test_a_notice_is_shown_as_plain_text_with_only_a_web_link():
cache = {"notices": [
{"id": "a", "message": ":warning: [b]x[/b] \u202eev\u200bil", "url": "https://tauric.ai/a"},
{"id": "b", "message": "Local link", "url": "file:///etc/passwd"},
{"id": "e", "message": "Malformed link", "url": "https://[docs"},
{"id": "c", "message": {"not": "text"}},
{"id": "d", "message": "\x1b\x07"},
]}
assert updates.messages(cache, running="0.7.0") == [":warning: [b]x[/b] evil https://tauric.ai/a", "Local link",
"Malformed link https://[docs"]
console = Console(record=True, width=120, color_system=None)
with mock.patch.object(updates, "read_cache", lambda: cache), mock.patch("tradingagents.__version__", "0.7.0"):
updates.show(console)
assert ":warning: [b]x[/b] evil" in console.export_text()
@pytest.mark.unit
def test_notices_are_read_from_their_own_key():
# Notices have their own key, so ``announcements`` stays a list of strings.
payload = {"announcements": ["Plain text for older clients"],
"notices": [{"id": "x", "message": "Targeted", "max_version": "0.6.0"}]}
assert updates.parse_notices(payload) == payload["notices"]
assert updates.parse_notices({"announcements": ["old shape only"]}) is None
assert updates.parse_notices({"notices": []}) == []
# --- how it is checked ---------------------------------------------------------------
@pytest.mark.unit
def test_a_check_is_due_once_a_day():
now = 1_000_000.0
assert updates.due({}, now)
assert not updates.due({"checked_at": now - 3600}, now)
assert updates.due({"checked_at": now - 25 * 3600}, now)
def _response(payload, status=200):
return SimpleNamespace(status_code=status, ok=status < 400, json=lambda: payload)
@pytest.mark.unit
def test_a_check_records_the_latest_release_and_notices(tmp_path, monkeypatch):
cache_file = tmp_path / "update_check.json"
monkeypatch.setattr(updates, "CACHE", cache_file)
def get(url, timeout):
assert timeout <= 3
if "api.github.com" in url:
return _response({"tag_name": "v0.7.0", "html_url": LATEST["url"],
"body": "Update notices and a ChatGPT plan provider.\n\n## Added"})
return _response({"announcements": ["old"], "notices": [{"id": "n", "message": "hi"}]})
monkeypatch.setattr(updates.requests, "get", get)
updates.refresh(now=5000.0)
cache = json.loads(cache_file.read_text())
assert cache["checked_at"] == 5000.0
assert cache["latest"] == {"version": "0.7.0", "url": LATEST["url"], "summary": LATEST["summary"]}
assert cache["notices"] == [{"id": "n", "message": "hi"}]
@pytest.mark.unit
def test_a_failed_check_keeps_what_was_known_and_waits_a_day(tmp_path, monkeypatch, capsys):
cache_file = tmp_path / "update_check.json"
cache_file.write_text(json.dumps({"checked_at": 1.0, "latest": LATEST, "notices": []}))
monkeypatch.setattr(updates, "CACHE", cache_file)
def get(url, timeout):
raise updates.requests.ConnectionError("offline")
monkeypatch.setattr(updates.requests, "get", get)
updates.refresh(now=9000.0) # never raises
cache = json.loads(cache_file.read_text())
assert cache["checked_at"] == 9000.0 and cache["latest"] == LATEST
assert capsys.readouterr() == ("", "") # the check never prints
@pytest.mark.unit
def test_only_a_notices_list_replaces_the_known_notices(tmp_path, monkeypatch):
cache_file = tmp_path / "update_check.json"
known = [{"id": "n", "message": "hi"}]
cache_file.write_text(json.dumps({"checked_at": 1.0, "notices": known}))
monkeypatch.setattr(updates, "CACHE", cache_file)
for answer in (_response({"detail": "Not Found"}, 404), _response({"error": "portal"}), _response(["x"])):
monkeypatch.setattr(updates.requests, "get", lambda url, timeout, answer=answer: answer)
updates.refresh(now=9000.0)
assert json.loads(cache_file.read_text())["notices"] == known
monkeypatch.setattr(updates.requests, "get", lambda url, timeout: _response({"announcements": [], "notices": []}))
updates.refresh(now=99000.0)
assert json.loads(cache_file.read_text())["notices"] == []
@pytest.mark.unit
def test_an_unreadable_cache_is_no_news(tmp_path, monkeypatch):
cache_file = tmp_path / "update_check.json"
cache_file.write_text("{not json")
monkeypatch.setattr(updates, "CACHE", cache_file)
assert updates.read_cache() == {}
# --- the upgrade command for how it was installed ----------------------------------
def _dist(direct=None, installer="pip"):
files = {"direct_url.json": json.dumps(direct) if direct else None, "INSTALLER": installer}
return SimpleNamespace(read_text=lambda name: files.get(name))
@pytest.mark.unit
@pytest.mark.skipif(os.name == "nt", reason="the upgrade commands are POSIX shell")
def test_the_upgrade_command_follows_the_install(tmp_path, monkeypatch):
clone = tmp_path / "TradingAgents"
(clone / ".git").mkdir(parents=True)
monkeypatch.delenv("TRADINGAGENTS_IN_DOCKER", raising=False)
monkeypatch.setattr(updates.sys, "executable", "/venv/bin/python")
url = clone.as_uri()
editable = updates.upgrade_command(_dist({"url": url, "dir_info": {"editable": True}}))
assert editable == f"git -C {clone} pull && /venv/bin/python -m pip install -e {clone}"
local = updates.upgrade_command(_dist({"url": url, "dir_info": {}}, installer="uv"))
assert local == f"git -C {clone} pull && uv pip install --python /venv/bin/python {clone}"
vcs = updates.upgrade_command(_dist({"url": "https://github.com/TauricResearch/TradingAgents.git",
"vcs_info": {"vcs": "git", "commit_id": "abc"}}))
assert vcs == "/venv/bin/python -m pip install -U git+https://github.com/TauricResearch/TradingAgents.git"
@pytest.mark.unit
def test_no_command_is_guessed_without_a_clone(tmp_path, monkeypatch):
monkeypatch.delenv("TRADINGAGENTS_IN_DOCKER", raising=False)
gone = (tmp_path / "deleted-clone").as_uri()
assert updates.upgrade_command(_dist({"url": gone, "dir_info": {}})) is None
assert updates.upgrade_command(_dist(None)) is None
monkeypatch.setenv("TRADINGAGENTS_IN_DOCKER", "1")
assert updates.upgrade_command(_dist(None)) == "git pull && docker compose build (on the host)"
@pytest.mark.unit
def test_whats_new_never_waits_for_the_person(tmp_path, monkeypatch):
cache_file = tmp_path / "update_check.json"
cache_file.write_text(json.dumps({"latest": LATEST, "notices": [{"id": "x", "message": "Notice"}]}))
monkeypatch.setattr(updates, "CACHE", cache_file)
monkeypatch.setattr("builtins.input", lambda *a: (_ for _ in ()).throw(AssertionError("asked for input")))
from rich.console import Console
out = Console(file=io.StringIO(), width=200)
updates.show(out)
assert "Notice" in out.file.getvalue()
@pytest.mark.unit
@pytest.mark.parametrize("stored", [
{"latest": "x"}, {"latest": ["0.9.0"]}, {"notices": 5}, {"notices": {"id": "x"}}, {"checked_at": "today"}, [],
])
def test_a_cache_of_the_wrong_shape_is_no_news(tmp_path, monkeypatch, stored):
cache_file = tmp_path / "update_check.json"
cache_file.write_text(json.dumps(stored))
monkeypatch.setattr(updates, "CACHE", cache_file)
assert updates.messages(updates.read_cache(), running="0.6.0", upgrade=lambda: None) == []
assert updates.due(updates.read_cache(), 1_000_000.0)
@pytest.mark.unit
def test_a_check_dated_in_the_future_is_due():
assert updates.due({"checked_at": 2_000_000.0}, 1_000_000.0)
@pytest.mark.unit
def test_a_notice_with_an_unreadable_expiry_is_not_shown():
cache = {"notices": [{"message": "Bad date", "expires": "next week"},
{"message": "Number", "expires": 20991231},
{"message": "Timestamp", "expires": "2999-01-01T00:00:00Z"},
{"message": "A date", "expires": "2999-01-01"}]}
text = "\n".join(updates.messages(cache, running="0.6.0", upgrade=lambda: None))
assert "Bad date" not in text and "Number" not in text and "Timestamp" not in text and "A date" in text
@pytest.mark.unit
def test_remote_text_cannot_carry_c1_or_direction_controls():
lines = updates.messages({"notices": [{"message": "A\x9b2Jb‮evil"}]}, running="0.6.0", upgrade=lambda: None)
assert lines == ["A 2Jbevil"] # the C1 control reads as a space, the override is dropped
@pytest.mark.unit
@pytest.mark.skipif(os.name == "nt", reason="the upgrade commands are POSIX shell")
def test_a_uv_git_install_is_upgraded_with_uv(monkeypatch):
monkeypatch.delenv("TRADINGAGENTS_IN_DOCKER", raising=False)
monkeypatch.setattr(updates.sys, "executable", "/venv/bin/python")
vcs = updates.upgrade_command(_dist({"url": "https://github.com/TauricResearch/TradingAgents.git",
"vcs_info": {"vcs": "git"}}, installer="uv"))
assert vcs == "uv pip install --python /venv/bin/python -U git+https://github.com/TauricResearch/TradingAgents.git"
@pytest.mark.unit
def test_no_command_is_offered_on_windows(monkeypatch):
monkeypatch.delenv("TRADINGAGENTS_IN_DOCKER", raising=False)
monkeypatch.setattr(updates.os, "name", "nt")
assert updates.upgrade_command(_dist({"url": "https://github.com/TauricResearch/TradingAgents.git",
"vcs_info": {"vcs": "git"}})) is None
@pytest.mark.unit
def test_the_upgrade_command_is_worked_out_only_for_a_newer_release():
asked = []
updates.messages({"latest": LATEST}, running=LATEST["version"], upgrade=lambda: asked.append(1))
assert asked == []
+3 -3
View File
@@ -312,7 +312,7 @@ def test_past_decisions_settle_alongside_the_analysts(tmp_path, monkeypatch, off
model = TimedModel()
graph = _graph(tmp_path, monkeypatch, model)
graph.memory_log.store_decision("NVDA", "2026-01-02", "Rating: Buy\n\nBuy NVDA.")
graph.memory_log.store_decision("NVDA", "2025-12-31", "Rating: Buy\n\nBuy NVDA.")
closes = pd.Series([100.0 + i for i in range(10)], index=pd.bdate_range("2026-01-02", periods=10))
monkeypatch.setattr(settlement, "get_closes", lambda *a, **k: closes)
@@ -322,7 +322,7 @@ def test_past_decisions_settle_alongside_the_analysts(tmp_path, monkeypatch, off
assert len(reflections) == 1 # settled once, inside the run
analyst_turns = [c for c in model.seen if c[0] == "agent"]
assert reflections[0][2] < max(c[3] for c in analyst_turns), "settled before the analysts, not alongside"
settled = next(e for e in graph.memory_log.load_entries() if e["date"] == "2026-01-02")
settled = next(e for e in graph.memory_log.load_entries() if e["date"] == "2025-12-31")
assert not settled["pending"]
lessons = graph.memory_log.get_past_context("NVDA", as_of=TRADE_DATE)
assert lessons
@@ -372,7 +372,7 @@ def test_the_memory_step_reads_lessons_as_of_the_trade_date_and_settles_once(tmp
from tradingagents.memory import settlement
graph = _graph(tmp_path, monkeypatch, ScriptedModel(structured=True))
graph.memory_log.store_decision("NVDA", "2026-01-02", "Rating: Buy\n\nBuy NVDA.")
graph.memory_log.store_decision("NVDA", "2025-12-31", "Rating: Buy\n\nBuy NVDA.")
closes = pd.Series([100.0 + i for i in range(10)], index=pd.bdate_range("2026-01-02", periods=10))
monkeypatch.setattr(settlement, "get_closes", lambda *a, **k: closes)
asked = []
+121 -16
View File
@@ -300,6 +300,16 @@ class TestTradingMemoryLogCore:
assert "Buy entry 0" not in ctx
assert "Buy entry 5" in ctx
def test_the_most_recent_decisions_are_the_latest_dated_not_the_latest_written(self, tmp_path):
"""A backfill writes January after June; June is still the most recent lesson."""
log = make_log(tmp_path)
_seed_completed(tmp_path, "NVDA", "2026-06-01", "Buy entry June.", "Correct.")
for i in range(5):
_seed_completed(tmp_path, "NVDA", f"2026-01-{i+1:02d}", f"Buy entry {i}.", "Correct.")
ctx = log.get_past_context("NVDA", n_same=5)
assert "Buy entry June" in ctx and "Buy entry 0" not in ctx
assert ctx.index("Buy entry June") < ctx.index("Buy entry 4")
def test_n_cross_limit_respected(self, tmp_path):
"""Only the n_cross most recent cross-ticker entries are included."""
log = make_log(tmp_path)
@@ -341,6 +351,16 @@ class TestTradingMemoryLogCore:
dates = [e["date"] for e in entries]
assert dates == ["2026-01-03", "2026-01-04", "2026-01-05"]
def test_rotation_prunes_the_oldest_dated_not_the_first_written(self, tmp_path):
"""A backfill writes older dates last; rotation still keeps the newest decisions."""
log = TradingMemoryLog({
"memory_log_path": str(tmp_path / "trading_memory.md"),
"memory_log_max_entries": 3,
})
for date in ("2026-06-01", "2026-06-02", "2026-01-01", "2026-01-02", "2026-01-03"):
_resolve_entry(log, "NVDA", date, DECISION_BUY, f"Lesson {date}.")
assert sorted(e["date"] for e in log.load_entries()) == ["2026-01-03", "2026-06-01", "2026-06-02"]
def test_rotation_never_prunes_pending(self, tmp_path):
"""Pending entries (unresolved) are kept regardless of the cap."""
log = TradingMemoryLog({
@@ -512,8 +532,8 @@ class TestDeferredReflection:
# settlement.fetch_returns
def test_fetch_returns_valid_ticker(self):
stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0]
spy_prices = [400.0, 402.0, 404.0, 403.0, 405.0, 406.0]
stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0, 107.0]
spy_prices = [400.0, 402.0, 404.0, 403.0, 405.0, 406.0, 407.0]
with patch("yfinance.Ticker") as mock_ticker_cls:
def _make_ticker(sym):
m = MagicMock()
@@ -524,8 +544,8 @@ class TestDeferredReflection:
assert raw is not None and alpha is not None and days is not None
assert isinstance(raw, float) and isinstance(alpha, float) and isinstance(days, int)
assert days == 5
# resolution date = the bar `days` sessions after the trade date (#1251)
assert resolved == "2026-01-10"
# resolution date = the bar `days` sessions after entry, the first close after the decision (#1251)
assert resolved == "2026-01-11"
def test_fetch_returns_too_recent(self):
"""Only 1 data point available → returns all-None, no crash."""
@@ -549,7 +569,7 @@ class TestDeferredReflection:
"""SPY having fewer rows than the stock (but still a full window) must
not raise IndexError."""
stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0, 107.0, 108.0] # 8 rows
spy_prices = [400.0, 402.0, 403.0, 405.0, 406.0, 407.0] # 6 rows
spy_prices = [400.0, 402.0, 403.0, 405.0, 406.0, 407.0, 408.0] # 7 rows
with patch("yfinance.Ticker") as mock_ticker_cls:
def _make_ticker(sym):
m = MagicMock()
@@ -559,7 +579,22 @@ class TestDeferredReflection:
raw, alpha, days, resolved = settlement.fetch_returns("NVDA", "2026-01-05")
assert raw is not None and alpha is not None
assert days == 5 # full holding window used for both series
assert resolved == "2026-01-10"
assert resolved == "2026-01-11"
def test_a_decision_is_scored_from_the_first_close_after_its_date(self):
"""#1518: news published after the close on the decision date reaches the
analysts, so the close of that date is not a price they could have traded at."""
stock_prices = [100.0, 150.0, 150.0, 150.0, 150.0, 150.0, 165.0] # jumps on the next close
spy_prices = [400.0] * 7
with patch("yfinance.Ticker") as mock_ticker_cls:
def _make_ticker(sym):
m = MagicMock()
m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices)
return m
mock_ticker_cls.side_effect = _make_ticker
raw, alpha, days, resolved = settlement.fetch_returns("NVDA", "2026-01-05")
assert raw == pytest.approx(0.10) and alpha == pytest.approx(0.10) # 150 -> 165, not 100 -> 150
assert resolved == "2026-01-11"
def test_fetch_returns_incomplete_window_stays_pending(self):
"""#1169: a rerun before the full holding window has traded returns
@@ -1082,10 +1117,10 @@ class TestSettlementWindow:
raw, alpha, days, resolved = settlement.fetch_returns("BTC-USD", "2026-01-03", 5, "SPY")
# The coin is held Sat 3 -> Thu 8; SPY over the same span is Fri 2 -> Thu 8.
assert resolved == "2026-01-08"
assert raw == pytest.approx(105.0 / 100.0 - 1)
assert alpha == pytest.approx(raw - (spy["2026-01-08"] / spy["2026-01-02"] - 1))
# Decided Sat 3, the coin is held Sun 4 -> Fri 9; SPY over the same span is Fri 2 -> Fri 9.
assert resolved == "2026-01-09"
assert raw == pytest.approx(106.0 / 101.0 - 1)
assert alpha == pytest.approx(raw - (spy["2026-01-09"] / spy["2026-01-02"] - 1))
def test_bars_stamped_in_different_time_zones_meet_on_their_dates(self, monkeypatch):
# Yahoo stamps a coin's daily bar at midnight UTC and SPY's at midnight
@@ -1098,9 +1133,9 @@ class TestSettlementWindow:
raw, alpha, _, resolved = settlement.fetch_returns("BTC-USD", "2026-01-03", 5, "SPY")
assert resolved == "2026-01-08"
assert resolved == "2026-01-09"
spy_by_day = spy.tz_localize(None)
assert alpha == pytest.approx(raw - (spy_by_day["2026-01-08"] / spy_by_day["2026-01-02"] - 1))
assert alpha == pytest.approx(raw - (spy_by_day["2026-01-09"] / spy_by_day["2026-01-02"] - 1))
def test_the_outcome_waits_for_the_benchmark_to_trade_through_the_window(self, monkeypatch):
coin = pd.Series([100.0 + i for i in range(10)], index=pd.date_range("2026-01-03", periods=10, freq="D"))
@@ -1117,9 +1152,79 @@ class TestSettlementWindow:
raw, alpha, _, resolved = settlement.fetch_returns("NVDA", "2026-01-05", 5, "SPY")
assert raw == pytest.approx(105.0 / 100.0 - 1)
assert resolved == "2026-01-13"
assert alpha == pytest.approx(raw - (spy["2026-01-13"] / spy["2026-01-05"] - 1))
# Entered at the first real close after the decision: Wed 7, past the missing Tue 6.
assert raw == pytest.approx(106.0 / 101.0 - 1)
assert resolved == "2026-01-14"
assert alpha == pytest.approx(raw - (spy["2026-01-14"] / spy["2026-01-07"] - 1))
@pytest.mark.unit
class TestSettlementUsesFinalCloses:
"""A decision settles on closes that are final, whenever the pass runs."""
def test_a_bar_of_a_session_still_trading_is_not_scored(self, monkeypatch):
# 09:12 in Shanghai on Tue 13: Yahoo already serves that day's bar at the live price.
days = pd.bdate_range("2026-01-02", "2026-01-13", tz="Asia/Shanghai")
stock = pd.Series([100.0 + i for i in range(len(days))], index=days)
index = pd.Series([3000.0] * len(days), index=days)
monkeypatch.setattr(settlement, "get_closes", _closes({"600519.SS": stock, "000001.SS": index}))
monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-01-13 01:12", tz="UTC"))
# Decided Mon 5: entry Tue 6, exit five sessions later on Tue 13, today.
assert settlement.fetch_returns("600519.SS", "2026-01-05", 5, "000001.SS") == (None, None, None, None)
monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-01-14 01:12", tz="UTC"))
assert settlement.fetch_returns("600519.SS", "2026-01-05", 5, "000001.SS")[3] == "2026-01-13"
def test_a_decision_before_a_long_market_closure_settles(self, monkeypatch):
# The exchange is shut Thu 1 to Wed 7 October.
days = pd.bdate_range("2026-09-21", "2026-10-30")
days = days[(days < "2026-10-01") | (days > "2026-10-07")]
stock = pd.Series([100.0 + i for i in range(len(days))], index=days)
index = pd.Series([3000.0 + i for i in range(len(days))], index=days)
monkeypatch.setattr(settlement, "get_closes", _closes({"600519.SS": stock, "000001.SS": index}))
monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-11-02 08:00", tz="UTC"))
raw, alpha, days_held, resolved = settlement.fetch_returns("600519.SS", "2026-09-29", 5, "000001.SS")
# Entry Wed 30 September, then five sessions from Thu 8 October.
assert resolved == "2026-10-14" and days_held == 5
def test_a_market_whose_clocks_skip_midnight_still_settles(self, monkeypatch):
# Santiago springs forward at midnight: 2026-09-06 has no 00:00.
days = pd.bdate_range("2026-08-21", "2026-09-04", tz="America/Santiago")
stock = pd.Series([100.0 + i for i in range(len(days))], index=days)
index = pd.Series([6000.0 + i for i in range(len(days))], index=days)
monkeypatch.setattr(settlement, "get_closes", _closes({"SQM-B.SN": stock, "^IPSA": index}))
monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-09-06 15:00", tz="UTC"))
assert settlement.fetch_returns("SQM-B.SN", "2026-08-24", 5, "^IPSA")[3] == "2026-09-01"
def test_a_benchmark_shut_past_the_window_is_fetched_again(self, monkeypatch):
days = pd.bdate_range("2026-09-14", "2026-10-30")
stock = pd.Series([100.0 + i for i in range(len(days))], index=days)
shut = days[(days < "2026-09-30") | (days > "2026-10-20")]
index = pd.Series([3000.0 + i for i in range(len(shut))], index=shut)
monkeypatch.setattr(settlement, "get_closes", _closes({"AAPL": stock, "000001.SS": index}))
monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-11-02 08:00", tz="UTC"))
assert settlement.fetch_returns("AAPL", "2026-09-29", 5, "000001.SS")[3] == "2026-10-07"
def test_a_series_with_no_usable_close_stays_pending(self, monkeypatch):
# Yahoo can answer with a dated, time-zoned frame whose closes are all missing.
days = pd.bdate_range("2026-01-02", "2026-01-30", tz="America/New_York")
empty = pd.Series([float("nan")] * len(days), index=days)
index = pd.Series([400.0 + i for i in range(len(days))], index=days)
monkeypatch.setattr(settlement, "get_closes", _closes({"NVDA": empty, "SPY": index}))
monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-02-02 12:00", tz="UTC"))
assert settlement.fetch_returns("NVDA", "2026-01-05", 5, "SPY") == (None, None, None, None)
def test_a_window_that_cannot_have_traded_asks_for_no_prices(self, monkeypatch):
monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-01-08 22:00", tz="UTC"))
monkeypatch.setattr(settlement, "get_closes", lambda *a: pytest.fail("fetched prices"))
assert settlement.fetch_returns("NVDA", "2026-01-05", 5, "SPY") == (None, None, None, None)
@pytest.mark.unit
@@ -1130,7 +1235,7 @@ def test_a_close_that_is_not_a_price_is_skipped_not_scored(monkeypatch):
spy = pd.Series([400.0 + i for i in range(8)], index=idx)
monkeypatch.setattr(settlement, "get_closes", _closes({"NVDA": stock, "SPY": spy}))
raw, alpha, _, _ = settlement.fetch_returns("NVDA", "2026-01-05", 5, "SPY")
raw, alpha, _, _ = settlement.fetch_returns("NVDA", "2026-01-02", 5, "SPY")
assert raw == pytest.approx(105.0 / 100.0 - 1) # the window opens on the first real close
+13
View File
@@ -78,6 +78,19 @@ class PolymarketFormatTests(unittest.TestCase):
self.assertIn("resolves 2030-12-31", out)
self.assertIn("1-week -4.5pp", out) # -0.045 -> -4.5pp
def test_a_long_shot_is_not_shown_as_impossible_or_certain(self):
search = {"events": [{"markets": [
_market("Long shot?", 0.003, volume=2_000, end_date="2030-12-31T00:00:00Z"),
_market("Near certain?", 0.997, volume=1_000, end_date="2030-12-31T00:00:00Z"),
_market("Tick from zero?", 0.0005, volume=900, end_date="2030-12-31T00:00:00Z"),
_market("Tick from one?", 0.9995, volume=800, end_date="2030-12-31T00:00:00Z"),
_market("All but settled?", 0.9999999, volume=700, end_date="2030-12-31T00:00:00Z"),
]}]}
with mock.patch.object(polymarket, "_request", return_value=search):
out = polymarket.get_prediction_markets("anything", limit=10)
for odds in ("Yes 0.3%", "Yes 99.7%", "Yes 0.05%", "Yes 99.95%", "Yes 99.99999%"):
self.assertIn(odds, out)
def test_weekly_change_omitted_when_absent(self):
# "Open small?" has wk=None -> no 1-week clause on its line.
with mock.patch.object(polymarket, "_request", return_value=_SEARCH):
+44
View File
@@ -45,6 +45,20 @@ def test_a_ticker_held_under_another_spelling_is_matched():
assert "No current position" not in text and "5" in text
@pytest.mark.unit
@pytest.mark.parametrize("held, analyzed, canonical", [
("BTCUSDT", "BTC-USD", "BTC-USD"),
("XAUUSD+", "GC=F", "GC=F"),
("700.HK", "0700.HK", "0700.HK"),
("0700.HK", "700.HK", "0700.HK"),
])
def test_a_holding_under_a_broker_alias_is_the_analyzed_instrument(held, analyzed, canonical):
book = PortfolioContext.model_validate({"positions": [{"ticker": held, "quantity": 2}, {"ticker": "ethusdt", "quantity": 1}]})
text = book.render(analyzed)
assert f"Current position in {canonical}: 2 units" in text
assert "Other positions: ETHUSDT 1" in text # as listed
@pytest.mark.unit
def test_absent_context_is_reported_as_not_provided():
notice = get_portfolio_context_from_state({"company_of_interest": "AAPL"})
@@ -66,6 +80,36 @@ def test_load_rejects_a_malformed_file_with_a_clear_error(tmp_path):
load_portfolio(bad)
@pytest.mark.unit
def test_one_instrument_held_under_two_spellings_shows_both_holdings():
book = PortfolioContext.model_validate({"positions": [
{"ticker": "BTCUSDT", "quantity": 1}, {"ticker": "BTC-USD", "quantity": 2}]})
text = book.render("BTC-USD")
assert "Current position in BTC-USD: 1 units (listed as BTCUSDT)" in text
assert "Current position in BTC-USD: 2 units" in text and "Other positions" not in text
@pytest.mark.unit
def test_a_holding_keeps_the_instrument_it_was_listed_as():
# WTI is also an alias of crude oil futures; listed beside AAPL it is the stock as written.
text = PortfolioContext.model_validate({"positions": [{"ticker": "WTI", "quantity": 100}]}).render("AAPL")
assert "Other positions: WTI 100" in text and "CL=F" not in text
@pytest.mark.unit
@pytest.mark.parametrize("book", [
'{"cash": NaN}',
'{"positions": [{"ticker": "AAPL", "quantity": Infinity}]}',
'{"positions": [{"ticker": "AAPL", "quantity": 1, "average_price": "nan"}]}',
])
def test_load_rejects_a_number_that_is_not_finite(tmp_path, book):
# Python's json writes NaN and Infinity, and they would reach the agents as "nan units".
bad = tmp_path / "p.json"
bad.write_text(book)
with pytest.raises(ValueError, match="portfolio"):
load_portfolio(bad)
@pytest.mark.unit
def test_load_reads_a_valid_file(tmp_path):
good = tmp_path / "p.json"
+56
View File
@@ -58,3 +58,59 @@ def test_key_optionality():
assert OPENAI_COMPATIBLE_PROVIDERS["xai"].key_optional is False
# OLLAMA_BASE_URL is the only base-URL env override.
assert OPENAI_COMPATIBLE_PROVIDERS["ollama"].base_url_env == "OLLAMA_BASE_URL"
# --- a provider that signs in: its own credential, its own pinned endpoint ---------
class _Recorder:
"""Stands in for the chat class and records what the client built."""
def __init__(self, **kwargs):
self.kwargs = kwargs
def _signed_in_provider(monkeypatch, token="plan-token"):
from tradingagents.llm_clients import openai_client
spec = openai_client.ProviderSpec(
base_url="https://api.openai.com/v1", pinned_base_url=True, use_responses_api=True,
credential=lambda: (lambda: token), chat_class=_Recorder,
)
monkeypatch.setitem(openai_client.OPENAI_COMPATIBLE_PROVIDERS, "signed-in", spec)
return openai_client
@pytest.mark.unit
def test_a_signed_in_provider_sends_its_own_credential_never_an_api_key(monkeypatch):
openai_client = _signed_in_provider(monkeypatch)
monkeypatch.setenv("OPENAI_API_KEY", "sk-must-not-be-used")
llm = openai_client.OpenAIClient("gpt-x", provider="signed-in").get_llm()
assert callable(llm.kwargs["api_key"]) and llm.kwargs["api_key"]() == "plan-token"
assert llm.kwargs["base_url"] == "https://api.openai.com/v1"
@pytest.mark.unit
@pytest.mark.parametrize("client_kwargs", [
{"base_url": "https://relay.example/v1"}, # backend_url / TRADINGAGENTS_LLM_BACKEND_URL
{"api_key": "sk-explicit"},
])
def test_a_signed_in_provider_refuses_another_endpoint_or_key(monkeypatch, client_kwargs):
openai_client = _signed_in_provider(monkeypatch)
base_url = client_kwargs.pop("base_url", None)
client = openai_client.OpenAIClient("gpt-x", base_url=base_url, provider="signed-in", **client_kwargs)
with pytest.raises(ValueError):
client.get_llm()
@pytest.mark.unit
def test_a_missing_sign_in_fails_before_any_request(monkeypatch):
from tradingagents.llm_clients import openai_client
def not_signed_in():
raise ValueError("Not signed in; run `tradingagents login chatgpt`.")
spec = openai_client.ProviderSpec(base_url="https://api.openai.com/v1", pinned_base_url=True,
credential=not_signed_in, chat_class=_Recorder)
monkeypatch.setitem(openai_client.OPENAI_COMPATIBLE_PROVIDERS, "signed-in", spec)
with pytest.raises(ValueError, match="login chatgpt"):
openai_client.OpenAIClient("gpt-x", provider="signed-in").get_llm()
+40
View File
@@ -49,6 +49,46 @@ def test_save_reports_explicit_path(tmp_path):
assert out == tmp_path / "complete_report.md"
@pytest.mark.unit
@pytest.mark.parametrize("empty_value", [None, ""])
def test_save_reports_replaces_previous_run_sections(tmp_path, empty_value):
graph = SimpleNamespace(run_settings=lambda: SETTINGS)
first = dict(_state(), trade_date="2026-09-22")
TradingAgentsGraph.save_reports(graph, first, "AAPL", save_path=tmp_path)
(tmp_path / "notes.md").write_text("User notes", encoding="utf-8")
(tmp_path / "1_analysts" / "notes.md").write_text("Analyst notes", encoding="utf-8")
second = {
"trade_date": "2026-09-23",
"market_report": "NEW MARKET",
"news_report": empty_value,
"investment_debate_state": {"bull_history": empty_value},
}
out = TradingAgentsGraph.save_reports(graph, second, "AAPL", save_path=tmp_path)
assert (tmp_path / "1_analysts" / "market.md").read_text() == "NEW MARKET"
for filename in (
"1_analysts/news.md", "2_research/bull.md", "2_research/manager.md",
"3_trading/trader.md", "4_risk/neutral.md", "5_portfolio/decision.md",
):
assert not (tmp_path / filename).exists()
assert (tmp_path / "notes.md").read_text() == "User notes"
assert (tmp_path / "1_analysts" / "notes.md").read_text() == "Analyst notes"
assert "NEW MARKET" in out.read_text(encoding="utf-8")
assert "NEWS" not in out.read_text(encoding="utf-8")
assert "NEWS" not in (tmp_path / "complete_report.html").read_text(encoding="utf-8")
@pytest.mark.unit
def test_empty_report_removes_previous_sections_and_disabled_html(tmp_path):
write_report_tree(_state(), "AAPL", tmp_path)
out = write_report_tree({}, "AAPL", tmp_path, html=False)
assert list(tmp_path.rglob("*.md")) == [out]
assert not (tmp_path / "complete_report.html").exists()
assert "Trading Analysis Report: AAPL" in out.read_text()
@pytest.mark.unit
def test_save_reports_defaults_under_results_dir(tmp_path):
graph = object.__new__(TradingAgentsGraph)
+51 -17
View File
@@ -97,9 +97,10 @@ def test_the_quarter_is_not_confused_with_the_year_to_date():
@pytest.mark.unit
def test_a_line_the_filer_does_not_tag_is_named_unavailable():
def test_a_line_the_filer_does_not_tag_is_named_as_such():
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
assert "Stockholders Equity" in out and "unavailable" in out
untagged = [line for line in out.splitlines() if line.startswith("# Not tagged by this filer:")]
assert len(untagged) == 1 and "Stockholders Equity" in untagged[0]
assert "364980" in out # the rest of the statement still returns
@@ -158,8 +159,7 @@ def test_a_throttle_lets_the_next_vendor_try(monkeypatch):
def test_values_do_not_break_the_columns():
"""Figures run to the billions; a thousands separator would split the field."""
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
body = [row for row in out.splitlines() if row.startswith("Total Assets")][0]
assert body.count(",") == out.splitlines()[3].count(",")
assert _table(out)[1]["Total Assets"][-1] == "364980"
@pytest.mark.unit
@@ -167,15 +167,12 @@ def test_a_per_share_figure_keeps_its_own_unit():
"""Statements are reported in millions, but EPS is dollars per share: scaling
it the same way prints a real figure as zero."""
out = sec_edgar.get_income_statement("AAPL", "annual", "2024-11-15")
row = [r for r in out.splitlines() if r.startswith("Diluted EPS")][0]
assert "6.08" in row
assert "USD/shares" in row or "per share" in row
assert _table(out)[1]["Diluted EPS (USD/shares)"] == ["6.08"]
@pytest.mark.unit
def test_every_row_has_one_cell_per_period():
"""An untagged line still has to line up with the columns, or the table is
misread by position."""
def test_every_row_has_one_cell_per_column():
"""Every period's row lines up with the header, or the table is misread by position."""
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
table = [r for r in out.splitlines() if r and not r.startswith("#")]
widths = {row.count(",") for row in table}
@@ -237,11 +234,18 @@ def test_capital_expenditure_is_found_under_either_tag_filers_use(monkeypatch):
monkeypatch.setattr(sec_edgar, "_fetch_json",
lambda url: TICKER_MAP if "company_tickers" in url else facts)
out = sec_edgar.get_cashflow("AAPL", "annual", "2025-03-01")
assert [r for r in out.splitlines() if r.startswith("Capital Expenditure")] == ["Capital Expenditure,70"]
assert _table(out)[1]["Capital Expenditure"] == ["70"]
def _table(out):
"""(periods, {column: cells by period}) of a statement, one row per period."""
lines = [line for line in out.splitlines() if line and not line.startswith("#")]
header, rows = lines[0].split(","), [line.split(",") for line in lines[1:]]
return [row[0] for row in rows], {name: [row[i] for row in rows] for i, name in enumerate(header) if i}
def _columns(out):
return [line for line in out.splitlines() if line.startswith(",")][0].split(",")[1:]
return _table(out)[0]
@pytest.mark.unit
@@ -279,7 +283,7 @@ def test_a_recast_outside_the_annual_report_still_counts_from_its_filing(monkeyp
def eps(date):
out = sec_edgar.get_income_statement("AAPL", "annual", date)
return [line for line in out.splitlines() if line.startswith("Diluted EPS")][0].split(",")[1]
return _table(out)[1]["Diluted EPS (USD/shares)"][0]
assert eps("2019-01-01") == "16.97"
assert eps("2020-01-01") == "2.12"
@@ -301,7 +305,7 @@ def test_a_cash_flow_filed_year_to_date_is_served_with_its_span(monkeypatch):
out = sec_edgar.get_cashflow("AAPL", "quarterly", "2026-09-28")
assert _columns(out) == ["2025-12-27", "2026-03-28 (6 months)", "2026-06-27 (9 months)"]
assert [r for r in out.splitlines() if r.startswith("Operating Cash Flow")] == ["Operating Cash Flow,30000,53000,81000"]
assert _table(out)[1]["Operating Cash Flow"] == ["30000", "53000", "81000"]
@pytest.mark.unit
@@ -318,6 +322,36 @@ def test_a_row_filed_only_year_to_date_keeps_its_figure_beside_a_row_filed_by_qu
out = sec_edgar.get_cashflow("AAPL", "quarterly", "2026-09-28")
assert _columns(out) == ["2026-06-30", "2026-06-30 (6 months)"]
rows = {r.split(",")[0]: r.split(",")[1:] for r in out.splitlines()[3:]}
assert rows["Operating Cash Flow"] == ["", "120"]
assert rows["Capital Expenditure"] == ["10", ""]
columns = _table(out)[1]
assert columns["Operating Cash Flow"] == ["", "120"]
assert columns["Capital Expenditure"] == ["10", ""]
@pytest.mark.unit
def test_a_statement_serves_every_period_one_row_each(monkeypatch):
"""A filer's whole history in a row per line item ran to 67 columns and was read
a column off (#1517); a row per period keeps each figure beside its date."""
quarters = [
_fact(f"{year}-{month:02d}-30", (year * 10 + month // 3) * 1_000_000, f"{year}-{month + 1:02d}-28",
form="10-Q", fp=f"Q{month // 3}", start=f"{year}-{month - 2:02d}-01")
for year in range(2015, 2026) for month in (3, 6, 9)
]
facts = {"facts": {"us-gaap": {"Revenues": {"units": {"USD": quarters}}}}}
monkeypatch.setattr(sec_edgar, "_fetch_json",
lambda url: TICKER_MAP if "company_tickers" in url else facts)
periods, columns = _table(sec_edgar.get_income_statement("AAPL", "quarterly", "2026-01-15"))
assert periods == [f"{y}-{m:02d}-30" for y in range(2015, 2026) for m in (3, 6, 9)]
assert columns["Revenue"][0] == "20151" and columns["Revenue"][-1] == "20253"
@pytest.mark.unit
def test_a_figure_under_a_million_is_not_printed_as_zero(monkeypatch):
facts = {"facts": {"us-gaap": {"Revenues": {"units": {"USD": [
_fact("2025-12-31", 303_300, "2026-02-20", start="2025-01-01"),
_fact("2024-12-31", -25_000, "2025-02-20", start="2024-01-01")]}}}}}
monkeypatch.setattr(sec_edgar, "_fetch_json",
lambda url: TICKER_MAP if "company_tickers" in url else facts)
assert _table(sec_edgar.get_income_statement("AAPL", "annual", "2026-03-01"))[1]["Revenue"] == ["-0.0250", "0.303"]
+6 -2
View File
@@ -34,12 +34,16 @@ def test_a_test_saves_cli_selections_to_its_own_directory(tmp_path):
@pytest.mark.unit
def test_yfinance_keeps_its_cache_out_of_the_users_home():
"""yfinance caches each symbol's time zone on disk, in the home directory by default."""
"""yfinance caches each symbol's time zone on disk, in the home directory by default.
The suite's own temporary directory may itself sit under the home (``%TEMP%``
on Windows), so the check is that the cache is the suite's, not where it is.
"""
from pathlib import Path
from yfinance.cache import _TzDBManager
assert not Path(_TzDBManager.get_location()).is_relative_to(Path.home())
assert Path(_TzDBManager.get_location()).name.startswith("tradingagents-tests-yf-")
@pytest.mark.unit
+1 -1
View File
@@ -54,7 +54,7 @@ def test_fetch_returns_normalizes_symbol(monkeypatch):
assert queried[0] == "GC=F" # stock symbol normalized (#984)
assert queried[1] == "SPY" # benchmark left as the canonical symbol
assert raw is not None and days is not None
assert resolved == "2025-01-07" # resolution date recorded (#1251)
assert resolved == "2025-01-08" # resolution date recorded (#1251)
def test_news_lookup_normalizes_symbol(monkeypatch):
+32
View File
@@ -28,6 +28,7 @@ TRADE_DATE = "2026-08-14"
(None, TRADE_DATE), # omitted
("Sept 1", TRADE_DATE), # unparseable
("", TRADE_DATE),
("2026-8-1", "2026-08-01"), # unpadded: served in its canonical form
])
def test_as_of_takes_the_earlier_date(requested, expected):
assert as_of(requested, TRADE_DATE) == expected
@@ -36,6 +37,7 @@ def test_as_of_takes_the_earlier_date(requested, expected):
@pytest.mark.unit
def test_as_of_without_a_trade_date_passes_the_request_through():
assert as_of("2026-09-14", "") == "2026-09-14"
assert as_of("2026-9-14", "") == "2026-09-14"
@pytest.mark.unit
@@ -43,6 +45,7 @@ def test_as_of_without_a_trade_date_passes_the_request_through():
("2026-08-01", "2026-09-14", ("2026-08-01", TRADE_DATE)), # end clamped
("2026-08-01", "2026-08-10", ("2026-08-01", "2026-08-10")), # inside: unchanged
("2026-09-01", "2026-09-08", ("2026-08-07", TRADE_DATE)), # wholly later: span kept, moved back
("2026-8-1", "2026-8-10", ("2026-08-01", "2026-08-10")), # unpadded: canonical
])
def test_as_of_window(start, end, expected):
assert as_of_window(start, end, TRADE_DATE) == expected
@@ -128,6 +131,24 @@ def test_future_window_from_the_model_is_clamped():
assert args == ("get_stock_data", "NVDA", "2026-08-01", TRADE_DATE)
@pytest.mark.unit
def test_an_unpadded_date_reaches_the_vendor_in_canonical_form():
# Vendors compare dates as YYYY-MM-DD text: "2026-07-23" > "2026-7-3" is False,
# so an unpadded date would let a filing made after it through.
args = _run(tools.get_income_statement, {"curr_date": "2026-7-3"}, tools)
assert args[-1] == "2026-07-03"
args = _run(tools.get_news, {"start_date": "2026-7-1", "end_date": "2026-7-3"}, tools)
assert args[-2:] == ("2026-07-01", "2026-07-03")
@pytest.mark.unit
@pytest.mark.parametrize("tool", [tools.get_balance_sheet, tools.get_cashflow, tools.get_income_statement])
def test_a_statement_frequency_is_one_the_vendors_serve(tool):
# Any other word was served annual figures under the word asked for.
schema = tool.tool_call_schema.model_json_schema()["properties"]["freq"]
assert schema["enum"] == ["annual", "quarterly"]
@pytest.mark.unit
def test_direct_call_without_state_is_unchanged():
with mock.patch.object(tools, "route_to_vendor", return_value="ok") as routed:
@@ -146,6 +167,15 @@ def test_a_start_date_that_is_not_a_date_is_sent_back_to_the_model(tool):
assert "YYYY-MM-DD" in out and "start_date" in out
@pytest.mark.unit
def test_a_window_that_ends_before_it_starts_is_sent_back_to_the_model():
# A reversed window reached the vendor, which then reported no data for it.
with mock.patch.object(tools, "route_to_vendor", return_value="ok") as routed:
out = tools.get_stock_data.func("NVDA", "2026-08-12", "2026-08-01", trade_date=TRADE_DATE)
assert not routed.called
assert "start_date" in out and "end_date" in out
@pytest.mark.unit
def test_a_window_whose_start_is_not_a_date_is_refused():
with pytest.raises(ValueError, match="start_date"):
@@ -161,6 +191,8 @@ def test_propagate_rejects_a_non_canonical_date(bad):
with pytest.raises(ValueError, match="YYYY-MM-DD"):
object.__new__(TradingAgentsGraph).propagate("AAPL", bad)
with pytest.raises(ValueError, match="YYYY-MM-DD"): # the CLI starts a run here
object.__new__(TradingAgentsGraph).create_run_state("AAPL", bad)
@pytest.mark.unit
+44
View File
@@ -73,3 +73,47 @@ class TestTool:
{"symbol": "COF", "curr_date": "2026-05-20"}
)
assert "Verified market data snapshot for COF" in out
@pytest.mark.unit
class TestPricesKeepTheirDigits:
"""A price prints with its significant digits: a sub-cent coin is not 0.00, a rate keeps its pips."""
@pytest.mark.parametrize("value, text", [
(0.00000503123456, "0.00000503123"),
(1.123456, "1.12346"),
(763.960022, "763.96"),
(81074.1875, "81074.19"),
(100.0, "100.00"),
(-0.0012345678, "-0.00123457"),
(0.0, "0.00"),
])
def test_price_text(self, value, text):
from tradingagents.dataflows.vendors.yahoo.common import price_text
assert price_text(value) == text
def test_the_snapshot_shows_a_sub_cent_close(self, monkeypatch):
data = _sample_ohlcv()
for col in ("Open", "High", "Low", "Close"):
data[col] = data[col] * 0.0000001
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: data)
text = validator.build_verified_market_snapshot("SHIB-USD", "2026-05-20")
assert "| Close | 0.0000135 |" in text and "| Close | 0.00 |" not in text
def test_the_price_table_shows_a_sub_cent_close(self, monkeypatch):
from tradingagents.dataflows.vendors.yahoo import market
days = pd.bdate_range("2026-05-11", "2026-05-15")
frame = pd.DataFrame({"Open": 0.00000501, "High": 0.00000512, "Low": 0.00000499,
"Close": 0.00000503, "Volume": 10**9}, index=days)
class _Ticker:
def __init__(self, symbol):
pass
def history(self, **kwargs):
return frame
monkeypatch.setattr(market.yf, "Ticker", _Ticker)
text = market.get_YFin_data_online("SHIB-USD", "2026-05-11", "2026-05-15")
assert "0.00000503" in text and ",0.0," not in text
+1 -1
View File
@@ -1,6 +1,6 @@
"""TradingAgents: multi-agent LLM financial trading framework."""
__version__ = "0.6.0"
__version__ = "0.7.1"
from dotenv import find_dotenv, load_dotenv
+4 -4
View File
@@ -4,7 +4,7 @@ Each dated tool takes the run's ``trade_date`` from graph state (``InjectedState
and never serves data past it, whatever date the model asks for.
"""
from typing import Annotated
from typing import Annotated, Literal
from langchain_core.tools import tool
from langgraph.prebuilt import InjectedState
@@ -114,7 +114,7 @@ def get_fundamentals(
@tool
def get_balance_sheet(
ticker: Annotated[str, InjectedState("company_of_interest")],
freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly",
freq: Annotated[Literal["annual", "quarterly"], "reporting frequency"] = "quarterly",
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
trade_date: Annotated[str, InjectedState("trade_date")] = "",
) -> str:
@@ -133,7 +133,7 @@ def get_balance_sheet(
@tool
def get_cashflow(
ticker: Annotated[str, InjectedState("company_of_interest")],
freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly",
freq: Annotated[Literal["annual", "quarterly"], "reporting frequency"] = "quarterly",
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
trade_date: Annotated[str, InjectedState("trade_date")] = "",
) -> str:
@@ -152,7 +152,7 @@ def get_cashflow(
@tool
def get_income_statement(
ticker: Annotated[str, InjectedState("company_of_interest")],
freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly",
freq: Annotated[Literal["annual", "quarterly"], "reporting frequency"] = "quarterly",
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
trade_date: Annotated[str, InjectedState("trade_date")] = "",
) -> str:
+29 -7
View File
@@ -16,6 +16,7 @@ cell rather than a position carried forward.
from __future__ import annotations
import itertools
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
@@ -119,13 +120,26 @@ class BacktestSummary:
if self.pending:
lines.append("Pending cells are not scored above; re-run to settle them.")
lines.append(
f"Alpha is measured over {self.holding} after each analysis date. "
f"Alpha is measured over {self.holding} from the first close after each analysis date. "
"One model sampling per cell, and text feeds are not archived, so "
"these figures are indicative rather than repeatable."
)
return "\n".join(lines)
def _new_run(runs: Path) -> str:
"""A new run's id, stamped now, and its directory, claimed so no other sweep shares it."""
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
runs.mkdir(parents=True, exist_ok=True)
for n in itertools.count(1):
run_id = stamp if n == 1 else f"{stamp}_{n}"
try:
(runs / run_id).mkdir()
return run_id
except FileExistsError:
continue
def run_backtest(
tickers: list[str],
dates: list[str],
@@ -143,11 +157,15 @@ def run_backtest(
interrupted sweep resumes by being run again. ``progress(done, total,
ticker, date)`` is called before each cell that runs.
"""
# run_id becomes a path segment, so it is validated like a ticker: an
# absolute or dotted value would otherwise place the run outside results_dir.
run_id = safe_ticker_component(run_id or datetime.now().strftime("%Y%m%d_%H%M%S"))
run_dir = Path(config["results_dir"]) / "backtest" / run_id
run_dir.mkdir(parents=True, exist_ok=True)
runs = Path(config["results_dir"]) / "backtest"
if run_id:
# run_id becomes a path segment, so it is validated like a ticker: an
# absolute or dotted value would otherwise place the run outside results_dir.
run_id = safe_ticker_component(run_id)
(runs / run_id).mkdir(parents=True, exist_ok=True)
else:
run_id = _new_run(runs)
run_dir = runs / run_id
run_config = {**config, "results_dir": str(run_dir),
"memory_log_path": str(run_dir / "trading_memory.md")}
@@ -174,7 +192,11 @@ def run_backtest(
# last cell would stay pending without this pass.
for ticker in tickers:
try:
graph.settle_pending(ticker)
settlement = graph.settle_pending(ticker)
result.settlement_failures.extend(
(failed_ticker, f"{date}: {reason}")
for failed_ticker, date, reason in settlement.failed
)
except Exception as exc: # reflection calls an LLM; one failure is not the sweep's
logger.warning("Settling %s failed: %s", ticker, exc)
result.settlement_failures.append((ticker, str(exc)))
+15 -9
View File
@@ -1,7 +1,8 @@
"""Point-in-time rules shared by every dated path: data is served as of the run's date.
- ``as_of`` / ``as_of_window`` clamp a date or window the model asks for to the
trade date, so no tool reaches a vendor with a later one.
trade date, so no tool reaches a vendor with a later one, and always hand it
over as ``YYYY-MM-DD``: vendors compare dates as text.
- ``in_window`` trims dated items (news, StockTwits, Reddit) to the analysis
window: timestamps normalized to UTC, the upper bound exclusive at midnight
after ``end``, and an undated item kept only when the window reaches the
@@ -79,13 +80,14 @@ def as_of(requested: str | None, trade_date: str) -> str | None:
"""The date a tool serves: the model's date, but never later than the run's.
A model can omit the date or pass today's instead of the analysis date, which
would walk past every point-in-time guard behind the tool. An empty
would walk past every point-in-time guard behind the tool. A date is served in
its canonical form: "2026-7-3" compares as later than "2026-07-23". An empty
``trade_date`` (a direct call outside a graph run) passes the request through.
"""
if not trade_date:
return requested
parsed = _parse(requested)
return requested if parsed is not None and parsed <= _parse(trade_date) else trade_date
if not trade_date:
return requested if parsed is None else f"{parsed:%Y-%m-%d}"
return f"{parsed:%Y-%m-%d}" if parsed is not None and parsed <= _parse(trade_date) else trade_date
def as_of_window(start_date: str, end_date: str, trade_date: str) -> tuple[str, str]:
@@ -93,16 +95,20 @@ def as_of_window(start_date: str, end_date: str, trade_date: str) -> tuple[str,
A window wholly after the run date keeps its length and moves back to end there.
An unreadable end reads as the run date, but no window can be guessed from an
unreadable start, so it raises ValueError for the caller to send back (#1476).
unreadable start or from a start after the end, so those raise ValueError for
the caller to send back (#1476).
"""
start, old_end = _parse(start_date), _parse(end_date)
if start is None:
raise ValueError(f"start_date {start_date!r} is not a date; give it as YYYY-MM-DD")
if old_end is not None and start > old_end:
raise ValueError(f"start_date {start_date} is after end_date {end_date}; give the earlier date first")
end = as_of(end_date, trade_date)
if end == end_date or start <= _parse(end):
return start_date, end
end_dt = _parse(end)
if end_dt is None or end_dt == old_end or start <= end_dt:
return f"{start:%Y-%m-%d}", end
span = (old_end - start) if old_end is not None and old_end >= start else timedelta(0)
return f"{_parse(end) - span:%Y-%m-%d}", end
return f"{end_dt - span:%Y-%m-%d}", end
def withhold_live_profile(as_of_date: str | None, label: str) -> str | None:
+2 -2
View File
@@ -74,12 +74,12 @@ def _hold_windows(handle, wait: bool = True) -> Iterator[bool]:
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK if wait else msvcrt.LK_NBLCK, 1)
break
except OSError as exc:
if not wait and exc.errno in (errno.EACCES, errno.EDEADLOCK):
if not wait and exc.errno in (errno.EACCES, errno.EDEADLK):
yield False # another writer holds it
return
# LK_LOCK gives up after about ten seconds of another writer's hold;
# any other failure is not a wait.
if exc.errno != errno.EDEADLOCK:
if exc.errno != errno.EDEADLK:
raise
try:
yield True
+17 -1
View File
@@ -11,6 +11,7 @@ outcomes (a "Yes" at 0.76 means the market prices a 76% chance).
"""
import json
import logging
import math
from datetime import UTC, datetime
import requests
@@ -67,6 +68,21 @@ def _is_forward_looking(market: dict, now: datetime) -> bool:
)
def _odds(prob: float) -> str:
"""A probability as a percentage; within 1% of 0 or 100 its distance from
there keeps two significant digits, so 0.05% is not 0% and 99.95% not 100%."""
def decimals(distance: float) -> int:
return 1 - math.floor(math.log10(distance))
if 0 < prob < 0.01:
percent = prob * 100
return f"{percent:.{decimals(percent)}f}".rstrip("0").rstrip(".") + "%"
if 0.99 < prob < 1:
distance = (1 - prob) * 100
return f"{100 - distance:.{decimals(distance)}f}".rstrip("0").rstrip(".") + "%"
return f"{prob:.0%}"
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.
@@ -142,7 +158,7 @@ def get_prediction_markets(topic: str, limit: int | None = None, as_of_date: str
else ""
)
lines.append(
f"- **{m.get('question')}** — {label} {prob:.0%} "
f"- **{m.get('question')}** — {label} {_odds(prob)} "
f"(${volume:,.0f} volume, resolves {end_date}{wk_str})"
)
+27 -14
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import json
import logging
import math
import os
import time
from datetime import date, datetime
@@ -216,27 +217,39 @@ def _statement(kind: str, ticker: str, freq: str, as_of_date: str, title: str) -
periods = sorted({(end, index) for spans_of in chosen.values() for end, index in spans_of.items()})
if not periods:
raise NoMarketDataError(ticker, ticker, f"no {freq} {title.lower()} filed by {as_of_date}")
tagged = {label: line for label, line in lines.items() if line[0]}
untagged = [label for label in lines if label not in tagged]
header = (
f"# {title} for {ticker.upper()} ({freq}), USD in millions unless the row says otherwise\n"
f"# SEC EDGAR facts filed on or before {as_of_date}, at the values filed then\n\n"
f"# {title} for {ticker.upper()} ({freq}), USD in millions unless the column says otherwise\n"
f"# SEC EDGAR facts filed on or before {as_of_date}, at the values filed then\n"
+ (f"# Not tagged by this filer: {', '.join(untagged)}\n" if untagged else "")
+ "\n"
)
rows = [",".join([""] + [end + names[index] for end, index in periods])]
for label, (values, unit) in lines.items():
# Every row spans the same columns, or a reader lines the table up wrong.
if not values:
rows.append(",".join([label] + ["unavailable (not tagged by this filer)"] * len(periods)))
continue
name = label if unit == "USD" else f"{label} ({unit})"
# Plain numbers: a thousands separator would split the CSV field.
# One row per period, so every figure sits beside its own date: across a
# filer's whole history a row per line item ran to dozens of columns and was
# read a column off (#1517).
rows = [",".join(["Period"] + [label if unit == "USD" else f"{label} ({unit})"
for label, (_, unit) in tagged.items()])]
for end, index in periods:
cells = []
for end, index in periods:
for label, (values, unit) in tagged.items():
value = values.get((end, index)) if chosen[label].get(end) == index else None
cells.append("" if value is None else f"{value / 1e6:.0f}" if unit == "USD" else f"{value:.2f}")
rows.append(",".join([name] + cells))
cells.append("" if value is None else _millions(value) if unit == "USD" else f"{value:.2f}")
rows.append(",".join([end + names[index]] + cells))
return header + "\n".join(rows) + "\n"
def _millions(value: float) -> str:
"""USD in millions; under a million, three significant digits, so it is not printed as 0.
Plain numbers: a thousands separator would split the CSV field.
"""
millions = value / 1e6
if millions == 0 or abs(millions) >= 1:
return f"{millions:.0f}"
return f"{millions:.{2 - math.floor(math.log10(abs(millions)))}f}"
def get_balance_sheet(ticker: str, freq: str = "quarterly", as_of_date: str | None = None) -> str:
"""Balance sheet as filed on or before ``as_of_date``."""
return _statement("balance_sheet", ticker, freq, as_of_date, "Balance Sheet")
+16 -1
View File
@@ -1,6 +1,8 @@
"""What every Yahoo request shares: its retry, and how a failure or an empty answer is reported."""
"""What every Yahoo request shares: its retry, how a failure or an empty answer is
reported, and how a price is written out."""
import logging
import math
import time
import yfinance as yf
@@ -14,6 +16,19 @@ logger = logging.getLogger(__name__)
YAHOO_HOST = "https://query2.finance.yahoo.com"
def price_text(value: float) -> str:
"""A price with six significant digits and at least two decimals.
Fixed decimals print a sub-cent coin as 0.00 and a currency pair without its
pips; this keeps both readable.
"""
if not math.isfinite(value):
return str(value)
decimals = 2 if value == 0 else max(2, 5 - math.floor(math.log10(abs(value))))
whole, _, fraction = f"{value:.{decimals}f}".partition(".")
return f"{whole}.{fraction.rstrip('0').ljust(2, '0')}"
def raise_for_empty(symbol: str, canonical: str, what: str) -> None:
"""Report an empty Yahoo answer as an absence, or as an outage if it is one.
+3 -5
View File
@@ -9,7 +9,7 @@ from stockstats import wrap
from tradingagents.dataflows.errors import NoMarketDataError, VendorError
from tradingagents.dataflows.symbols import normalize_symbol
from tradingagents.dataflows.vendors.yahoo.common import raise_for_empty, yf_retry
from tradingagents.dataflows.vendors.yahoo.common import price_text, raise_for_empty, yf_retry
from tradingagents.dataflows.vendors.yahoo.ohlcv import _assert_ohlcv_not_stale, load_ohlcv
logger = logging.getLogger(__name__)
@@ -48,11 +48,9 @@ def get_YFin_data_online(
# turns into one clear unavailable signal (#1021).
_assert_ohlcv_not_stale(data, end_date, symbol, canonical)
# Round numerical values to 2 decimal places for cleaner display
numeric_columns = ["Open", "High", "Low", "Close", "Adj Close"]
for col in numeric_columns:
for col in ("Open", "High", "Low", "Close", "Adj Close"):
if col in data.columns:
data[col] = data[col].round(2)
data[col] = data[col].map(price_text, na_action="ignore")
csv_string = data.to_csv()
+2 -1
View File
@@ -17,6 +17,7 @@ from stockstats import wrap
from tradingagents.dataflows.errors import NoMarketDataError
from tradingagents.dataflows.symbols import normalize_symbol
from tradingagents.dataflows.vendors.yahoo.common import price_text
from tradingagents.dataflows.vendors.yahoo.ohlcv import load_ohlcv
# A fixed, common indicator set so the snapshot is the same shape every run.
@@ -59,7 +60,7 @@ def _fmt(value) -> str:
if isinstance(value, (int,)):
return str(value)
if isinstance(value, float):
return f"{value:.2f}"
return price_text(value)
return str(value)
+2 -2
View File
@@ -175,8 +175,8 @@ def build_default_config() -> dict:
# based on the ticker's exchange suffix. SPY remains the US default
# so the reflection label keeps reading "Alpha vs SPY" for US tickers
# while non-US tickers get their regional index automatically.
# Trading days after the analysis date over which a decision's outcome is
# measured, for reflection and for the backtest figures.
# Trading days from the first close after the analysis date over which a
# decision's outcome is measured, for reflection and for the backtest figures.
"holding_period_days": 5,
"benchmark_ticker": None,
"benchmark_map": {
+1
View File
@@ -297,6 +297,7 @@ class TradingAgentsGraph:
memory log's lessons are not here: the graph's Memory Log step settles
and loads them alongside the analysts (see ``_memory_step``).
"""
trade_date = _validate_trade_date(trade_date)
return self.propagator.create_initial_state(
company_name,
trade_date,
@@ -15,12 +15,13 @@ _PASSTHROUGH_KWARGS = (
# Sonnet 4.6+, and the Claude 5 family (Sonnet 5, Opus 5.5, Fable 5). Sonnet 4.5 and any
# Haiku version 400 with ``"This model does not support the effort parameter"``
# (#831). Versions may be dotted (``opus-4-8``) or single-number (``sonnet-5``,
# ``fable-5``); the per-family minimum below is forward-compatible.
# ``fable-5``), and a snapshot carries its date (``sonnet-4-20250514``), which is
# not a minor version; the per-family minimum below is forward-compatible.
_EFFORT_EXACT = {
"claude-mythos-preview", # non-standard preview name; effort-capable
"claude-mythos-5", # Fable 5 twin (Project Glasswing); effort-capable
}
_EFFORT_MODEL = re.compile(r"^claude-(opus|sonnet|fable)-(\d+)(?:-(\d+))?$")
_EFFORT_MODEL = re.compile(r"^claude-(opus|sonnet|fable)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$")
_EFFORT_MIN_VERSION = {"opus": (4, 5), "sonnet": (4, 6), "fable": (5, 0)}
+2
View File
@@ -35,6 +35,8 @@ PROVIDER_API_KEY_ENV: dict[str, str | None] = {
"kimi": "MOONSHOT_API_KEY",
"groq": "GROQ_API_KEY",
"nvidia": "NVIDIA_API_KEY",
# A ChatGPT plan authenticates with the person's sign-in, not a key.
"chatgpt": None,
# Local runtimes do not authenticate.
"ollama": None,
# Generic OpenAI-compatible endpoint: the client reads this when set (keyed
+5 -2
View File
@@ -6,7 +6,7 @@ from langchain_openai import AzureChatOpenAI
from .base_client import BaseLLMClient, normalize_content
_PASSTHROUGH_KWARGS = (
"timeout", "max_retries", "api_key", "reasoning_effort", "temperature",
"timeout", "max_retries", "api_key", "reasoning_effort", "temperature", "max_tokens",
"callbacks", "http_client", "http_async_client",
)
@@ -23,7 +23,8 @@ class AzureOpenAIClient(BaseLLMClient):
Requires environment variables:
AZURE_OPENAI_API_KEY: API key
AZURE_OPENAI_ENDPOINT: Endpoint URL (e.g. https://<resource>.openai.azure.com/)
AZURE_OPENAI_ENDPOINT: Endpoint URL (e.g. https://<resource>.openai.azure.com/),
unless backend_url gives one
AZURE_OPENAI_DEPLOYMENT_NAME: Deployment name
OPENAI_API_VERSION: API version (e.g. 2025-03-01-preview)
"""
@@ -39,6 +40,8 @@ class AzureOpenAIClient(BaseLLMClient):
"model": self.model,
"azure_deployment": os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", self.model),
}
if self.base_url: # a configured endpoint, else AZURE_OPENAI_ENDPOINT
llm_kwargs["azure_endpoint"] = self.base_url
for key in _PASSTHROUGH_KWARGS:
if key in self.kwargs:
+416
View File
@@ -0,0 +1,416 @@
"""Sign in with ChatGPT, so a run uses the person's ChatGPT Plus or Pro plan.
This follows OpenAI's documented flow for open-source apps ("Sign in with ChatGPT":
developers.openai.com/siwc): dynamic client registration, PKCE, and a loopback
redirect on 127.0.0.1. The sign-in, its tokens and this host's id live in one file
under ~/.tradingagents, readable only by its owner. Access tokens last an hour and
are refreshed under a lock shared by every thread and process, because each refresh
rotates the refresh token and replaying a used one ends the sign-in.
"""
import base64
import hashlib
import http.server
import json
import os
import secrets
import threading
import time
import urllib.parse
import uuid
from collections.abc import Callable
from pathlib import Path
import requests
from tradingagents.dataflows.files import locked
from tradingagents.default_config import _TRADINGAGENTS_HOME
ISSUER = "https://auth.openai.com"
AUTHORIZE_URL = f"{ISSUER}/api/accounts/authorize"
TOKEN_URL = f"{ISSUER}/api/accounts/oauth/token"
REVOKE_URL = f"{ISSUER}/api/accounts/oauth/revoke"
JWKS_URL = f"{ISSUER}/.well-known/jwks.json"
API_BASE = "https://api.openai.com/v1"
DYNAMIC_CLIENT_ID = "dynamic_agent_client"
APP_NAME = "TradingAgents"
SCOPE = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct"
PLAN_SCOPE = "chatgpt.tokens.use.direct"
CALLBACK_PATH = "/auth/callback"
DEFAULT_PORT = 1455
REFRESH_MARGIN = 300 # refresh when the access token has under 5 minutes left
RETRY_AFTER = 60 # after OpenAI could not renew, wait this long before trying again
TIMEOUT = 30
AUTH_FILE = Path(_TRADINGAGENTS_HOME) / "chatgpt-auth.json"
LOGIN_HINT = "Not signed in to ChatGPT; run `tradingagents login chatgpt`."
_retry_after = 0.0 # no renewal attempt in this process before this time
class ChatGPTAuthError(RuntimeError):
"""Signing in or refreshing failed; the message says what to do."""
class ChatGPTUnreachable(ChatGPTAuthError):
"""OpenAI could not be reached or was busy; the sign-in itself is still good."""
# --- the auth file -------------------------------------------------------------------
def _read() -> dict:
try:
data = json.loads(AUTH_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def _write(data: dict) -> None:
"""Write the auth file whole, readable only by its owner. Unlike a cache, a failed
write must surface: losing a rotated refresh token would end the sign-in."""
AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
temp = AUTH_FILE.with_name(f"{AUTH_FILE.name}.{uuid.uuid4().hex}.tmp")
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(data, handle)
os.replace(temp, AUTH_FILE)
except BaseException:
temp.unlink(missing_ok=True)
raise
def _host_id(data: dict) -> str:
return data.get("host_id") or f"urn:uuid:{uuid.uuid4()}"
def signed_in_as() -> str | None:
"""The signed-in account's email, or None."""
data = _read()
return (data.get("account") or {}).get("email") if data.get("tokens") else None
# --- tokens --------------------------------------------------------------------------
def _b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def _b64url_decode(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def _signing_keys() -> list[dict]:
"""OpenAI's ID-token signing keys. Fetched before a token request, so nothing can fail
on the network between OpenAI issuing tokens and this file keeping them."""
try:
keys = requests.get(JWKS_URL, timeout=TIMEOUT).json()["keys"]
except (requests.RequestException, ValueError, KeyError, TypeError) as exc:
raise ChatGPTUnreachable(f"Could not reach OpenAI to check the sign-in ({type(exc).__name__}); "
"try again.") from None
return keys if isinstance(keys, list) else []
def _validated_claims(id_token: str, client_id: str, nonce: str | None, keys: list[dict]) -> dict:
"""The ID token's claims after checking its RS256 signature against OpenAI's keys,
issuer, audience, expiry and (at sign-in) nonce, as the sign-in guide requires."""
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding, rsa
try:
header_b64, payload_b64, signature_b64 = id_token.split(".")
header = json.loads(_b64url_decode(header_b64))
key = next((k for k in keys if k.get("kid") == header.get("kid") and k.get("kty") == "RSA"), None)
if header.get("alg") != "RS256" or key is None:
raise InvalidSignature
public = rsa.RSAPublicNumbers(int.from_bytes(_b64url_decode(key["e"]), "big"),
int.from_bytes(_b64url_decode(key["n"]), "big")).public_key()
public.verify(_b64url_decode(signature_b64), f"{header_b64}.{payload_b64}".encode(),
padding.PKCS1v15(), hashes.SHA256())
claims = json.loads(_b64url_decode(payload_b64))
except (InvalidSignature, ValueError, KeyError, TypeError, AttributeError):
raise ChatGPTAuthError("ChatGPT sign-in returned an ID token OpenAI's keys do not verify.") from None
audience = claims.get("aud")
audiences = audience if isinstance(audience, list) else [audience]
if (claims.get("iss") != ISSUER or client_id not in audiences or not claims.get("sub")
or claims.get("exp", 0) + 5 < time.time()
or (nonce is not None and claims.get("nonce") != nonce)):
raise ChatGPTAuthError("ChatGPT sign-in returned an ID token that does not match this sign-in.")
return claims
def _token_record(response: dict, now: float, previous: dict | None = None) -> dict:
previous = previous or {}
return {
"access_token": response["access_token"],
"refresh_token": response.get("refresh_token") or previous.get("refresh_token"),
"expires_at": now + int(response.get("expires_in") or 3600),
"earliest_refresh_at": response.get("earliest_refresh_at"),
"scopes": sorted((response.get("scope") or "").split()) or previous.get("scopes", []),
}
def _oauth_error(response) -> str:
try:
body = response.json()
except ValueError:
return f"HTTP {response.status_code}"
error = body.get("error")
if isinstance(error, dict):
return error.get("code") or error.get("message") or f"HTTP {response.status_code}"
return error or f"HTTP {response.status_code}"
def _token_request(form: dict, task: str, refused: str) -> dict:
"""OpenAI's answer to a token request, or the error to show: ``ChatGPTUnreachable``
when OpenAI could not answer (try again), ``ChatGPTAuthError`` when it refused."""
try:
response = requests.post(TOKEN_URL, timeout=TIMEOUT, data=form)
except requests.RequestException as exc:
raise ChatGPTUnreachable(f"Could not reach OpenAI to {task} ({type(exc).__name__}); try again.") from None
if response.status_code in (408, 429) or response.status_code >= 500:
raise ChatGPTUnreachable(f"OpenAI could not {task} now ({_oauth_error(response)}); try again.")
if response.status_code != 200:
raise ChatGPTAuthError(f"OpenAI did not {task} ({_oauth_error(response)}); {refused}.")
try:
body = response.json()
except ValueError:
body = None
if not isinstance(body, dict) or not isinstance(body.get("access_token"), str):
raise ChatGPTUnreachable(f"OpenAI's answer could not be read when asked to {task}; try again.")
return body
def _refresh(data: dict, now: float) -> dict:
tokens, account = data["tokens"], data["account"]
keys = _signing_keys()
body = _token_request({"grant_type": "refresh_token", "client_id": account["client_id"],
"refresh_token": tokens["refresh_token"], "resource": API_BASE},
"renew your ChatGPT sign-in", "run `tradingagents login chatgpt` again")
if body.get("id_token"):
claims = _validated_claims(body["id_token"], account["client_id"], None, keys)
if claims["sub"] != account.get("subject"):
raise ChatGPTAuthError("The renewed ChatGPT sign-in belongs to another account.")
return {**data, "tokens": _token_record(body, now, tokens)}
def _due(tokens: dict, now: float) -> bool:
"""Whether to renew: under five minutes left, and OpenAI accepts a renewal by now
(``earliest_refresh_at``), unless the token has already run out."""
expires = tokens.get("expires_at", 0)
earliest = tokens.get("earliest_refresh_at")
if now < expires - REFRESH_MARGIN:
return False
return now >= expires or not isinstance(earliest, (int, float)) or now >= earliest
def access_token(now: Callable[[], float] = time.time) -> str:
"""A valid access token, renewed when it has under five minutes left.
Renewal happens under a lock every thread and process shares, and starts from
the token on disk: another run may have renewed it already, and the refresh
token it rotated away must never be sent again. When OpenAI cannot be reached
or is busy, a token that still works is used and renewal is tried again a minute later."""
global _retry_after
tokens = _read().get("tokens")
if not tokens:
raise ChatGPTAuthError(LOGIN_HINT)
if not _due(tokens, now()):
return tokens["access_token"]
with locked(AUTH_FILE):
data = _read()
if not data.get("tokens"):
raise ChatGPTAuthError(LOGIN_HINT)
tokens = data["tokens"]
if _due(tokens, now()):
still_works = tokens.get("expires_at", 0) > now()
if now() < _retry_after:
if still_works:
return tokens["access_token"]
raise ChatGPTUnreachable("OpenAI could not renew your ChatGPT sign-in a moment ago; "
"try again in a minute.")
try:
data = _refresh(data, now())
except ChatGPTUnreachable:
_retry_after = now() + RETRY_AFTER
if not still_works:
raise
return tokens["access_token"]
_write(data)
return data["tokens"]["access_token"]
def credential() -> Callable[[], str]:
"""The api_key callable for the chatgpt provider; raises now when nobody is signed in,
so a run stops before it starts rather than at its first model call."""
if not _read().get("tokens"):
raise ChatGPTAuthError(LOGIN_HINT)
return access_token
def list_models() -> list[tuple[str, str]]:
"""(name, model ID) for each model the signed-in plan offers, in OpenAI's order."""
response = requests.get(f"{API_BASE}/models", timeout=10,
headers={"Authorization": f"Bearer {access_token()}"})
response.raise_for_status()
models = [m for m in response.json().get("models", [])
if isinstance(m, dict) and isinstance(m.get("slug"), str)
and m.get("visibility") == "list" and m.get("supported_in_api", True)]
models.sort(key=lambda m: m.get("priority") or 0)
return [(m.get("display_name") or m["slug"], m["slug"]) for m in models]
# --- signing in and out --------------------------------------------------------------
class _Callback:
"""The first answer carrying this attempt's ``state``; stray or stale requests are ignored."""
def __init__(self, state: str):
self.state = state
self.params: dict | None = None
self.received = threading.Event()
self._lock = threading.Lock()
def accept(self, url: str) -> bool:
parsed = urllib.parse.urlparse(url.strip())
params = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}
if parsed.path != CALLBACK_PATH or params.get("state") != self.state:
return False
with self._lock:
if self.params is None:
self.params = params
self.received.set()
return True
def _listen(callback: _Callback, port: int) -> http.server.HTTPServer:
class Handler(http.server.BaseHTTPRequestHandler):
timeout = 5 # a connection that sends nothing cannot hold up the real answer
def do_GET(self): # noqa: N802 (http.server's name)
ok = callback.accept(self.path)
self.send_response(200 if ok else 400)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
if ok:
self.wfile.write(b"Return to the terminal to finish signing in to TradingAgents.\n")
def log_message(self, *args): # the URL carries the sign-in code
pass
try:
server = http.server.HTTPServer(("127.0.0.1", port), Handler)
except OSError:
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
# A short poll interval lets the server stop promptly once the answer has arrived.
threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True).start()
return server
def _revoke(tokens: dict, client_id: str | None) -> bool:
try:
response = requests.post(REVOKE_URL, timeout=TIMEOUT, data={
"token": tokens.get("refresh_token"), "token_type_hint": "refresh_token",
"client_id": client_id,
})
except requests.RequestException:
return False
return response.status_code == 200
def sign_in(say: Callable[[str], None] = print, read_line: Callable[[], str] = input,
timeout: float = 600, port: int = DEFAULT_PORT) -> str:
"""Sign in with ChatGPT and save the result; returns the account's email.
The address to open is printed. The browser comes back to 127.0.0.1; when this
machine is remote or in a container, paste the address the browser ends up on.
The pasted line is read in a thread that can outlive this call, so sign in as the
last prompt of a process (``tradingagents login chatgpt``)."""
data = _read()
host_id = _host_id(data)
account = data.get("account") or {}
client_id = account.get("client_id") or DYNAMIC_CLIENT_ID
state, nonce, verifier = (_b64url(secrets.token_bytes(32)), _b64url(secrets.token_bytes(32)),
_b64url(secrets.token_bytes(64)))
callback = _Callback(state)
server = _listen(callback, port)
try:
redirect_uri = f"http://127.0.0.1:{server.server_address[1]}{CALLBACK_PATH}"
params = {
"client_id": client_id, "ext_agent_host_id": host_id, "response_type": "code",
"redirect_uri": redirect_uri, "scope": SCOPE, "resource": API_BASE, "state": state,
"nonce": nonce, "code_challenge_method": "S256",
"code_challenge": _b64url(hashlib.sha256(verifier.encode()).digest()),
}
if client_id == DYNAMIC_CLIENT_ID:
params["agent_name_hint"] = APP_NAME
elif account.get("email"):
params["login_hint"] = account["email"]
say("Continue with ChatGPT: open this address and sign in with your Plus or Pro plan:")
say(f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params, quote_via=urllib.parse.quote)}")
say("If the browser cannot reach this machine, paste here the address it ends up on.")
def read_pasted():
try:
while not callback.received.is_set():
if not callback.accept(read_line()) and not callback.received.is_set():
say("That is not the address this sign-in ends on; paste the whole address.")
except (EOFError, OSError):
return
threading.Thread(target=read_pasted, daemon=True).start()
if not callback.received.wait(timeout):
raise ChatGPTAuthError("Signing in with ChatGPT timed out; run `tradingagents login chatgpt` again.")
finally:
server.shutdown()
server.server_close()
answer = callback.params
if answer.get("error"):
raise ChatGPTAuthError(f"ChatGPT sign-in was not completed ({answer['error']}).")
issued = answer.get("client_id") or client_id
if issued == DYNAMIC_CLIENT_ID or (account.get("client_id") and issued != account["client_id"]):
raise ChatGPTAuthError("ChatGPT sign-in did not register TradingAgents; run "
"`tradingagents logout chatgpt`, then sign in again.")
keys = _signing_keys()
body = _token_request({"grant_type": "authorization_code", "client_id": issued,
"code": answer.get("code", ""), "code_verifier": verifier,
"redirect_uri": redirect_uri, "resource": API_BASE},
"sign you in to ChatGPT", "sign in again")
claims = _validated_claims(body.get("id_token") or "", issued, nonce, keys)
if account.get("subject") and claims["sub"] != account["subject"]:
raise ChatGPTAuthError(f"This machine is signed in as {account.get('email') or 'another account'}; "
"run `tradingagents logout chatgpt` first.")
if PLAN_SCOPE not in (body.get("scope") or "").split():
raise ChatGPTAuthError("ChatGPT did not allow TradingAgents to use your plan; "
"sign in again and approve plan usage.")
AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
with locked(AUTH_FILE):
replaced = _read().get("tokens")
_write({
"host_id": host_id,
"account": {"client_id": issued, "email": claims.get("email"), "subject": claims["sub"]},
"tokens": _token_record(body, time.time()),
})
if replaced:
_revoke(replaced, issued)
return claims.get("email") or "your ChatGPT account"
def sign_out() -> str | None:
"""Revoke the sign-in at OpenAI and forget it, keeping only this host's id.
Returns None when nobody was signed in, "revoked", or "forgotten" when OpenAI
could not be reached to revoke it."""
if not _read().get("tokens"):
return None
with locked(AUTH_FILE):
data = _read()
tokens = data.get("tokens")
if not tokens:
return None
revoked = _revoke(tokens, (data.get("account") or {}).get("client_id"))
_write({"host_id": _host_id(data)})
return "revoked" if revoked else "forgotten"
+5 -1
View File
@@ -87,6 +87,10 @@ def _coerce_max_tokens(value):
return n
# Providers that take OpenAI's ``openai_reasoning_effort``.
REASONING_EFFORT_PROVIDERS = ("openai", "chatgpt")
def build_llm_kwargs(config: dict) -> dict[str, Any]:
"""Keyword arguments for ``create_llm_client`` from a TradingAgents config."""
kwargs = {}
@@ -97,7 +101,7 @@ def build_llm_kwargs(config: dict) -> dict[str, Any]:
if thinking_level:
kwargs["thinking_level"] = thinking_level
elif provider == "openai":
elif provider in REASONING_EFFORT_PROVIDERS:
reasoning_effort = config.get("openai_reasoning_effort")
if reasoning_effort:
kwargs["reasoning_effort"] = reasoning_effort
@@ -221,6 +221,8 @@ MODEL_OPTIONS: ProviderModeOptions = {
"nvidia": _CUSTOM_ONLY,
# Bedrock model IDs / cross-region inference profile IDs are user-specified.
"bedrock": _CUSTOM_ONLY,
# The plan's models are listed live from OpenAI once signed in.
"chatgpt": _CUSTOM_ONLY,
}
+74 -1
View File
@@ -1,5 +1,7 @@
import logging
import os
import re
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
@@ -12,6 +14,8 @@ from .base_client import BaseLLMClient, normalize_content
from .capabilities import get_capabilities
from .validators import validate_model
logger = logging.getLogger(__name__)
class NormalizedChatOpenAI(ChatOpenAI):
"""ChatOpenAI with normalized content output and capability-aware binding.
@@ -162,6 +166,48 @@ class MinimaxChatOpenAI(NormalizedChatOpenAI):
return payload
# Settings a ChatGPT plan refuses.
_PLAN_REFUSED = ("temperature", "top_p", "max_tokens")
class ChatGPTPlanChatOpenAI(NormalizedChatOpenAI):
"""The person's ChatGPT plan through OpenAI's Responses API.
Plan usage accepts no stored responses and no ``system`` role, and refuses
sampling and length settings. Responses stream and are not stored; system
instructions are sent as ``developer`` messages, OpenAI's name for the same
role; refused settings are dropped with a warning; no API organization or
project is sent.
"""
def __init__(self, **kwargs):
for name in _PLAN_REFUSED:
if kwargs.pop(name, None) is not None:
logger.warning("A ChatGPT plan does not accept %s; it is not sent.", name)
kwargs.setdefault("streaming", True)
kwargs.setdefault("store", False)
super().__init__(**kwargs)
# The sign-in names the account; an organization or project set for API
# keys (OPENAI_ORG_ID, OPENAI_PROJECT_ID) must not travel with it.
self.openai_organization = None
for client in (self.root_client, self.root_async_client):
client.organization = None
client.project = None
def _get_request_payload(self, input_, *, stop=None, **kwargs):
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
for item in payload.get("input") or []:
if isinstance(item, dict) and item.get("role") == "system":
item["role"] = "developer"
return payload
def _chatgpt_credential() -> Callable[[], str]:
from .chatgpt_auth import credential
return credential()
# Kwargs forwarded from user config to ChatOpenAI
_PASSTHROUGH_KWARGS = (
"timeout", "max_retries", "reasoning_effort", "temperature", "max_tokens",
@@ -204,6 +250,11 @@ class ProviderSpec:
placeholder_key: str = "EMPTY" # sent when no key is available (keyless local servers)
require_base_url: bool = False # error if no base_url is resolved (generic endpoint)
use_responses_api: bool = False # native OpenAI Responses API
# A provider the user signs in to: ``credential()`` returns the api_key callable
# (a fresh token per request) or raises when nobody is signed in. Its token goes
# only to its pinned base_url, and never alongside an API key.
credential: Callable[[], Callable[[], str]] | None = None
pinned_base_url: bool = False # refuse any other endpoint for this provider
# Single source of truth for the OpenAI-compatible provider family. Dual-region
@@ -227,6 +278,10 @@ OPENAI_COMPATIBLE_PROVIDERS: dict[str, ProviderSpec] = {
"ollama": ProviderSpec(base_url="http://localhost:11434/v1", base_url_env="OLLAMA_BASE_URL",
key_optional=True, placeholder_key="ollama",
chat_class=LocalCompatibleChatOpenAI),
# The person's ChatGPT plan, through their own sign-in.
"chatgpt": ProviderSpec(base_url="https://api.openai.com/v1", chat_class=ChatGPTPlanChatOpenAI,
use_responses_api=True, credential=_chatgpt_credential,
pinned_base_url=True),
# Generic endpoint: user supplies base_url; key optional (keyless local).
"openai_compatible": ProviderSpec(
require_base_url=True, key_optional=True, chat_class=LocalCompatibleChatOpenAI
@@ -234,6 +289,18 @@ OPENAI_COMPATIBLE_PROVIDERS: dict[str, ProviderSpec] = {
}
# Providers the user signs in to instead of setting an API key.
SIGNED_IN_PROVIDERS = tuple(key for key, spec in OPENAI_COMPATIBLE_PROVIDERS.items() if spec.credential)
def check_endpoint(provider: str, base_url: str | None) -> None:
"""Raise ValueError when a provider pinned to its own endpoint is pointed elsewhere."""
spec = OPENAI_COMPATIBLE_PROVIDERS.get(provider.lower())
if spec is not None and spec.pinned_base_url and base_url and base_url != spec.base_url:
raise ValueError(f"Provider '{provider}' only connects to {spec.base_url}; "
"unset backend_url / TRADINGAGENTS_LLM_BACKEND_URL for it.")
def is_openai_compatible(provider: str) -> bool:
"""Whether ``provider`` is served by the OpenAI-compatible registry."""
return provider.lower() in OPENAI_COMPATIBLE_PROVIDERS
@@ -289,6 +356,7 @@ class OpenAIClient(BaseLLMClient):
# OLLAMA_BASE_URL) > provider default. None means use the SDK default.
env_base_url = os.environ.get(spec.base_url_env) if spec.base_url_env else None
base_url = self.base_url or env_base_url or spec.base_url
check_endpoint(self.provider, base_url)
if spec.require_base_url and not base_url:
raise ValueError(
f"Provider '{self.provider}' requires a base_url. Set it via "
@@ -301,9 +369,14 @@ class OpenAIClient(BaseLLMClient):
# API key: required unless key_optional; keyless local servers get a
# placeholder. The env-var name is the single source in api_key_env.
# A signed-in provider sends its own credential, and only that.
api_key_env = get_api_key_env(self.provider)
api_key = os.environ.get(api_key_env) if api_key_env else None
if api_key:
if spec.credential is not None:
if "api_key" in self.kwargs:
raise ValueError(f"Provider '{self.provider}' uses its sign-in, not an api_key.")
llm_kwargs["api_key"] = spec.credential()
elif api_key:
llm_kwargs["api_key"] = api_key
elif spec.key_optional:
llm_kwargs["api_key"] = spec.placeholder_key
+1 -1
View File
@@ -7,7 +7,7 @@ from .model_catalog import get_known_models
# accepted without warning.
_ANY_MODEL_PROVIDERS = (
"ollama", "openrouter", "openai_compatible",
"mistral", "kimi", "groq", "nvidia", "bedrock",
"mistral", "kimi", "groq", "nvidia", "bedrock", "chatgpt",
)
VALID_MODELS = {
+15 -14
View File
@@ -109,7 +109,10 @@ class TradingMemoryLog:
return ""
same, cross = [], []
for e in reversed(entries):
# Most recent by decision date, then by the later write: a backfill writes
# older decisions last.
newest = sorted(range(len(entries)), key=lambda i: (entries[i]["date"], i), reverse=True)
for e in (entries[i] for i in newest):
if len(same) >= n_same and len(cross) >= n_cross:
break
if e["ticker"] == ticker and len(same) < n_same:
@@ -271,7 +274,7 @@ class TradingMemoryLog:
return tag + "]"
def _apply_rotation(self, blocks: list[str]) -> list[str]:
"""Drop oldest resolved blocks when their count exceeds max_entries.
"""Drop the oldest-dated resolved blocks when their count exceeds max_entries.
Pending blocks are always kept (they represent unprocessed work).
Returns ``blocks`` unchanged when rotation is disabled or under cap.
@@ -279,12 +282,12 @@ class TradingMemoryLog:
if not self._max_entries or self._max_entries <= 0:
return blocks
# Tag each block with (kept, is_resolved) by parsing tag-line markers.
# Tag each block with (block, is_resolved, decision date) from its tag line.
decisions = []
for block in blocks:
stripped = block.strip()
if not stripped:
decisions.append((block, False))
decisions.append((block, False, ""))
continue
tag_line = stripped.splitlines()[0].strip()
is_resolved = (
@@ -292,20 +295,18 @@ class TradingMemoryLog:
and tag_line.endswith("]")
and not tag_line.endswith("| pending]")
)
decisions.append((block, is_resolved))
decisions.append((block, is_resolved, tag_line[1:].split("|")[0].strip()))
resolved_count = sum(1 for _, r in decisions if r)
resolved_count = sum(1 for _, r, _ in decisions if r)
if resolved_count <= self._max_entries:
return blocks
to_drop = resolved_count - self._max_entries
kept: list[str] = []
for block, is_resolved in decisions:
if is_resolved and to_drop > 0:
to_drop -= 1
continue
kept.append(block)
return kept
# The oldest by decision date, then by position: a backfill writes older
# decisions last.
resolved = [i for i, (_, is_resolved, _) in enumerate(decisions) if is_resolved]
dated = sorted(resolved, key=lambda i: (decisions[i][2], i))
dropped = set(dated[:resolved_count - self._max_entries])
return [block for i, (block, _, _) in enumerate(decisions) if i not in dropped]
def _parse_entry(self, raw: str) -> dict | None:
lines = raw.strip().splitlines()
+1 -1
View File
@@ -21,7 +21,7 @@ class Reflector:
"""
return (
"You are a trading analyst reviewing your own past decision now that the outcome is known.\n"
f"The outcome covers {holding_days} trading days after the analysis date, "
f"The outcome covers {holding_days} trading days from the first close after the analysis date, "
"which may be shorter than the horizon the decision was written for.\n"
"Write exactly 2-4 sentences of plain prose (no bullets, no headers, no markdown).\n\n"
"Cover in order:\n"
+41 -14
View File
@@ -5,6 +5,8 @@ import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta
import pandas as pd
from tradingagents.dataflows.symbols import normalize_symbol
from tradingagents.dataflows.vendors.yahoo.market import get_closes
@@ -35,25 +37,34 @@ def resolve_benchmark(ticker: str, config: dict) -> str:
return benchmark_map.get("", "SPY")
def _now() -> pd.Timestamp:
return pd.Timestamp.now(tz="UTC")
def _by_day(closes):
"""Closes keyed by the calendar day of their bar, missing closes dropped.
"""Final closes keyed by the calendar day of their bar, missing closes dropped.
Yahoo stamps a daily bar at midnight in its market's time zone (UTC for a
coin, New York for SPY), so two series meet on their dates, not their
instants. A missing or non-positive close is no price at all, not a bar to
score on.
score on. Today's bar, in its market's time zone, is the session still
trading: its close is the live price, so it is not scored.
"""
closes = closes[closes > 0]
if getattr(closes.index, "tz", None) is not None:
closes = closes.tz_localize(None)
return closes
tz = getattr(closes.index, "tz", None)
if not closes.empty:
closes = closes[closes.index.date < _now().tz_convert(tz).date()]
return closes if tz is None else closes.tz_localize(None)
def fetch_returns(
ticker: str, trade_date: str, holding_days: int = 5,
benchmark: str = "SPY",
) -> tuple[float | None, float | None, int | None, str | None]:
"""Fetch raw and alpha return for ticker over holding_days from trade_date.
"""Fetch raw and alpha return for ticker over holding_days after trade_date.
The position is entered at the first close after ``trade_date``: a run dated
``trade_date`` reads news published after that day's close (#1518).
``benchmark`` is the index used as the alpha baseline (resolved by the
caller via ``resolve_benchmark``). Returns ``(raw_return, alpha_return,
@@ -64,16 +75,32 @@ def fetch_returns(
to report.
"""
start = datetime.strptime(trade_date, "%Y-%m-%d")
# holding_days counts trading days, so ask for the calendar span they
# occupy (about 7 for every 5) plus a week for holidays.
end = start + timedelta(days=round(holding_days * 7 / 5) + 7)
end_str = end.strftime("%Y-%m-%d")
# Closes for the instrument the analysis priced (XAUUSD -> GC=F, #984).
stock = _by_day(get_closes(ticker, trade_date, end_str))
today = _now().tz_localize(None).normalize()
# The entry and holding_days more sessions all close after the decision date
# and before today, with a day's slack for markets east of UTC: until then
# the window cannot have traded and no prices are asked for.
if (today - start).days < holding_days + 1:
return None, None, None, None
# holding_days counts trading days after the entry session, so ask for the
# calendar span they occupy (about 7 for every 5) plus a week for holidays.
end = start + timedelta(days=round((holding_days + 1) * 7 / 5) + 7)
# From a week earlier, so the benchmark has a close on or before entry.
bench_start = (start - timedelta(days=7)).strftime("%Y-%m-%d")
bench = _by_day(get_closes(benchmark, bench_start, end_str))
def closes(until):
# Closes for the instrument the analysis priced (XAUUSD -> GC=F, #984),
# from the first session after the decision date.
stock = _by_day(get_closes(ticker, trade_date, until.strftime("%Y-%m-%d")))
return stock[stock.index > start], _by_day(get_closes(benchmark, bench_start, until.strftime("%Y-%m-%d")))
def traded(stock, bench):
return len(stock) > holding_days and not bench.empty and bench.index[-1] >= stock.index[holding_days]
stock, bench = closes(end)
# A window that has passed without trading through spans a closure longer than
# the week allowed for holidays (a national holiday, a halt): ask through today.
if not traded(stock, bench) and end < today:
stock, bench = closes(today + timedelta(days=1))
# Require the full holding window to have traded. A rerun before it has
# leaves the entry pending to retry next run, rather than settling on a
+27 -10
View File
@@ -17,35 +17,52 @@ import hashlib
import json
from pathlib import Path
from pydantic import BaseModel, Field, ValidationError
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from tradingagents.dataflows.symbols import normalize_symbol
class Position(BaseModel):
model_config = ConfigDict(allow_inf_nan=False)
ticker: str = Field(description="Instrument symbol, e.g. AAPL")
quantity: float = Field(description="Signed units held; negative is short")
average_price: float | None = Field(default=None, description="Average entry price per unit")
class PortfolioContext(BaseModel):
model_config = ConfigDict(allow_inf_nan=False)
cash: float | None = Field(default=None, description="Free cash available")
currency: str | None = Field(default=None, description="Currency label for cash and prices")
positions: list[Position] = Field(default_factory=list)
def positions_in(self, ticker: str) -> list[Position]:
"""The holdings of the instrument the run prices for ``ticker``, under any
broker spelling (BTCUSDT, 700.HK). Holdings keep the spelling they were
given: a symbol can also name another instrument (WTI the stock)."""
symbol = normalize_symbol(ticker)
return [p for p in self.positions if normalize_symbol(p.ticker) == symbol]
def position_in(self, ticker: str) -> Position | None:
return next((p for p in self.positions if p.ticker.upper() == ticker.strip().upper()), None)
held = self.positions_in(ticker)
return held[0] if held else None
def render(self, ticker: str) -> str:
"""The portfolio block for the decision agents, led by the analyzed instrument."""
symbol = ticker.strip().upper()
held = self.position_in(symbol)
if held is None:
lines = [f"- No current position in {symbol}"]
else:
price = f", average price {held.average_price:,.2f}" if held.average_price is not None else ""
lines = [f"- Current position in {symbol}: {held.quantity:,.4g} units{price}"]
symbol = normalize_symbol(ticker)
held = self.positions_in(symbol)
lines = []
for position in held:
price = f", average price {position.average_price:,.2f}" if position.average_price is not None else ""
spelled = position.ticker.strip().upper()
listed = f" (listed as {position.ticker})" if spelled != symbol else ""
lines.append(f"- Current position in {symbol}: {position.quantity:,.4g} units{price}{listed}")
if not held:
lines.append(f"- No current position in {symbol}")
if self.cash is not None:
lines.append(f"- Cash available: {self.cash:,.2f}{' ' + self.currency if self.currency else ''}")
others = [p for p in self.positions if p is not held]
others = [p for p in self.positions if all(p is not h for h in held)]
if others:
lines.append("- Other positions: " + ", ".join(f"{p.ticker.upper()} {p.quantity:,.4g}" for p in others))
return "Portfolio at the analysis date:\n" + "\n".join(lines)
+16
View File
@@ -96,6 +96,8 @@ def write_report_tree(final_state: dict, ticker: str, save_path, settings: dict
``settings`` (``TradingAgentsGraph.run_settings()``) adds what produced the run
to the report's header. ``html`` also writes ``complete_report.html``, one
self-contained page of the same report (#1419).
Reusing a directory replaces the generated report files, removing sections
absent from this run and disabled HTML. Other files and directories are kept.
"""
save_path = Path(save_path)
save_path.mkdir(parents=True, exist_ok=True)
@@ -117,4 +119,18 @@ def write_report_tree(final_state: dict, ticker: str, save_path, settings: dict
(save_path / "complete_report.html").write_text(
render_report(ticker, final_state, settings, parts), encoding="utf-8"
)
else:
(save_path / "complete_report.html").unlink(missing_ok=True)
# Reconcile only the filenames owned by this writer; never clear the tree,
# since callers may keep notes or other artifacts alongside the reports.
current_files = {
(folder, filename)
for _heading, folder, written in parts
for _agent, filename, _text in written
}
for _heading, folder, agents in _SECTIONS:
for _agent, filename, _path in agents:
if (folder, filename) not in current_files:
(save_path / folder / filename).unlink(missing_ok=True)
return save_path / "complete_report.md"