Merge pull request #1536 from TauricResearch/v0.7.0

TradingAgents v0.7.0 release
This commit is contained in:
Tauric-Research
2026-10-16 16:38:41 -05:00
committed by GitHub
37 changed files with 2180 additions and 185 deletions
+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"
+25
View File
@@ -6,6 +6,31 @@ 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.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"
+8 -3
View File
@@ -32,15 +32,16 @@
## News
<!-- news:start -->
- [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.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.
@@ -183,6 +184,8 @@ export TYPESAFE_API_KEY=... # Jev social-post screening (optional)
For Azure OpenAI, copy `.env.enterprise.example` to `.env.enterprise` and fill in your credentials.
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`.
For local models, configure Ollama with `llm_provider: "ollama"`. The default endpoint is `http://localhost:11434/v1`; set `OLLAMA_BASE_URL` to point at a remote `ollama-serve`. Pull models with `ollama pull <name>`, and pick "Custom model ID" in the CLI for any model not listed by default.
@@ -213,6 +216,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 +248,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 +379,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
+103 -45
View File
@@ -1,16 +1,19 @@
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.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,6 +27,14 @@ ANALYST_CHOICES = [
CRYPTO_SUFFIXES = ("-USD", "-USDT", "-USDC", "-BTC", "-ETH")
# The style of every selection menu.
_MENU_STYLE = questionary.Style([
("selected", "fg:magenta noinherit"),
("highlighted", "fg:magenta noinherit"),
("pointer", "fg:magenta noinherit"),
])
def is_valid_ticker_input(value: str) -> bool:
"""Whether a ticker entry is acceptable (charset + length).
@@ -238,6 +249,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 +295,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 +310,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 +327,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 +345,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 +382,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 +439,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 +657,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 +700,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",
+424
View File
@@ -0,0 +1,424 @@
"""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 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
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:")
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",)
+50
View File
@@ -180,3 +180,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")
+337
View File
@@ -0,0 +1,337 @@
"""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
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
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 = []
+32 -16
View File
@@ -512,8 +512,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 +524,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 +549,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 +559,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 +1097,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 +1113,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 +1132,10 @@ 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
@@ -1130,7 +1146,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
+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()
+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):
+1 -1
View File
@@ -1,6 +1,6 @@
"""TradingAgents: multi-agent LLM financial trading framework."""
__version__ = "0.6.0"
__version__ = "0.7.0"
from dotenv import find_dotenv, load_dotenv
+1 -1
View File
@@ -119,7 +119,7 @@ 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."
)
+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": {
+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
+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 = {
+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"
+10 -5
View File
@@ -53,7 +53,10 @@ 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,13 +67,15 @@ 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)
# 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)
end_str = end.strftime("%Y-%m-%d")
# Closes for the instrument the analysis priced (XAUUSD -> GC=F, #984).
# 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, end_str))
stock = stock[stock.index > start]
# 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))