mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-11-04 08:32:45 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e44faafa0 | ||
|
|
bf7f4b55a3 | ||
|
|
d46c052278 | ||
|
|
ec4e2c8e5a | ||
|
|
a334a1f878 | ||
|
|
ae0424745e | ||
|
|
347974a000 | ||
|
|
b78f136ba3 | ||
|
|
caf6792bba | ||
|
|
ee06dcb961 | ||
|
|
21bc5d8fe7 | ||
|
|
2717033ad8 | ||
|
|
50bf38ee92 | ||
|
|
6c23d7551c | ||
|
|
3225a3c054 | ||
|
|
62a06edb38 | ||
|
|
28d32653d3 | ||
|
|
1c0e3e37fc | ||
|
|
eb4a486092 | ||
|
|
a5b0f217e3 | ||
|
|
2fe04cc771 | ||
|
|
e8f5503e9b | ||
|
|
f802271fb3 | ||
|
|
bc55fe3ed7 | ||
|
|
f5e3eae3a8 | ||
|
|
7142746bde | ||
|
|
36479ae1fa | ||
|
|
69042d6d33 | ||
|
|
071a6cea2a | ||
|
|
c3cdaeb4eb | ||
|
|
c9be8b6047 | ||
|
|
609c8e80a1 | ||
|
|
1394a3f72a |
+1
-1
@@ -1,6 +1,6 @@
|
||||
.git
|
||||
.venv
|
||||
.env
|
||||
**/.env*
|
||||
.claude
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -6,6 +6,62 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
Changes that need action when upgrading are listed under "Upgrading from" in their release.
|
||||
|
||||
## [0.7.1] — 2026-10-27
|
||||
|
||||
Decisions are scored only on final closes and settle across long market holidays, a model's unpadded date no longer reads filings made after the run date, and fixes to portfolios, prices, SEC statements, reports, Azure and Claude settings.
|
||||
|
||||
### Upgrading from 0.7.0
|
||||
|
||||
- Docker images no longer include `.env.enterprise`. For Azure OpenAI in Docker, put its keys in `.env`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A date a model passes without zero padding, such as `2024-7-5`, is read as that date. It could compare as later than a filing made after the run date, and SEC EDGAR then served that filing. (#1488)
|
||||
- A decision is no longer scored on the bar of a session still trading, and one made before a market closure longer than a week, such as a national holiday, settles once trading resumes instead of staying pending. (#1548, #1539)
|
||||
- A backtest reports the failures of its last settlement pass, and two started in the same second no longer share one run. (#1557, #1549)
|
||||
- A holding listed under a broker's spelling (`BTCUSDT`, `700.HK`) is matched to the instrument analyzed, and a portfolio number that is not finite is refused when the file loads. (#1556, #1509)
|
||||
- Prices keep their significant digits: a sub-cent close no longer prints as 0.00, and a currency pair keeps its pips. (#1567)
|
||||
- SEC EDGAR statements list one row per period, so a model no longer reads a figure against the wrong quarter across a long history, and a figure under one million no longer prints as 0. (#1517, #1589)
|
||||
- Saving a report to a folder used before removes the earlier run's sections. (#1566)
|
||||
- A date window that ends before it starts is sent back to the model instead of returning no data. (#1595)
|
||||
- A statement frequency other than annual or quarterly is sent back to the model instead of being served annual figures under that word. (#1593)
|
||||
- The memory's most recent lessons, and the entries rotation keeps, follow decision dates, so a backfill of older dates no longer displaces them. (#1603)
|
||||
- Prediction-market odds near 0% or 100% show their distance from it to two significant digits. (#1598)
|
||||
- Azure OpenAI receives `max_tokens` and a configured endpoint. (#1584, #1600)
|
||||
- Claude's effort setting is decided correctly for dated model IDs such as `claude-sonnet-4-20250514`. (#1585)
|
||||
- The CLI checks a ticker with the same rule as the run, so `XAUUSD+` is accepted and `..` is refused at the prompt. (#1604)
|
||||
- Docker images no longer include `.env` files from any folder.
|
||||
- Tests that failed on Windows and macOS for platform reasons are fixed. (#1506, #1485, #1541)
|
||||
|
||||
### Contributors
|
||||
|
||||
[@alanfate3](https://github.com/alanfate3), [@aranlucas](https://github.com/aranlucas), [@BichengWang](https://github.com/BichengWang), [@Bortlesboat](https://github.com/Bortlesboat), [@hoffman988](https://github.com/hoffman988), [@jhill-cmd](https://github.com/jhill-cmd), [@muhib-karim](https://github.com/muhib-karim), [@pei711](https://github.com/pei711), [@peiji1981](https://github.com/peiji1981), [@rudycelekli](https://github.com/rudycelekli), [@Shubham-Padkonde](https://github.com/Shubham-Padkonde), [@stevexyz2003](https://github.com/stevexyz2003), [@SuperMarioYL](https://github.com/SuperMarioYL), [@sytfly](https://github.com/sytfly), [@yuv009](https://github.com/yuv009).
|
||||
|
||||
## [0.7.0] — 2026-10-16
|
||||
|
||||
Runs on your ChatGPT plan, backtests scored from the first close after each decision, a note at start-up when a newer release is out, and a wheel attached to each GitHub release.
|
||||
|
||||
### Added
|
||||
|
||||
- **Your ChatGPT plan.** The `chatgpt` provider runs TradingAgents on a ChatGPT Plus or Pro plan instead of an API key, through OpenAI's sign-in for open-source apps. Choose "Subscription" in the CLI's provider menu; for unattended runs, Docker and Python (`llm_provider="chatgpt"`), sign in once with `tradingagents login chatgpt`. `tradingagents logout chatgpt` revokes the sign-in. The CLI lists the plan's models, and reasoning effort applies as it does for OpenAI. (#1194, #1475)
|
||||
- **What's new at start-up.** The interactive CLI mentions a newer release, with the command that upgrades this install when it can tell how TradingAgents was installed, and notices from Tauric Research for the running version. It checks at most once a day in the background, so a run never waits on it; runs without a terminal never check, and `TRADINGAGENTS_NO_UPDATE_CHECK=1` turns it off.
|
||||
- **Release downloads.** Each GitHub release carries the source archive and a wheel, so a version installs with `pip install` from its release page without cloning.
|
||||
|
||||
### Upgrading from 0.6.0
|
||||
|
||||
- `cryptography` 42 and `packaging` 24 or later are installed.
|
||||
- Decisions are now scored from the first close after their date. Outcomes already settled in a memory log keep the earlier entry, the close of the decision date, so a backtest resumed with `run_id` mixes the two; start a new run to compare like with like.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A decision is no longer scored against news it could not have traded on: a run dated *d* reads news published after the close on *d*, so the memory log and backtests enter it at the first close after *d*, not the close of *d*. (#1518)
|
||||
- A run without a terminal, such as a scheduled job or CI, no longer contacts the announcements server before it starts.
|
||||
- A model tier on another provider keeps its configured reasoning effort, thinking level or Claude effort when the main provider has none. (#1440)
|
||||
|
||||
### Contributors
|
||||
|
||||
[@LeonWTW](https://github.com/LeonWTW), [@OleksanderSS](https://github.com/OleksanderSS), [@sovorn-c](https://github.com/sovorn-c).
|
||||
|
||||
## [0.6.0] — 2026-10-03
|
||||
|
||||
HTML reports, a provider per model tier, past decisions settled for every ticker while the analysts work, and company news read while Yahoo's news feed is down.
|
||||
|
||||
+3
-1
@@ -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"
|
||||
|
||||
@@ -32,15 +32,17 @@
|
||||
## News
|
||||
|
||||
<!-- news:start -->
|
||||
- [2026-10] **TradingAgents v0.7.1** released with decisions scored only on final closes and settled across long market holidays, a fix for model dates that could read filings made after the run date, and fixes to portfolios, prices, SEC statements and reports.
|
||||
- [2026-10] **TradingAgents v0.7.0** released with runs on your own ChatGPT Plus or Pro plan, backtests scored from the first close after each decision, a note at start-up when a newer release is out, and a wheel attached to each GitHub release.
|
||||
- [2026-10] **TradingAgents v0.6.0** released with reports saved as one HTML page, a provider per model tier so the managers and analysts can run on different models, past decisions settled for every ticker while the analysts work, and company news read from Yahoo search while Yahoo's news feed is down.
|
||||
- [2026-09] **TradingAgents v0.5.2** released with parallel analysts for a faster analysis, a CLI that runs without prompts from flags such as `--ticker` and `--date`, the run's settings recorded in every report, and backtests that see only data published by each analysis date.
|
||||
- [2026-09] **TradingAgents v0.5.1** released with a package layout organised by what each module holds (import paths moved), optional Jev screening of social posts, GPT-6 Sol and Luna as the default models, and fixes to run isolation and SEC EDGAR statements.
|
||||
|
||||
Full release notes are in [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
<details>
|
||||
<summary>Earlier news</summary>
|
||||
|
||||
- [2026-09] **TradingAgents v0.5.2** released with parallel analysts for a faster analysis, a CLI that runs without prompts from flags such as `--ticker` and `--date`, the run's settings recorded in every report, and backtests that see only data published by each analysis date.
|
||||
- [2026-09] **TradingAgents v0.5.1** released with a package layout organised by what each module holds (import paths moved), optional Jev screening of social posts, GPT-6 Sol and Luna as the default models, and fixes to run isolation and SEC EDGAR statements.
|
||||
- [2026-09] **TradingAgents v0.5.0** released with point-in-time integrity across every dated path, SEC EDGAR fundamentals served as filed, backtesting over a ticker and date grid, portfolio-aware runs, and current model lineups across every provider.
|
||||
- [2026-08] **TradingAgents v0.4.0** released with look-ahead / point-in-time fixes across FRED macro, social sentiment, and the decision-log memory; clearer decision signals; working CLI checkpoint resume; Trader price grounding; and the GPT-5.6 and GLM-5.3 models.
|
||||
- [2026-07] **TradingAgents v0.3.1** released with correctness and stability fixes: Alpha Vantage look-ahead filtering, graph-router crash-safety, graph-shape-aware checkpoint resume, working crypto sentiment sources, a configurable LLM retry budget, Bedrock API-key auth, and Claude Sonnet 5 / Fable 5 support.
|
||||
@@ -181,7 +183,9 @@ export ALPHA_VANTAGE_API_KEY=... # Alpha Vantage
|
||||
export TYPESAFE_API_KEY=... # Jev social-post screening (optional)
|
||||
```
|
||||
|
||||
For Azure OpenAI, copy `.env.enterprise.example` to `.env.enterprise` and fill in your credentials.
|
||||
For Azure OpenAI, copy `.env.enterprise.example` to `.env.enterprise` and fill in your credentials. With Docker, put these keys in `.env` instead: the image does not include `.env` files.
|
||||
|
||||
To run on your ChatGPT Plus or Pro plan instead of an API key, choose "Subscription" in the CLI's provider menu, or set `llm_provider: "chatgpt"`. Sign in once with `tradingagents login chatgpt`: it prints an address to open in your browser, which returns to this machine on `127.0.0.1`. When the browser runs on another machine, or TradingAgents runs in Docker (`docker compose run --rm tradingagents login chatgpt`), paste the address the browser ends up on. The sign-in is kept in `~/.tradingagents/chatgpt-auth.json`, and `tradingagents logout chatgpt` revokes it. Runs count toward your plan's usage limits, and the plan does not accept `temperature` or `max_tokens`.
|
||||
|
||||
For AWS Bedrock, install the extra with `pip install ".[bedrock]"`, set `llm_provider: "bedrock"`, configure AWS credentials (environment variables, `~/.aws/credentials`, or an IAM role) and `AWS_DEFAULT_REGION`, and use a Bedrock model ID, e.g. `us.anthropic.claude-opus-5-5`.
|
||||
|
||||
@@ -213,6 +217,8 @@ tradingagents --ticker NVDA --date 2026-09-23 --analysts market,news,fundamental
|
||||
```
|
||||
Each flag skips only its own question. Run without a terminal, a missing answer stops the run before it starts and names the flag or variable to set.
|
||||
|
||||
At start-up the interactive CLI mentions a newer release and notices from Tauric Research. It checks at most once a day, in the background, and sends nothing about your runs; set `TRADINGAGENTS_NO_UPDATE_CHECK=1` to turn it off. Runs without a terminal never check.
|
||||
|
||||
A saved report also includes `complete_report.html`, the report as one page with its sections listed beside the text, for reading in a browser, on a phone or in print. Answering the save question at the prompt also asks about the page and can open it in your browser; `--no-html` skips it, and so does `ta.save_reports(state, "NVDA", html=False)` from Python.
|
||||
|
||||
### Markets and tickers
|
||||
@@ -243,7 +249,7 @@ An interface will appear showing results as they load, letting you track the age
|
||||
|
||||
### Implementation Details
|
||||
|
||||
We built TradingAgents with LangGraph to ensure flexibility and modularity. The framework supports multiple LLM providers: OpenAI, Google, Anthropic, xAI, DeepSeek, Qwen (Alibaba DashScope, international and China endpoints), GLM (Zhipu), MiniMax (global + China), OpenRouter, Ollama for local models, and Azure OpenAI for enterprise.
|
||||
We built TradingAgents with LangGraph to ensure flexibility and modularity. The framework supports multiple LLM providers: OpenAI, Google, Anthropic, xAI, DeepSeek, Qwen (Alibaba DashScope, international and China endpoints), GLM (Zhipu), MiniMax (global + China), OpenRouter, Ollama for local models, Azure OpenAI for enterprise, and your own ChatGPT plan.
|
||||
|
||||
### Python Usage
|
||||
|
||||
@@ -374,7 +380,7 @@ From the CLI:
|
||||
tradingagents backtest NVDA,AAPL --start 2026-06-01 --end 2026-08-01 --every 7
|
||||
```
|
||||
|
||||
Each cell is scored on realized alpha against the instrument's regional benchmark, grouped by rating. Your own memory log is never written to, and re-running the same grid with `run_id=result.run_id` skips the cells that already ran, so an interrupted sweep continues where it stopped.
|
||||
Each cell is scored on realized alpha against the instrument's regional benchmark, grouped by rating. A decision is entered at the first close after its date, because a run reads news published after that day's close. Your own memory log is never written to, and re-running the same grid with `run_id=result.run_id` skips the cells that already ran, so an interrupted sweep continues where it stopped.
|
||||
|
||||
## Reproducibility
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -21,7 +21,7 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
from cli.models import AnalystType, AssetType
|
||||
from cli.prompts import _llm_provider_table, filter_analysts_for_asset_type
|
||||
from cli.prompts import LISTED_LIVE, _llm_provider_table, filter_analysts_for_asset_type
|
||||
from tradingagents.dataflows.files import replace_file
|
||||
from tradingagents.llm_clients.model_catalog import get_model_options
|
||||
|
||||
@@ -86,6 +86,8 @@ def sanitize(prefs: dict, asset_type) -> dict:
|
||||
offered = {model for _, model in get_model_options(base, mode)}
|
||||
except KeyError:
|
||||
continue
|
||||
if prefs.get(field) in offered:
|
||||
# Models listed live are not in the catalog, so any model ID is kept.
|
||||
listed_live = base in LISTED_LIVE and isinstance(prefs.get(field), str)
|
||||
if prefs.get(field) in offered or listed_live:
|
||||
kept[field] = prefs[field]
|
||||
return kept
|
||||
|
||||
+116
-52
@@ -1,16 +1,20 @@
|
||||
import datetime
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import questionary
|
||||
import typer
|
||||
from dotenv import find_dotenv, set_key
|
||||
from rich.markup import escape
|
||||
|
||||
from cli.display import console
|
||||
from cli.models import AnalystType, AssetType
|
||||
from tradingagents.dataflows.symbols import safe_ticker_component
|
||||
from tradingagents.llm_clients.api_key_env import get_api_key_env
|
||||
from tradingagents.llm_clients.model_catalog import get_model_options
|
||||
from tradingagents.llm_clients.openai_client import OPENAI_COMPATIBLE_PROVIDERS, SIGNED_IN_PROVIDERS
|
||||
|
||||
TICKER_INPUT_EXAMPLES = "SPY, 0700.HK, BTC-USD"
|
||||
|
||||
@@ -24,15 +28,28 @@ ANALYST_CHOICES = [
|
||||
CRYPTO_SUFFIXES = ("-USD", "-USDT", "-USDC", "-BTC", "-ETH")
|
||||
|
||||
|
||||
def is_valid_ticker_input(value: str) -> bool:
|
||||
"""Whether a ticker entry is acceptable (charset + length).
|
||||
# The style of every selection menu.
|
||||
_MENU_STYLE = questionary.Style([
|
||||
("selected", "fg:magenta noinherit"),
|
||||
("highlighted", "fg:magenta noinherit"),
|
||||
("pointer", "fg:magenta noinherit"),
|
||||
])
|
||||
|
||||
Allows the characters Yahoo symbols use, including ``=`` for futures/forex
|
||||
like ``GC=F`` and ``EURUSD=X`` (#980), and ``^`` for indices. Empty input is
|
||||
allowed (it defaults to SPY downstream).
|
||||
|
||||
def is_valid_ticker_input(value: str) -> bool:
|
||||
"""Whether a ticker entry names a symbol the run can use.
|
||||
|
||||
The symbol it resolves to (``XAUUSD+`` -> ``GC=F``) must pass the data
|
||||
layer's own check, so the prompt refuses exactly what the run would.
|
||||
Empty input is allowed (it defaults to SPY downstream).
|
||||
"""
|
||||
v = value.strip()
|
||||
return not v or (all(ch.isalnum() or ch in "._-^=" for ch in v) and len(v) <= 32)
|
||||
if not value.strip():
|
||||
return True
|
||||
try:
|
||||
safe_ticker_component(normalize_ticker_symbol(value))
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_ticker() -> str:
|
||||
@@ -238,6 +255,35 @@ def _fetch_openrouter_models() -> list[tuple[str, str]]:
|
||||
return []
|
||||
|
||||
|
||||
def select_chatgpt_model(mode: str, default=None) -> str:
|
||||
"""Select one of the ChatGPT plan's models, or enter a model ID."""
|
||||
from tradingagents.llm_clients import chatgpt_auth
|
||||
try:
|
||||
models = chatgpt_auth.list_models()
|
||||
except Exception as e:
|
||||
console.print(f"\n[yellow]Could not list your ChatGPT plan's models: {escape(str(e))}[/yellow]")
|
||||
models = []
|
||||
return _select_listed_model(f"Select Your [{mode.title()}-Thinking] ChatGPT Plan Model:",
|
||||
models, default, _prompt_custom_model_id)
|
||||
|
||||
|
||||
def _select_listed_model(title: str, models, default, custom) -> str:
|
||||
"""Select from ``models`` ((name, model ID) pairs, listed live) or enter one with ``custom``."""
|
||||
choices = [questionary.Choice(name, value=mid) for name, mid in models]
|
||||
choices.append(questionary.Choice("Custom model ID", value="custom"))
|
||||
choice = questionary.select(
|
||||
title,
|
||||
choices=choices,
|
||||
default=default if any(c.value == default for c in choices) else None,
|
||||
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
|
||||
style=_MENU_STYLE,
|
||||
).ask()
|
||||
if choice is None:
|
||||
console.print("\n[red]No model selected. Exiting...[/red]")
|
||||
exit(1)
|
||||
return custom() if choice == "custom" else choice
|
||||
|
||||
|
||||
def _require_text(message: str, hint: str) -> str:
|
||||
"""Prompt for a required value; exit cleanly if the user cancels.
|
||||
|
||||
@@ -255,7 +301,7 @@ def _require_text(message: str, hint: str) -> str:
|
||||
return response.strip()
|
||||
|
||||
|
||||
def select_openrouter_model(mode: str) -> str:
|
||||
def select_openrouter_model(mode: str, default=None) -> str:
|
||||
"""Select an OpenRouter model from the newest available, or enter a custom ID.
|
||||
|
||||
``mode`` ("quick"/"deep") labels the prompt so the two consecutive
|
||||
@@ -270,30 +316,14 @@ def select_openrouter_model(mode: str) -> str:
|
||||
and mid.split("/", 1)[0] in _OPENROUTER_MAINSTREAM
|
||||
]
|
||||
top = (mainstream or models)[:5]
|
||||
return _select_listed_model(
|
||||
f"Select Your [{mode.title()}-Thinking] OpenRouter Model (latest available):", top, default,
|
||||
lambda: _require_text("Enter OpenRouter model ID (e.g. google/gemma-4-26b-a4b-it):",
|
||||
"Please enter a model ID."))
|
||||
|
||||
choices = [questionary.Choice(name, value=mid) for name, mid in top]
|
||||
choices.append(questionary.Choice("Custom model ID", value="custom"))
|
||||
|
||||
choice = questionary.select(
|
||||
f"Select Your [{mode.title()}-Thinking] OpenRouter Model (latest available):",
|
||||
choices=choices,
|
||||
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
|
||||
style=questionary.Style([
|
||||
("selected", "fg:magenta noinherit"),
|
||||
("highlighted", "fg:magenta noinherit"),
|
||||
("pointer", "fg:magenta noinherit"),
|
||||
]),
|
||||
).ask()
|
||||
|
||||
if choice is None:
|
||||
console.print("\n[red]No model selected. Exiting...[/red]")
|
||||
exit(1)
|
||||
if choice == "custom":
|
||||
return _require_text(
|
||||
"Enter OpenRouter model ID (e.g. google/gemma-4-26b-a4b-it):",
|
||||
"Please enter a model ID.",
|
||||
)
|
||||
return choice
|
||||
# Providers whose models are listed live, with the menu that offers them.
|
||||
LISTED_LIVE = {"openrouter": select_openrouter_model, "chatgpt": select_chatgpt_model}
|
||||
|
||||
|
||||
def _prompt_custom_model_id() -> str:
|
||||
@@ -303,8 +333,9 @@ def _prompt_custom_model_id() -> str:
|
||||
|
||||
def _select_model(provider: str, mode: str, default=None) -> str:
|
||||
"""Select a model for the given provider and mode (quick/deep)."""
|
||||
if provider.lower() == "openrouter":
|
||||
return select_openrouter_model(mode)
|
||||
listed = LISTED_LIVE.get(provider.lower())
|
||||
if listed is not None:
|
||||
return listed(mode, default)
|
||||
|
||||
if provider.lower() == "azure":
|
||||
return _require_text(
|
||||
@@ -320,13 +351,7 @@ def _select_model(provider: str, mode: str, default=None) -> str:
|
||||
],
|
||||
default=_matching_choice(get_model_options(provider, mode), default),
|
||||
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
|
||||
style=questionary.Style(
|
||||
[
|
||||
("selected", "fg:magenta noinherit"),
|
||||
("highlighted", "fg:magenta noinherit"),
|
||||
("pointer", "fg:magenta noinherit"),
|
||||
]
|
||||
),
|
||||
style=_MENU_STYLE,
|
||||
).ask()
|
||||
|
||||
if choice is None:
|
||||
@@ -363,6 +388,7 @@ def _llm_provider_table() -> list[tuple[str, str, str | None]]:
|
||||
("OpenAI", "openai", "https://api.openai.com/v1"),
|
||||
("Google", "google", None),
|
||||
("Anthropic", "anthropic", "https://api.anthropic.com/"),
|
||||
("ChatGPT (Plus or Pro plan)", "chatgpt", "https://api.openai.com/v1"),
|
||||
("xAI", "xai", "https://api.x.ai/v1"),
|
||||
("DeepSeek", "deepseek", "https://api.deepseek.com"),
|
||||
("Qwen", "qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"),
|
||||
@@ -419,37 +445,50 @@ def prompt_openai_compatible_url(default=None) -> str:
|
||||
return url.strip()
|
||||
|
||||
|
||||
# The menu entry that groups the providers used through a personal plan.
|
||||
_SUBSCRIPTION = ("subscription", None)
|
||||
|
||||
|
||||
def select_llm_provider(default=None) -> tuple[str, str | None]:
|
||||
"""Select the LLM provider and its API endpoint."""
|
||||
PROVIDERS = _llm_provider_table()
|
||||
# A region-specific key (qwen-cn) is chosen in a later prompt; the menu
|
||||
# lists the base provider.
|
||||
base = (default or "").split("-cn")[0]
|
||||
preselected = next(
|
||||
choices = []
|
||||
for display, provider_key, url in PROVIDERS:
|
||||
if provider_key in SIGNED_IN_PROVIDERS:
|
||||
continue
|
||||
choices.append(questionary.Choice(display, value=(provider_key, url)))
|
||||
if provider_key == "anthropic":
|
||||
choices.append(questionary.Choice("Subscription (use your ChatGPT plan)", value=_SUBSCRIPTION))
|
||||
preselected = _SUBSCRIPTION if base in SIGNED_IN_PROVIDERS else next(
|
||||
((key, url) for _, key, url in PROVIDERS if key == base), None
|
||||
)
|
||||
|
||||
choice = questionary.select(
|
||||
"Select your LLM Provider:",
|
||||
choices=[
|
||||
questionary.Choice(display, value=(provider_key, url))
|
||||
for display, provider_key, url in PROVIDERS
|
||||
],
|
||||
choices=choices,
|
||||
default=preselected,
|
||||
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
|
||||
style=questionary.Style(
|
||||
[
|
||||
("selected", "fg:magenta noinherit"),
|
||||
("highlighted", "fg:magenta noinherit"),
|
||||
("pointer", "fg:magenta noinherit"),
|
||||
]
|
||||
),
|
||||
style=_MENU_STYLE,
|
||||
).ask()
|
||||
|
||||
if choice is None:
|
||||
console.print("\n[red]No LLM provider selected. Exiting...[/red]")
|
||||
exit(1)
|
||||
|
||||
if choice == _SUBSCRIPTION:
|
||||
choice = questionary.select(
|
||||
"Select your subscription:",
|
||||
choices=[questionary.Choice(display, value=(key, url))
|
||||
for display, key, url in PROVIDERS if key in SIGNED_IN_PROVIDERS],
|
||||
style=_MENU_STYLE,
|
||||
).ask()
|
||||
if choice is None:
|
||||
console.print("\n[red]No subscription selected. Exiting...[/red]")
|
||||
exit(1)
|
||||
|
||||
provider, url = choice
|
||||
return provider, url
|
||||
|
||||
@@ -624,6 +663,32 @@ def confirm_ollama_endpoint(url: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def ensure_signed_in(provider: str) -> None:
|
||||
"""Make sure a plan provider's sign-in works before the run, offering to sign in now.
|
||||
|
||||
Signing in runs as its own process (``tradingagents login chatgpt``), so its
|
||||
read of a pasted address ends with it and never takes a later answer."""
|
||||
if provider.lower() not in SIGNED_IN_PROVIDERS:
|
||||
return
|
||||
from tradingagents.llm_clients import chatgpt_auth # the one signed-in provider
|
||||
|
||||
try:
|
||||
chatgpt_auth.access_token()
|
||||
except chatgpt_auth.ChatGPTUnreachable as exc:
|
||||
console.print(f"[red]{escape(str(exc))}[/red]")
|
||||
raise typer.Exit(code=1) from None
|
||||
except chatgpt_auth.ChatGPTAuthError as exc:
|
||||
if not sys.stdin.isatty():
|
||||
console.print(f"[red]{escape(str(exc))}[/red]")
|
||||
raise typer.Exit(code=1) from None
|
||||
console.print(f"\n[yellow]{escape(str(exc))}[/yellow]")
|
||||
# -P keeps a cli/ package in the working directory from shadowing ours.
|
||||
login = [sys.executable, "-P", "-m", "cli.main", "login", "chatgpt"]
|
||||
if subprocess.run(login).returncode != 0:
|
||||
raise typer.Exit(code=1) from None
|
||||
console.print(f"[green]✓ Using your ChatGPT plan:[/green] {escape(chatgpt_auth.signed_in_as() or '')}")
|
||||
|
||||
|
||||
def ensure_api_key(provider: str) -> str | None:
|
||||
"""Make sure the API key for `provider` is available in the environment.
|
||||
|
||||
@@ -641,7 +706,6 @@ def ensure_api_key(provider: str) -> str | None:
|
||||
|
||||
# Key-optional providers (generic OpenAI-compatible / local servers) read the
|
||||
# key when present but must never force an interactive prompt.
|
||||
from tradingagents.llm_clients.openai_client import OPENAI_COMPATIBLE_PROVIDERS
|
||||
spec = OPENAI_COMPATIBLE_PROVIDERS.get(provider.lower())
|
||||
if spec is not None and spec.key_optional:
|
||||
return os.environ.get(env_var)
|
||||
|
||||
+5
-4
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -28,6 +28,8 @@ class TestEffortGate:
|
||||
"claude-haiku-4-5", "claude-haiku-5-0", "claude-haiku-4-7-preview",
|
||||
# Sonnet 4.5 (and earlier) 400 on effort — only Sonnet 4.6+ supports it.
|
||||
"claude-sonnet-4-5", "claude-sonnet-4-0",
|
||||
# A dated snapshot's date is not its minor version (#1585).
|
||||
"claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-sonnet-4-5-20250929",
|
||||
],
|
||||
)
|
||||
def test_unsupported_models_do_not_receive_effort(self, monkeypatch, model):
|
||||
@@ -39,7 +41,7 @@ class TestEffortGate:
|
||||
"model",
|
||||
[
|
||||
"claude-opus-4-5", "claude-opus-4-6", "claude-opus-4-7",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-sonnet-4-6", "claude-opus-4-5-20251101",
|
||||
],
|
||||
)
|
||||
def test_current_opus_and_sonnet_receive_effort(self, monkeypatch, model):
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Azure OpenAI takes the run's settings like every other provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tradingagents.llm_clients.factory import create_llm_client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _azure_env(monkeypatch):
|
||||
monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-key")
|
||||
monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://from-env.openai.azure.com/")
|
||||
monkeypatch.setenv("OPENAI_API_VERSION", "2025-03-01-preview")
|
||||
monkeypatch.delenv("AZURE_OPENAI_DEPLOYMENT_NAME", raising=False)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_the_output_token_cap_reaches_the_deployment():
|
||||
llm = create_llm_client("azure", "gpt-5.5", max_tokens=1234).get_llm()
|
||||
assert llm.max_tokens == 1234
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_an_explicit_endpoint_wins_over_the_environment():
|
||||
llm = create_llm_client("azure", "gpt-5.5", base_url="https://deep.openai.azure.com/").get_llm()
|
||||
assert llm.azure_endpoint == "https://deep.openai.azure.com/"
|
||||
assert create_llm_client("azure", "gpt-5.5").get_llm().azure_endpoint == "https://from-env.openai.azure.com/"
|
||||
@@ -12,6 +12,7 @@ import pytest
|
||||
|
||||
from tradingagents.backtest import iter_grid, run_backtest, summarize
|
||||
from tradingagents.memory import TradingMemoryLog
|
||||
from tradingagents.memory.settlement import Settlement
|
||||
|
||||
DECISION = "Rating: Buy\n\nbuy it"
|
||||
|
||||
@@ -58,6 +59,7 @@ class _FakeGraph:
|
||||
|
||||
def settle_pending(self, ticker):
|
||||
self.settled.append(ticker)
|
||||
return Settlement()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -153,6 +155,23 @@ def test_the_analyst_set_under_test_is_the_one_that_runs(tmp_path):
|
||||
assert _FakeGraph.instances[-1].analysts == ["market", "news"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_sweeps_started_in_the_same_second_get_their_own_runs(tmp_path, monkeypatch):
|
||||
import tradingagents.backtest as backtest
|
||||
|
||||
class _Frozen(backtest.datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return cls(2026, 1, 20, 9, 30, 0)
|
||||
|
||||
monkeypatch.setattr(backtest, "datetime", _Frozen)
|
||||
first = run_backtest(["NVDA"], ["2026-01-05"], _config(tmp_path))
|
||||
second = run_backtest(["NVDA"], ["2026-01-05"], _config(tmp_path))
|
||||
|
||||
assert first.run_id == "20260120_093000" and second.run_id == "20260120_093000_2"
|
||||
assert first.cells_run == second.cells_run == 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_run_id_cannot_escape_the_results_directory(tmp_path):
|
||||
"""run_id becomes a path segment, so it is validated like a ticker is."""
|
||||
@@ -172,6 +191,7 @@ def test_a_failed_settlement_does_not_lose_the_remaining_tickers(tmp_path, monke
|
||||
if ticker == "NVDA":
|
||||
raise RuntimeError("reflector timed out")
|
||||
settled.append(ticker)
|
||||
return Settlement()
|
||||
|
||||
monkeypatch.setattr(_FakeGraph, "settle_pending", _settle, raising=False)
|
||||
result = run_backtest(["NVDA", "AAPL"], ["2026-01-05"], _config(tmp_path))
|
||||
@@ -181,6 +201,46 @@ def test_a_failed_settlement_does_not_lose_the_remaining_tickers(tmp_path, monke
|
||||
assert result.settlement_failures == [("NVDA", "reflector timed out")]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("failure", ["prices", "reflection"])
|
||||
def test_handled_settlement_failures_reach_the_backtest_result(tmp_path, monkeypatch, failure):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from tradingagents.memory import settlement
|
||||
|
||||
def returns(ticker, *args, **kwargs):
|
||||
if ticker == "AAPL":
|
||||
return None, None, None, None # an incomplete window is not a failure
|
||||
if failure == "prices":
|
||||
raise RuntimeError("vendor unavailable")
|
||||
return 0.1, 0.02, 5, "2026-02-01"
|
||||
|
||||
def reflect(**kwargs):
|
||||
raise RuntimeError("reflector timed out")
|
||||
|
||||
def settle(self, ticker):
|
||||
self.settled.append(ticker)
|
||||
return settlement.settle_pending(
|
||||
ticker, self.memory_log, SimpleNamespace(reflect_on_final_decision=reflect), self.config,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(settlement, "fetch_returns", returns)
|
||||
monkeypatch.setattr(_FakeGraph, "settle_pending", settle)
|
||||
result = run_backtest(
|
||||
["NVDA", "AAPL"], ["2026-01-05", "2026-01-12"], _config(tmp_path),
|
||||
)
|
||||
|
||||
reason = ("prices unavailable: vendor unavailable" if failure == "prices"
|
||||
else "reflection failed: reflector timed out")
|
||||
assert result.settlement_failures == [
|
||||
("NVDA", f"2026-01-05: {reason}"),
|
||||
("NVDA", f"2026-01-12: {reason}"),
|
||||
]
|
||||
assert _FakeGraph.instances[-1].settled == ["NVDA", "AAPL"]
|
||||
assert result.cells_run == 4 and not result.failures
|
||||
assert len(_FakeGraph.instances[-1].memory_log.get_pending_entries()) == 4
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pending_note_appears_only_when_something_is_pending(tmp_path):
|
||||
settled = [("NVDA", "2026-01-05", DECISION, (0.1, 0.05))]
|
||||
|
||||
@@ -106,7 +106,7 @@ def test_on_windows_the_lock_is_waited_for_while_another_writer_holds_it(tmp_pat
|
||||
import errno
|
||||
import sys
|
||||
|
||||
fake, calls = _fake_msvcrt([OSError(errno.EDEADLOCK, "held"), None])
|
||||
fake, calls = _fake_msvcrt([OSError(errno.EDEADLK, "held"), None])
|
||||
monkeypatch.setitem(sys.modules, "msvcrt", fake)
|
||||
|
||||
with open(tmp_path / "log.md.lock", "a+b") as handle, files._hold_windows(handle):
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
"""Sign in with ChatGPT: the sign-in is checked, kept private, renewed once, and revoked.
|
||||
|
||||
OpenAI is replaced by a fake that signs ID tokens with a key made here, so every
|
||||
check of the sign-in (signature, issuer, audience, nonce, plan scope) runs for real.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
|
||||
from tradingagents.llm_clients import chatgpt_auth as auth
|
||||
|
||||
# The suite refuses all connections; the loopback test lets 127.0.0.1 through only.
|
||||
_CONNECT, _GETADDRINFO = socket.socket.connect, socket.getaddrinfo
|
||||
KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
CLIENT = "oaiapp_test"
|
||||
SUBJECT = "user-123"
|
||||
|
||||
|
||||
def _b64(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def _id_token(key=KEY, **claims) -> str:
|
||||
payload = {"iss": auth.ISSUER, "aud": CLIENT, "sub": SUBJECT, "email": "a@example.com",
|
||||
"exp": time.time() + 3600, **claims}
|
||||
head = _b64(json.dumps({"alg": "RS256", "kid": "k1"}).encode())
|
||||
body = _b64(json.dumps(payload).encode())
|
||||
signature = key.sign(f"{head}.{body}".encode(), padding.PKCS1v15(), hashes.SHA256())
|
||||
return f"{head}.{body}.{_b64(signature)}"
|
||||
|
||||
|
||||
def _jwks():
|
||||
numbers = KEY.public_key().public_numbers()
|
||||
return {"keys": [{"kid": "k1", "kty": "RSA", "e": _b64(numbers.e.to_bytes(3, "big")),
|
||||
"n": _b64(numbers.n.to_bytes(256, "big"))}]}
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, body, status=200):
|
||||
self.body, self.status_code = body, status
|
||||
|
||||
def json(self):
|
||||
return self.body
|
||||
|
||||
|
||||
class FakeOpenAI:
|
||||
"""Answers the JWKS, token and revoke endpoints and records what was sent."""
|
||||
|
||||
def __init__(self, token_body=None, status=200):
|
||||
self.posts = []
|
||||
self.token_body = token_body
|
||||
self.status = status
|
||||
self.lock = threading.Lock()
|
||||
self.keys_reachable = True
|
||||
self.revoke_status = 200
|
||||
self.post_error = None
|
||||
|
||||
def get(self, url, timeout):
|
||||
assert url == auth.JWKS_URL
|
||||
if not self.keys_reachable:
|
||||
raise auth.requests.ConnectionError("down")
|
||||
return _Response(_jwks())
|
||||
|
||||
def post(self, url, timeout, data):
|
||||
with self.lock:
|
||||
self.posts.append((url, data))
|
||||
if url == auth.REVOKE_URL:
|
||||
return _Response({}, self.revoke_status)
|
||||
if self.post_error is not None:
|
||||
raise self.post_error
|
||||
time.sleep(0.05) # let a racing refresher reach the lock
|
||||
body = self.token_body(data) if callable(self.token_body) else self.token_body
|
||||
return _Response(body, self.status)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def home(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(auth, "AUTH_FILE", tmp_path / "home" / "chatgpt-auth.json")
|
||||
monkeypatch.setattr(auth, "_retry_after", 0.0)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def openai(monkeypatch):
|
||||
fake = FakeOpenAI()
|
||||
monkeypatch.setattr(auth.requests, "get", fake.get)
|
||||
monkeypatch.setattr(auth.requests, "post", fake.post)
|
||||
return fake
|
||||
|
||||
|
||||
def _signed_in(expires_at, earliest=None, refresh_token="r1"):
|
||||
auth.AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
auth._write({"host_id": "urn:uuid:h", "account": {"client_id": CLIENT, "email": "a@example.com",
|
||||
"subject": SUBJECT},
|
||||
"tokens": {"access_token": "a1", "refresh_token": refresh_token,
|
||||
"expires_at": expires_at, "earliest_refresh_at": earliest}})
|
||||
|
||||
|
||||
def _renewal(data):
|
||||
return {"access_token": "a2", "refresh_token": "r2", "expires_in": 3600,
|
||||
"id_token": _id_token(), "scope": auth.SCOPE}
|
||||
|
||||
|
||||
# --- the ID token ----------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_genuine_id_token_is_accepted():
|
||||
assert auth._validated_claims(_id_token(nonce="n"), CLIENT, "n", _jwks()["keys"])["sub"] == SUBJECT
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("token, nonce", [
|
||||
(lambda: _id_token(nonce="other"), "n"),
|
||||
(lambda: _id_token(aud="oaiapp_someone_else", nonce="n"), "n"),
|
||||
(lambda: _id_token(iss="https://evil.example", nonce="n"), "n"),
|
||||
(lambda: _id_token(exp=time.time() - 600, nonce="n"), "n"),
|
||||
])
|
||||
def test_an_id_token_for_another_sign_in_is_refused(token, nonce):
|
||||
with pytest.raises(auth.ChatGPTAuthError):
|
||||
auth._validated_claims(token(), CLIENT, nonce, _jwks()["keys"])
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_an_id_token_signed_by_another_key_is_refused():
|
||||
stranger = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
with pytest.raises(auth.ChatGPTAuthError, match="do not verify"):
|
||||
auth._validated_claims(_id_token(key=stranger, nonce="n"), CLIENT, "n", _jwks()["keys"])
|
||||
|
||||
|
||||
# --- tokens ----------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_nobody_signed_in_is_told_how_to_sign_in(home, openai):
|
||||
with pytest.raises(auth.ChatGPTAuthError, match="tradingagents login chatgpt"):
|
||||
auth.credential()
|
||||
with pytest.raises(auth.ChatGPTAuthError, match="tradingagents login chatgpt"):
|
||||
auth.access_token()
|
||||
assert openai.posts == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_fresh_token_is_used_without_asking_openai(home, openai):
|
||||
_signed_in(expires_at=time.time() + 3000)
|
||||
assert auth.credential()() == "a1"
|
||||
assert openai.posts == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_token_near_expiry_is_renewed_with_the_stored_refresh_token(home, openai):
|
||||
_signed_in(expires_at=time.time() + 60)
|
||||
openai.token_body = _renewal
|
||||
assert auth.access_token() == "a2"
|
||||
(url, sent), = openai.posts
|
||||
assert url == auth.TOKEN_URL and sent["refresh_token"] == "r1" and sent["client_id"] == CLIENT
|
||||
stored = json.loads(auth.AUTH_FILE.read_text())
|
||||
assert stored["tokens"]["refresh_token"] == "r2" and stored["account"]["subject"] == SUBJECT
|
||||
if os.name != "nt": # Windows keeps no POSIX mode; the profile's ACL guards it
|
||||
assert stat.S_IMODE(auth.AUTH_FILE.stat().st_mode) == 0o600
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_renewal_waits_for_the_time_openai_allows_while_the_token_still_works(home, openai):
|
||||
now = time.time()
|
||||
_signed_in(expires_at=now + 200, earliest=now + 100)
|
||||
assert auth.access_token() == "a1"
|
||||
assert openai.posts == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_concurrent_runs_renew_once_and_never_replay_a_used_refresh_token(home, openai):
|
||||
_signed_in(expires_at=time.time() + 60)
|
||||
openai.token_body = _renewal
|
||||
tokens = []
|
||||
threads = [threading.Thread(target=lambda: tokens.append(auth.access_token())) for _ in range(4)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
assert tokens == ["a2"] * 4
|
||||
assert [sent["refresh_token"] for _, sent in openai.posts] == ["r1"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_refused_renewal_asks_to_sign_in_again_and_keeps_the_file(home, openai):
|
||||
_signed_in(expires_at=time.time() + 60)
|
||||
openai.token_body, openai.status = {"error": "invalid_grant"}, 400
|
||||
with pytest.raises(auth.ChatGPTAuthError, match="invalid_grant.*login chatgpt"):
|
||||
auth.access_token()
|
||||
assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_renewal_for_another_account_is_refused(home, openai):
|
||||
_signed_in(expires_at=time.time() + 60)
|
||||
openai.token_body = lambda data: {**_renewal(data), "id_token": _id_token(sub="someone-else")}
|
||||
with pytest.raises(auth.ChatGPTAuthError, match="another account"):
|
||||
auth.access_token()
|
||||
|
||||
|
||||
# --- signing in and out ------------------------------------------------------------
|
||||
|
||||
def _nonce_of(printed):
|
||||
url = next(line for line in printed if line.startswith(auth.AUTHORIZE_URL))
|
||||
return dict(urllib.parse.parse_qsl(urllib.parse.urlparse(url).query))["nonce"]
|
||||
|
||||
|
||||
def _paster(printed, edit=lambda query: query):
|
||||
calls = []
|
||||
|
||||
def read_line():
|
||||
calls.append(1)
|
||||
if len(calls) > 1:
|
||||
threading.Event().wait()
|
||||
url = next(line for line in printed if line.startswith(auth.AUTHORIZE_URL))
|
||||
asked = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(url).query))
|
||||
query = edit({"code": "c1", "state": asked["state"], "client_id": CLIENT})
|
||||
return f"{asked['redirect_uri']}?{urllib.parse.urlencode(query)}"
|
||||
|
||||
return read_line
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_signing_in_by_pasting_the_redirect_saves_a_private_sign_in(home, openai):
|
||||
printed = []
|
||||
|
||||
def token(data):
|
||||
assert data["grant_type"] == "authorization_code" and data["client_id"] == CLIENT
|
||||
assert data["code"] == "c1" and data["code_verifier"]
|
||||
return {"access_token": "a1", "refresh_token": "r1", "expires_in": 3600,
|
||||
"id_token": _id_token(nonce=_nonce_of(printed)), "scope": auth.SCOPE}
|
||||
|
||||
openai.token_body = token
|
||||
email = auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0)
|
||||
assert email == "a@example.com"
|
||||
stored = json.loads(auth.AUTH_FILE.read_text())
|
||||
assert stored["account"] == {"client_id": CLIENT, "email": "a@example.com", "subject": SUBJECT}
|
||||
assert stored["host_id"].startswith("urn:uuid:")
|
||||
if os.name != "nt": # Windows keeps no POSIX mode; the profile's ACL guards it
|
||||
assert stat.S_IMODE(auth.AUTH_FILE.stat().st_mode) == 0o600
|
||||
asked = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(printed[1]).query))
|
||||
assert asked["client_id"] == auth.DYNAMIC_CLIENT_ID and asked["agent_name_hint"] == "TradingAgents"
|
||||
assert asked["code_challenge_method"] == "S256" and asked["redirect_uri"].startswith("http://127.0.0.1:")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_an_answer_for_another_sign_in_attempt_is_ignored(home, openai):
|
||||
printed = []
|
||||
with pytest.raises(auth.ChatGPTAuthError, match="timed out"):
|
||||
auth.sign_in(say=printed.append, read_line=_paster(printed, lambda q: {**q, "state": "forged"}),
|
||||
timeout=1, port=0)
|
||||
assert any("not the address" in line for line in printed)
|
||||
assert openai.posts == [] and not auth.AUTH_FILE.exists()
|
||||
|
||||
|
||||
def _status(url):
|
||||
try:
|
||||
return urllib.request.urlopen(url, timeout=5).status
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_the_browser_answer_reaches_the_loopback_and_strays_are_ignored(home, openai, monkeypatch):
|
||||
refused_connect, refused_lookup = socket.socket.connect, socket.getaddrinfo
|
||||
monkeypatch.setattr(socket.socket, "connect", lambda sock, address: (
|
||||
_CONNECT if address[0] == "127.0.0.1" else refused_connect)(sock, address))
|
||||
monkeypatch.setattr(socket, "getaddrinfo", lambda host, *a, **k: (
|
||||
_GETADDRINFO if host == "127.0.0.1" else refused_lookup)(host, *a, **k))
|
||||
printed = []
|
||||
openai.token_body = lambda data: {"access_token": "a1", "refresh_token": "r1", "expires_in": 3600,
|
||||
"id_token": _id_token(nonce=_nonce_of(printed)), "scope": auth.SCOPE}
|
||||
|
||||
def browser():
|
||||
while not any(line.startswith(auth.AUTHORIZE_URL) for line in printed):
|
||||
time.sleep(0.01)
|
||||
asked = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(printed[1]).query))
|
||||
results.append(_status(f"{asked['redirect_uri']}?error=access_denied"))
|
||||
results.append(_status(f"{asked['redirect_uri']}?code=c1&state={asked['state']}&client_id={CLIENT}"))
|
||||
|
||||
results = []
|
||||
threading.Thread(target=browser, daemon=True).start()
|
||||
email = auth.sign_in(say=printed.append, read_line=threading.Event().wait, timeout=10, port=0)
|
||||
assert email == "a@example.com" and results == [400, 200]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_sign_in_without_plan_usage_is_refused(home, openai):
|
||||
printed = []
|
||||
openai.token_body = lambda data: {"access_token": "a1", "refresh_token": "r1", "expires_in": 3600,
|
||||
"id_token": _id_token(nonce=_nonce_of(printed)),
|
||||
"scope": "openid email offline_access"}
|
||||
with pytest.raises(auth.ChatGPTAuthError, match="approve plan usage"):
|
||||
auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0)
|
||||
assert not auth.AUTH_FILE.exists()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_signing_out_revokes_the_sign_in_and_keeps_only_the_host_id(home, openai):
|
||||
_signed_in(expires_at=time.time() + 3000)
|
||||
assert auth.sign_out() == "revoked"
|
||||
(url, sent), = openai.posts
|
||||
assert url == auth.REVOKE_URL and sent["token"] == "r1" and sent["client_id"] == CLIENT
|
||||
assert json.loads(auth.AUTH_FILE.read_text()) == {"host_id": "urn:uuid:h"}
|
||||
assert auth.signed_in_as() is None
|
||||
assert auth.sign_out() is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_unreachable_keys_stop_a_renewal_before_the_refresh_token_is_spent(home, openai):
|
||||
_signed_in(expires_at=time.time() - 1)
|
||||
openai.token_body, openai.keys_reachable = _renewal, False
|
||||
with pytest.raises(auth.ChatGPTUnreachable, match="Could not reach OpenAI"):
|
||||
auth.access_token()
|
||||
assert openai.posts == []
|
||||
assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("failure", ["keys", "network", 429, 503])
|
||||
def test_a_temporary_failure_keeps_a_token_that_still_works(home, openai, failure):
|
||||
_signed_in(expires_at=time.time() + 60)
|
||||
openai.token_body = _renewal
|
||||
if failure == "keys":
|
||||
openai.keys_reachable = False
|
||||
elif failure == "network":
|
||||
openai.post_error = auth.requests.ConnectionError("reset")
|
||||
else:
|
||||
openai.token_body, openai.status = {"error": "busy"}, failure
|
||||
assert auth.access_token() == "a1"
|
||||
assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1"
|
||||
if failure == "keys":
|
||||
assert openai.posts == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("failure", ["network", 408, 503])
|
||||
def test_a_temporary_failure_after_expiry_says_to_try_again_not_to_sign_in(home, openai, failure):
|
||||
_signed_in(expires_at=time.time() - 1)
|
||||
if failure == "network":
|
||||
openai.post_error = auth.requests.Timeout("slow")
|
||||
else:
|
||||
openai.token_body, openai.status = {"error": "busy"}, failure
|
||||
with pytest.raises(auth.ChatGPTUnreachable) as raised:
|
||||
auth.access_token()
|
||||
assert "try again" in str(raised.value) and "login" not in str(raised.value)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_signing_in_again_revokes_the_replaced_sign_in(home, openai):
|
||||
_signed_in(expires_at=time.time() + 3000, refresh_token="old")
|
||||
printed = []
|
||||
openai.token_body = lambda data: {"access_token": "a2", "refresh_token": "r2", "expires_in": 3600,
|
||||
"id_token": _id_token(nonce=_nonce_of(printed)), "scope": auth.SCOPE}
|
||||
auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0)
|
||||
assert openai.posts[-1][0] == auth.REVOKE_URL and openai.posts[-1][1]["token"] == "old"
|
||||
assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r2"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_another_account_cannot_replace_the_signed_in_one(home, openai):
|
||||
_signed_in(expires_at=time.time() + 3000)
|
||||
printed = []
|
||||
openai.token_body = lambda data: {"access_token": "a2", "refresh_token": "r2", "expires_in": 3600,
|
||||
"id_token": _id_token(nonce=_nonce_of(printed), sub="someone-else"),
|
||||
"scope": auth.SCOPE}
|
||||
with pytest.raises(auth.ChatGPTAuthError, match="logout chatgpt"):
|
||||
auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0)
|
||||
assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_signing_out_while_openai_is_unreachable_still_forgets_the_sign_in(home, openai):
|
||||
_signed_in(expires_at=time.time() + 3000)
|
||||
openai.revoke_status = 503
|
||||
assert auth.sign_out() == "forgotten"
|
||||
assert auth.signed_in_as() is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_after_a_temporary_failure_renewal_waits_a_minute_before_trying_again(home, openai):
|
||||
_signed_in(expires_at=time.time() + 200)
|
||||
openai.post_error = auth.requests.ConnectionError("reset")
|
||||
clock = [time.time()]
|
||||
assert auth.access_token(now=lambda: clock[0]) == "a1"
|
||||
assert auth.access_token(now=lambda: clock[0] + 30) == "a1"
|
||||
assert len(openai.posts) == 1
|
||||
openai.post_error, openai.token_body = None, _renewal
|
||||
assert auth.access_token(now=lambda: clock[0] + 61) == "a2"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_after_expiry_a_failed_renewal_is_not_retried_within_the_minute(home, openai):
|
||||
_signed_in(expires_at=time.time() - 1)
|
||||
openai.post_error = auth.requests.ConnectionError("reset")
|
||||
for _ in range(3):
|
||||
with pytest.raises(auth.ChatGPTUnreachable, match="try again"):
|
||||
auth.access_token()
|
||||
assert len(openai.posts) == 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_an_unreadable_renewal_answer_keeps_a_token_that_still_works(home, openai):
|
||||
_signed_in(expires_at=time.time() + 60)
|
||||
openai.token_body = lambda data: ["not", "a", "token"]
|
||||
assert auth.access_token() == "a1"
|
||||
assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_network_failure_while_signing_in_is_reported_not_raised_raw(home, openai):
|
||||
printed = []
|
||||
openai.post_error = auth.requests.ConnectionError("reset")
|
||||
with pytest.raises(auth.ChatGPTUnreachable, match="sign you in to ChatGPT.*try again"):
|
||||
auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0)
|
||||
assert not auth.AUTH_FILE.exists()
|
||||
@@ -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",)
|
||||
@@ -83,12 +83,15 @@ class _Summary:
|
||||
def test_every_command_is_registered_when_run_as_a_module():
|
||||
"""README documents `python -m cli.main`, which executes the file top to
|
||||
bottom, so a command defined after the __main__ block would not exist."""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Help draws boxes in UTF-8; read it as such whatever the locale (cp1252 on Windows).
|
||||
out = subprocess.run([sys.executable, "-m", "cli.main", "backtest", "--help"],
|
||||
capture_output=True, text=True, timeout=120)
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
env={**os.environ, "PYTHONIOENCODING": "utf-8"}, timeout=120)
|
||||
assert out.returncode == 0, out.stderr[-400:]
|
||||
# Where the terminal takes colour, help styles each option and splits
|
||||
# "--start" across escape sequences, so read the text without them.
|
||||
@@ -180,3 +183,53 @@ def test_backtest_refuses_a_selection_before_the_sweep(runner, monkeypatch, args
|
||||
result = runner.invoke(m.app, ["backtest", "NVDA", "--start", "2026-06-01", "--end", "2026-06-01", *args])
|
||||
|
||||
assert result.exit_code == 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_login_and_logout_chatgpt_do_not_run_an_analysis(runner, monkeypatch):
|
||||
from tradingagents.llm_clients import chatgpt_auth
|
||||
|
||||
monkeypatch.setattr(chatgpt_auth, "sign_in", lambda say: "a@example.com")
|
||||
monkeypatch.setattr(chatgpt_auth, "sign_out", lambda: "revoked")
|
||||
signed_in = runner.invoke(m.app, ["login", "chatgpt"])
|
||||
assert signed_in.exit_code == 0 and "Signed in to ChatGPT as a@example.com" in signed_in.output
|
||||
signed_out = runner.invoke(m.app, ["logout", "chatgpt"])
|
||||
assert signed_out.exit_code == 0 and "Signed out of ChatGPT" in signed_out.output
|
||||
assert runner.invoke(m.app, ["login", "claude"]).exit_code != 0
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_failed_login_exits_with_the_reason(runner, monkeypatch):
|
||||
from tradingagents.llm_clients import chatgpt_auth
|
||||
|
||||
def refuse(say):
|
||||
raise chatgpt_auth.ChatGPTAuthError("Signing in with ChatGPT timed out")
|
||||
|
||||
monkeypatch.setattr(chatgpt_auth, "sign_in", refuse)
|
||||
result = runner.invoke(m.app, ["login", "chatgpt"])
|
||||
assert result.exit_code == 1 and "timed out" in result.output
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("outcome, said", [
|
||||
(None, "Not signed in"), ("forgotten", "could not be reached to revoke"),
|
||||
])
|
||||
def test_logout_says_whether_openai_revoked_the_sign_in(runner, monkeypatch, outcome, said):
|
||||
from tradingagents.llm_clients import chatgpt_auth
|
||||
|
||||
monkeypatch.setattr(chatgpt_auth, "sign_out", lambda: outcome)
|
||||
result = runner.invoke(m.app, ["logout", "chatgpt"])
|
||||
assert result.exit_code == 0 and said in " ".join(result.output.split())
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_failure_message_with_brackets_is_printed_as_text(runner, monkeypatch):
|
||||
from tradingagents.llm_clients import chatgpt_auth
|
||||
|
||||
def refuse(say):
|
||||
raise chatgpt_auth.ChatGPTAuthError("ChatGPT sign-in failed ([/bogus]); sign in again.")
|
||||
|
||||
monkeypatch.setattr(chatgpt_auth, "sign_in", refuse)
|
||||
result = runner.invoke(m.app, ["login", "chatgpt"])
|
||||
assert result.exit_code == 1 and "[/bogus]" in result.output
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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=[]), \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -38,6 +38,9 @@ def test_normalize_symbol_crypto_and_passthrough(raw, expected):
|
||||
("", True), # empty -> defaults to SPY downstream
|
||||
("bad symbol!", False), # space + '!' rejected
|
||||
("A" * 40, False), # too long
|
||||
("XAUUSD+", True), # a broker's CFD spelling of gold, priced as GC=F
|
||||
("..", False), # names a directory, not an instrument
|
||||
("\uff21\uff21\uff30\uff2c", False), # full-width letters
|
||||
])
|
||||
def test_ticker_input_validation(value, ok):
|
||||
assert is_valid_ticker_input(value) is ok
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""What's new reaches the person at a terminal: a newer release and Tauric's notices.
|
||||
|
||||
The check runs at most daily in a background thread that only writes a cache; the
|
||||
notice shown is read from that cache, so a run never waits on the network, and
|
||||
unattended runs contact no server at all.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from datetime import date
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from packaging.version import Version
|
||||
from rich.console import Console
|
||||
|
||||
import cli.selections as cli_selections
|
||||
import cli.updates as updates
|
||||
|
||||
|
||||
class _Tty(io.StringIO):
|
||||
def isatty(self):
|
||||
return True
|
||||
|
||||
|
||||
# --- who sees it -----------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("stdin_tty, stdout_tty, ci, opt_out, expected", [
|
||||
(True, True, None, None, True),
|
||||
(False, True, None, None, False),
|
||||
(True, False, None, None, False),
|
||||
(True, True, "true", None, False),
|
||||
(True, True, None, "1", False),
|
||||
])
|
||||
def test_only_a_person_at_a_terminal_is_told(monkeypatch, stdin_tty, stdout_tty, ci, opt_out, expected):
|
||||
monkeypatch.setattr("sys.stdin", _Tty() if stdin_tty else io.StringIO())
|
||||
monkeypatch.setattr("sys.stdout", _Tty() if stdout_tty else io.StringIO())
|
||||
for name, value in (("CI", ci), ("TRADINGAGENTS_NO_UPDATE_CHECK", opt_out)):
|
||||
if value is None:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
else:
|
||||
monkeypatch.setenv(name, value)
|
||||
assert updates.enabled() is expected
|
||||
|
||||
|
||||
ENV = {
|
||||
"TRADINGAGENTS_LLM_PROVIDER": "openai",
|
||||
"TRADINGAGENTS_DEEP_THINK_LLM": "gpt-6-sol",
|
||||
"TRADINGAGENTS_QUICK_THINK_LLM": "gpt-6-luna",
|
||||
"TRADINGAGENTS_OUTPUT_LANGUAGE": "English",
|
||||
"TRADINGAGENTS_MAX_DEBATE_ROUNDS": "1",
|
||||
"TRADINGAGENTS_MAX_RISK_ROUNDS": "1",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_an_unattended_run_contacts_no_server(monkeypatch):
|
||||
monkeypatch.setattr("sys.stdin", io.StringIO()) # no terminal
|
||||
calls = []
|
||||
monkeypatch.setattr(updates.requests, "get", lambda *a, **k: calls.append(a))
|
||||
started = []
|
||||
monkeypatch.setattr(updates.threading, "Thread", lambda *a, **k: started.append(1))
|
||||
with mock.patch.dict(os.environ, ENV, clear=False), \
|
||||
mock.patch.object(cli_selections, "ensure_api_key"):
|
||||
cli_selections.get_user_selections({"ticker": "AAPL", "date": "2026-09-01", "analysts": "market"})
|
||||
assert calls == [] and started == []
|
||||
|
||||
|
||||
# --- what it says -----------------------------------------------------------------
|
||||
|
||||
LATEST = {"version": "0.7.0", "url": "https://github.com/TauricResearch/TradingAgents/releases/tag/v0.7.0",
|
||||
"summary": "Update notices and a ChatGPT plan provider."}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("running, shown", [
|
||||
("0.6.0", True), ("0.7.0.dev0", True), # a development build of the release is older than it
|
||||
("0.7.0", False), ("0.8.0.dev0", False), ("not-a-version", False),
|
||||
])
|
||||
def test_a_newer_release_is_announced_once_in_the_notice(running, shown):
|
||||
lines = updates.messages({"latest": LATEST}, running=running, upgrade=lambda: None)
|
||||
assert any("0.7.0 is available" in line for line in lines) is shown
|
||||
if shown:
|
||||
text = "\n".join(lines)
|
||||
assert f"you have {running}" in text and LATEST["url"] in text and LATEST["summary"] in text
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_the_notice_names_the_upgrade_command():
|
||||
lines = updates.messages({"latest": LATEST}, running="0.6.0", upgrade=lambda: "git -C /src pull && pip install /src")
|
||||
assert "Upgrade: git -C /src pull && pip install /src" in "\n".join(lines)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_remote_text_cannot_style_or_break_the_terminal():
|
||||
body = "[bold red]Grab it[/bold red] now\x1b[2J with `--html`, a very long line " + "x" * 400 + "\n\nSecond paragraph."
|
||||
summary = updates.summary_of(body)
|
||||
assert "\n" not in summary and "Second paragraph" not in summary and len(summary) <= 160
|
||||
lines = updates.messages({"latest": {**LATEST, "summary": summary}}, running="0.6.0", upgrade=lambda: None)
|
||||
shown = "\n".join(lines)
|
||||
assert "[bold red]Grab it" in shown and "\x1b" not in shown # shown as plain text, controls stripped
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_notices_reach_only_the_versions_they_name():
|
||||
cache = {"notices": [
|
||||
{"id": "a", "message": "For everyone", "url": None},
|
||||
{"id": "b", "message": "Old installs: news is fixed", "max_version": "0.6.0"},
|
||||
{"id": "c", "message": "New installs only", "min_version": "0.7.0"},
|
||||
{"id": "d", "message": "Expired", "expires": "2020-01-01"},
|
||||
{"id": "e", "message": "[link=x]markup[/link]"},
|
||||
"a bare string is not a notice",
|
||||
]}
|
||||
text = "\n".join(updates.messages(cache, running="0.6.0", upgrade=lambda: None))
|
||||
assert "For everyone" in text and "Old installs" in text
|
||||
assert "New installs only" not in text and "Expired" not in text
|
||||
assert "[link=x]markup[/link]" in text and "bare string" not in text
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_notice_shows_through_its_bounds_and_date_and_hides_when_they_cannot_be_read():
|
||||
today = date(2026, 10, 16)
|
||||
applies = lambda **notice: updates._applies(notice, Version("0.7.0"), today) # noqa: E731
|
||||
assert applies(min_version="0.7.0", max_version="0.7.0", expires="2026-10-16")
|
||||
assert applies(expires=None) and applies(min_version=None, max_version=None)
|
||||
assert not applies(expires="2026-10-15") and not applies(min_version="0.7.1") and not applies(max_version="0.6.9")
|
||||
for unreadable in ("", "seven", [], False):
|
||||
assert not applies(min_version=unreadable) and not applies(max_version=unreadable)
|
||||
for unreadable in ("", "2026-10-16junk", "20261020", "2026-W42-5", "2026-10-16T23:00-08:00", 20261016):
|
||||
assert not applies(expires=unreadable)
|
||||
assert not updates._applies({"min_version": "0.7.0"}, Version("0.7.0.dev0"), today)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_notice_is_shown_as_plain_text_with_only_a_web_link():
|
||||
cache = {"notices": [
|
||||
{"id": "a", "message": ":warning: [b]x[/b] \u202eev\u200bil", "url": "https://tauric.ai/a"},
|
||||
{"id": "b", "message": "Local link", "url": "file:///etc/passwd"},
|
||||
{"id": "e", "message": "Malformed link", "url": "https://[docs"},
|
||||
{"id": "c", "message": {"not": "text"}},
|
||||
{"id": "d", "message": "\x1b\x07"},
|
||||
]}
|
||||
assert updates.messages(cache, running="0.7.0") == [":warning: [b]x[/b] evil https://tauric.ai/a", "Local link",
|
||||
"Malformed link https://[docs"]
|
||||
console = Console(record=True, width=120, color_system=None)
|
||||
with mock.patch.object(updates, "read_cache", lambda: cache), mock.patch("tradingagents.__version__", "0.7.0"):
|
||||
updates.show(console)
|
||||
assert ":warning: [b]x[/b] evil" in console.export_text()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_notices_are_read_from_their_own_key():
|
||||
# Notices have their own key, so ``announcements`` stays a list of strings.
|
||||
payload = {"announcements": ["Plain text for older clients"],
|
||||
"notices": [{"id": "x", "message": "Targeted", "max_version": "0.6.0"}]}
|
||||
assert updates.parse_notices(payload) == payload["notices"]
|
||||
assert updates.parse_notices({"announcements": ["old shape only"]}) is None
|
||||
assert updates.parse_notices({"notices": []}) == []
|
||||
|
||||
|
||||
# --- how it is checked ---------------------------------------------------------------
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_check_is_due_once_a_day():
|
||||
now = 1_000_000.0
|
||||
assert updates.due({}, now)
|
||||
assert not updates.due({"checked_at": now - 3600}, now)
|
||||
assert updates.due({"checked_at": now - 25 * 3600}, now)
|
||||
|
||||
|
||||
def _response(payload, status=200):
|
||||
return SimpleNamespace(status_code=status, ok=status < 400, json=lambda: payload)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_check_records_the_latest_release_and_notices(tmp_path, monkeypatch):
|
||||
cache_file = tmp_path / "update_check.json"
|
||||
monkeypatch.setattr(updates, "CACHE", cache_file)
|
||||
|
||||
def get(url, timeout):
|
||||
assert timeout <= 3
|
||||
if "api.github.com" in url:
|
||||
return _response({"tag_name": "v0.7.0", "html_url": LATEST["url"],
|
||||
"body": "Update notices and a ChatGPT plan provider.\n\n## Added"})
|
||||
return _response({"announcements": ["old"], "notices": [{"id": "n", "message": "hi"}]})
|
||||
|
||||
monkeypatch.setattr(updates.requests, "get", get)
|
||||
updates.refresh(now=5000.0)
|
||||
cache = json.loads(cache_file.read_text())
|
||||
assert cache["checked_at"] == 5000.0
|
||||
assert cache["latest"] == {"version": "0.7.0", "url": LATEST["url"], "summary": LATEST["summary"]}
|
||||
assert cache["notices"] == [{"id": "n", "message": "hi"}]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_failed_check_keeps_what_was_known_and_waits_a_day(tmp_path, monkeypatch, capsys):
|
||||
cache_file = tmp_path / "update_check.json"
|
||||
cache_file.write_text(json.dumps({"checked_at": 1.0, "latest": LATEST, "notices": []}))
|
||||
monkeypatch.setattr(updates, "CACHE", cache_file)
|
||||
|
||||
def get(url, timeout):
|
||||
raise updates.requests.ConnectionError("offline")
|
||||
|
||||
monkeypatch.setattr(updates.requests, "get", get)
|
||||
updates.refresh(now=9000.0) # never raises
|
||||
cache = json.loads(cache_file.read_text())
|
||||
assert cache["checked_at"] == 9000.0 and cache["latest"] == LATEST
|
||||
assert capsys.readouterr() == ("", "") # the check never prints
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_only_a_notices_list_replaces_the_known_notices(tmp_path, monkeypatch):
|
||||
cache_file = tmp_path / "update_check.json"
|
||||
known = [{"id": "n", "message": "hi"}]
|
||||
cache_file.write_text(json.dumps({"checked_at": 1.0, "notices": known}))
|
||||
monkeypatch.setattr(updates, "CACHE", cache_file)
|
||||
for answer in (_response({"detail": "Not Found"}, 404), _response({"error": "portal"}), _response(["x"])):
|
||||
monkeypatch.setattr(updates.requests, "get", lambda url, timeout, answer=answer: answer)
|
||||
updates.refresh(now=9000.0)
|
||||
assert json.loads(cache_file.read_text())["notices"] == known
|
||||
monkeypatch.setattr(updates.requests, "get", lambda url, timeout: _response({"announcements": [], "notices": []}))
|
||||
updates.refresh(now=99000.0)
|
||||
assert json.loads(cache_file.read_text())["notices"] == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_an_unreadable_cache_is_no_news(tmp_path, monkeypatch):
|
||||
cache_file = tmp_path / "update_check.json"
|
||||
cache_file.write_text("{not json")
|
||||
monkeypatch.setattr(updates, "CACHE", cache_file)
|
||||
assert updates.read_cache() == {}
|
||||
|
||||
|
||||
# --- the upgrade command for how it was installed ----------------------------------
|
||||
|
||||
def _dist(direct=None, installer="pip"):
|
||||
files = {"direct_url.json": json.dumps(direct) if direct else None, "INSTALLER": installer}
|
||||
return SimpleNamespace(read_text=lambda name: files.get(name))
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.skipif(os.name == "nt", reason="the upgrade commands are POSIX shell")
|
||||
def test_the_upgrade_command_follows_the_install(tmp_path, monkeypatch):
|
||||
clone = tmp_path / "TradingAgents"
|
||||
(clone / ".git").mkdir(parents=True)
|
||||
monkeypatch.delenv("TRADINGAGENTS_IN_DOCKER", raising=False)
|
||||
monkeypatch.setattr(updates.sys, "executable", "/venv/bin/python")
|
||||
url = clone.as_uri()
|
||||
|
||||
editable = updates.upgrade_command(_dist({"url": url, "dir_info": {"editable": True}}))
|
||||
assert editable == f"git -C {clone} pull && /venv/bin/python -m pip install -e {clone}"
|
||||
local = updates.upgrade_command(_dist({"url": url, "dir_info": {}}, installer="uv"))
|
||||
assert local == f"git -C {clone} pull && uv pip install --python /venv/bin/python {clone}"
|
||||
vcs = updates.upgrade_command(_dist({"url": "https://github.com/TauricResearch/TradingAgents.git",
|
||||
"vcs_info": {"vcs": "git", "commit_id": "abc"}}))
|
||||
assert vcs == "/venv/bin/python -m pip install -U git+https://github.com/TauricResearch/TradingAgents.git"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_no_command_is_guessed_without_a_clone(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("TRADINGAGENTS_IN_DOCKER", raising=False)
|
||||
gone = (tmp_path / "deleted-clone").as_uri()
|
||||
assert updates.upgrade_command(_dist({"url": gone, "dir_info": {}})) is None
|
||||
assert updates.upgrade_command(_dist(None)) is None
|
||||
monkeypatch.setenv("TRADINGAGENTS_IN_DOCKER", "1")
|
||||
assert updates.upgrade_command(_dist(None)) == "git pull && docker compose build (on the host)"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_whats_new_never_waits_for_the_person(tmp_path, monkeypatch):
|
||||
cache_file = tmp_path / "update_check.json"
|
||||
cache_file.write_text(json.dumps({"latest": LATEST, "notices": [{"id": "x", "message": "Notice"}]}))
|
||||
monkeypatch.setattr(updates, "CACHE", cache_file)
|
||||
monkeypatch.setattr("builtins.input", lambda *a: (_ for _ in ()).throw(AssertionError("asked for input")))
|
||||
from rich.console import Console
|
||||
out = Console(file=io.StringIO(), width=200)
|
||||
updates.show(out)
|
||||
assert "Notice" in out.file.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("stored", [
|
||||
{"latest": "x"}, {"latest": ["0.9.0"]}, {"notices": 5}, {"notices": {"id": "x"}}, {"checked_at": "today"}, [],
|
||||
])
|
||||
def test_a_cache_of_the_wrong_shape_is_no_news(tmp_path, monkeypatch, stored):
|
||||
cache_file = tmp_path / "update_check.json"
|
||||
cache_file.write_text(json.dumps(stored))
|
||||
monkeypatch.setattr(updates, "CACHE", cache_file)
|
||||
assert updates.messages(updates.read_cache(), running="0.6.0", upgrade=lambda: None) == []
|
||||
assert updates.due(updates.read_cache(), 1_000_000.0)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_check_dated_in_the_future_is_due():
|
||||
assert updates.due({"checked_at": 2_000_000.0}, 1_000_000.0)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_notice_with_an_unreadable_expiry_is_not_shown():
|
||||
cache = {"notices": [{"message": "Bad date", "expires": "next week"},
|
||||
{"message": "Number", "expires": 20991231},
|
||||
{"message": "Timestamp", "expires": "2999-01-01T00:00:00Z"},
|
||||
{"message": "A date", "expires": "2999-01-01"}]}
|
||||
text = "\n".join(updates.messages(cache, running="0.6.0", upgrade=lambda: None))
|
||||
assert "Bad date" not in text and "Number" not in text and "Timestamp" not in text and "A date" in text
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_remote_text_cannot_carry_c1_or_direction_controls():
|
||||
lines = updates.messages({"notices": [{"message": "A\x9b2Jbevil"}]}, running="0.6.0", upgrade=lambda: None)
|
||||
assert lines == ["A 2Jbevil"] # the C1 control reads as a space, the override is dropped
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.skipif(os.name == "nt", reason="the upgrade commands are POSIX shell")
|
||||
def test_a_uv_git_install_is_upgraded_with_uv(monkeypatch):
|
||||
monkeypatch.delenv("TRADINGAGENTS_IN_DOCKER", raising=False)
|
||||
monkeypatch.setattr(updates.sys, "executable", "/venv/bin/python")
|
||||
vcs = updates.upgrade_command(_dist({"url": "https://github.com/TauricResearch/TradingAgents.git",
|
||||
"vcs_info": {"vcs": "git"}}, installer="uv"))
|
||||
assert vcs == "uv pip install --python /venv/bin/python -U git+https://github.com/TauricResearch/TradingAgents.git"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_no_command_is_offered_on_windows(monkeypatch):
|
||||
monkeypatch.delenv("TRADINGAGENTS_IN_DOCKER", raising=False)
|
||||
monkeypatch.setattr(updates.os, "name", "nt")
|
||||
assert updates.upgrade_command(_dist({"url": "https://github.com/TauricResearch/TradingAgents.git",
|
||||
"vcs_info": {"vcs": "git"}})) is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_the_upgrade_command_is_worked_out_only_for_a_newer_release():
|
||||
asked = []
|
||||
updates.messages({"latest": LATEST}, running=LATEST["version"], upgrade=lambda: asked.append(1))
|
||||
assert asked == []
|
||||
@@ -312,7 +312,7 @@ def test_past_decisions_settle_alongside_the_analysts(tmp_path, monkeypatch, off
|
||||
|
||||
model = TimedModel()
|
||||
graph = _graph(tmp_path, monkeypatch, model)
|
||||
graph.memory_log.store_decision("NVDA", "2026-01-02", "Rating: Buy\n\nBuy NVDA.")
|
||||
graph.memory_log.store_decision("NVDA", "2025-12-31", "Rating: Buy\n\nBuy NVDA.")
|
||||
closes = pd.Series([100.0 + i for i in range(10)], index=pd.bdate_range("2026-01-02", periods=10))
|
||||
monkeypatch.setattr(settlement, "get_closes", lambda *a, **k: closes)
|
||||
|
||||
@@ -322,7 +322,7 @@ def test_past_decisions_settle_alongside_the_analysts(tmp_path, monkeypatch, off
|
||||
assert len(reflections) == 1 # settled once, inside the run
|
||||
analyst_turns = [c for c in model.seen if c[0] == "agent"]
|
||||
assert reflections[0][2] < max(c[3] for c in analyst_turns), "settled before the analysts, not alongside"
|
||||
settled = next(e for e in graph.memory_log.load_entries() if e["date"] == "2026-01-02")
|
||||
settled = next(e for e in graph.memory_log.load_entries() if e["date"] == "2025-12-31")
|
||||
assert not settled["pending"]
|
||||
lessons = graph.memory_log.get_past_context("NVDA", as_of=TRADE_DATE)
|
||||
assert lessons
|
||||
@@ -372,7 +372,7 @@ def test_the_memory_step_reads_lessons_as_of_the_trade_date_and_settles_once(tmp
|
||||
from tradingagents.memory import settlement
|
||||
|
||||
graph = _graph(tmp_path, monkeypatch, ScriptedModel(structured=True))
|
||||
graph.memory_log.store_decision("NVDA", "2026-01-02", "Rating: Buy\n\nBuy NVDA.")
|
||||
graph.memory_log.store_decision("NVDA", "2025-12-31", "Rating: Buy\n\nBuy NVDA.")
|
||||
closes = pd.Series([100.0 + i for i in range(10)], index=pd.bdate_range("2026-01-02", periods=10))
|
||||
monkeypatch.setattr(settlement, "get_closes", lambda *a, **k: closes)
|
||||
asked = []
|
||||
|
||||
+121
-16
@@ -300,6 +300,16 @@ class TestTradingMemoryLogCore:
|
||||
assert "Buy entry 0" not in ctx
|
||||
assert "Buy entry 5" in ctx
|
||||
|
||||
def test_the_most_recent_decisions_are_the_latest_dated_not_the_latest_written(self, tmp_path):
|
||||
"""A backfill writes January after June; June is still the most recent lesson."""
|
||||
log = make_log(tmp_path)
|
||||
_seed_completed(tmp_path, "NVDA", "2026-06-01", "Buy entry June.", "Correct.")
|
||||
for i in range(5):
|
||||
_seed_completed(tmp_path, "NVDA", f"2026-01-{i+1:02d}", f"Buy entry {i}.", "Correct.")
|
||||
ctx = log.get_past_context("NVDA", n_same=5)
|
||||
assert "Buy entry June" in ctx and "Buy entry 0" not in ctx
|
||||
assert ctx.index("Buy entry June") < ctx.index("Buy entry 4")
|
||||
|
||||
def test_n_cross_limit_respected(self, tmp_path):
|
||||
"""Only the n_cross most recent cross-ticker entries are included."""
|
||||
log = make_log(tmp_path)
|
||||
@@ -341,6 +351,16 @@ class TestTradingMemoryLogCore:
|
||||
dates = [e["date"] for e in entries]
|
||||
assert dates == ["2026-01-03", "2026-01-04", "2026-01-05"]
|
||||
|
||||
def test_rotation_prunes_the_oldest_dated_not_the_first_written(self, tmp_path):
|
||||
"""A backfill writes older dates last; rotation still keeps the newest decisions."""
|
||||
log = TradingMemoryLog({
|
||||
"memory_log_path": str(tmp_path / "trading_memory.md"),
|
||||
"memory_log_max_entries": 3,
|
||||
})
|
||||
for date in ("2026-06-01", "2026-06-02", "2026-01-01", "2026-01-02", "2026-01-03"):
|
||||
_resolve_entry(log, "NVDA", date, DECISION_BUY, f"Lesson {date}.")
|
||||
assert sorted(e["date"] for e in log.load_entries()) == ["2026-01-03", "2026-06-01", "2026-06-02"]
|
||||
|
||||
def test_rotation_never_prunes_pending(self, tmp_path):
|
||||
"""Pending entries (unresolved) are kept regardless of the cap."""
|
||||
log = TradingMemoryLog({
|
||||
@@ -512,8 +532,8 @@ class TestDeferredReflection:
|
||||
# settlement.fetch_returns
|
||||
|
||||
def test_fetch_returns_valid_ticker(self):
|
||||
stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0]
|
||||
spy_prices = [400.0, 402.0, 404.0, 403.0, 405.0, 406.0]
|
||||
stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0, 107.0]
|
||||
spy_prices = [400.0, 402.0, 404.0, 403.0, 405.0, 406.0, 407.0]
|
||||
with patch("yfinance.Ticker") as mock_ticker_cls:
|
||||
def _make_ticker(sym):
|
||||
m = MagicMock()
|
||||
@@ -524,8 +544,8 @@ class TestDeferredReflection:
|
||||
assert raw is not None and alpha is not None and days is not None
|
||||
assert isinstance(raw, float) and isinstance(alpha, float) and isinstance(days, int)
|
||||
assert days == 5
|
||||
# resolution date = the bar `days` sessions after the trade date (#1251)
|
||||
assert resolved == "2026-01-10"
|
||||
# resolution date = the bar `days` sessions after entry, the first close after the decision (#1251)
|
||||
assert resolved == "2026-01-11"
|
||||
|
||||
def test_fetch_returns_too_recent(self):
|
||||
"""Only 1 data point available → returns all-None, no crash."""
|
||||
@@ -549,7 +569,7 @@ class TestDeferredReflection:
|
||||
"""SPY having fewer rows than the stock (but still a full window) must
|
||||
not raise IndexError."""
|
||||
stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0, 107.0, 108.0] # 8 rows
|
||||
spy_prices = [400.0, 402.0, 403.0, 405.0, 406.0, 407.0] # 6 rows
|
||||
spy_prices = [400.0, 402.0, 403.0, 405.0, 406.0, 407.0, 408.0] # 7 rows
|
||||
with patch("yfinance.Ticker") as mock_ticker_cls:
|
||||
def _make_ticker(sym):
|
||||
m = MagicMock()
|
||||
@@ -559,7 +579,22 @@ class TestDeferredReflection:
|
||||
raw, alpha, days, resolved = settlement.fetch_returns("NVDA", "2026-01-05")
|
||||
assert raw is not None and alpha is not None
|
||||
assert days == 5 # full holding window used for both series
|
||||
assert resolved == "2026-01-10"
|
||||
assert resolved == "2026-01-11"
|
||||
|
||||
def test_a_decision_is_scored_from_the_first_close_after_its_date(self):
|
||||
"""#1518: news published after the close on the decision date reaches the
|
||||
analysts, so the close of that date is not a price they could have traded at."""
|
||||
stock_prices = [100.0, 150.0, 150.0, 150.0, 150.0, 150.0, 165.0] # jumps on the next close
|
||||
spy_prices = [400.0] * 7
|
||||
with patch("yfinance.Ticker") as mock_ticker_cls:
|
||||
def _make_ticker(sym):
|
||||
m = MagicMock()
|
||||
m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices)
|
||||
return m
|
||||
mock_ticker_cls.side_effect = _make_ticker
|
||||
raw, alpha, days, resolved = settlement.fetch_returns("NVDA", "2026-01-05")
|
||||
assert raw == pytest.approx(0.10) and alpha == pytest.approx(0.10) # 150 -> 165, not 100 -> 150
|
||||
assert resolved == "2026-01-11"
|
||||
|
||||
def test_fetch_returns_incomplete_window_stays_pending(self):
|
||||
"""#1169: a rerun before the full holding window has traded returns
|
||||
@@ -1082,10 +1117,10 @@ class TestSettlementWindow:
|
||||
|
||||
raw, alpha, days, resolved = settlement.fetch_returns("BTC-USD", "2026-01-03", 5, "SPY")
|
||||
|
||||
# The coin is held Sat 3 -> Thu 8; SPY over the same span is Fri 2 -> Thu 8.
|
||||
assert resolved == "2026-01-08"
|
||||
assert raw == pytest.approx(105.0 / 100.0 - 1)
|
||||
assert alpha == pytest.approx(raw - (spy["2026-01-08"] / spy["2026-01-02"] - 1))
|
||||
# Decided Sat 3, the coin is held Sun 4 -> Fri 9; SPY over the same span is Fri 2 -> Fri 9.
|
||||
assert resolved == "2026-01-09"
|
||||
assert raw == pytest.approx(106.0 / 101.0 - 1)
|
||||
assert alpha == pytest.approx(raw - (spy["2026-01-09"] / spy["2026-01-02"] - 1))
|
||||
|
||||
def test_bars_stamped_in_different_time_zones_meet_on_their_dates(self, monkeypatch):
|
||||
# Yahoo stamps a coin's daily bar at midnight UTC and SPY's at midnight
|
||||
@@ -1098,9 +1133,9 @@ class TestSettlementWindow:
|
||||
|
||||
raw, alpha, _, resolved = settlement.fetch_returns("BTC-USD", "2026-01-03", 5, "SPY")
|
||||
|
||||
assert resolved == "2026-01-08"
|
||||
assert resolved == "2026-01-09"
|
||||
spy_by_day = spy.tz_localize(None)
|
||||
assert alpha == pytest.approx(raw - (spy_by_day["2026-01-08"] / spy_by_day["2026-01-02"] - 1))
|
||||
assert alpha == pytest.approx(raw - (spy_by_day["2026-01-09"] / spy_by_day["2026-01-02"] - 1))
|
||||
|
||||
def test_the_outcome_waits_for_the_benchmark_to_trade_through_the_window(self, monkeypatch):
|
||||
coin = pd.Series([100.0 + i for i in range(10)], index=pd.date_range("2026-01-03", periods=10, freq="D"))
|
||||
@@ -1117,9 +1152,79 @@ class TestSettlementWindow:
|
||||
|
||||
raw, alpha, _, resolved = settlement.fetch_returns("NVDA", "2026-01-05", 5, "SPY")
|
||||
|
||||
assert raw == pytest.approx(105.0 / 100.0 - 1)
|
||||
assert resolved == "2026-01-13"
|
||||
assert alpha == pytest.approx(raw - (spy["2026-01-13"] / spy["2026-01-05"] - 1))
|
||||
# Entered at the first real close after the decision: Wed 7, past the missing Tue 6.
|
||||
assert raw == pytest.approx(106.0 / 101.0 - 1)
|
||||
assert resolved == "2026-01-14"
|
||||
assert alpha == pytest.approx(raw - (spy["2026-01-14"] / spy["2026-01-07"] - 1))
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSettlementUsesFinalCloses:
|
||||
"""A decision settles on closes that are final, whenever the pass runs."""
|
||||
|
||||
def test_a_bar_of_a_session_still_trading_is_not_scored(self, monkeypatch):
|
||||
# 09:12 in Shanghai on Tue 13: Yahoo already serves that day's bar at the live price.
|
||||
days = pd.bdate_range("2026-01-02", "2026-01-13", tz="Asia/Shanghai")
|
||||
stock = pd.Series([100.0 + i for i in range(len(days))], index=days)
|
||||
index = pd.Series([3000.0] * len(days), index=days)
|
||||
monkeypatch.setattr(settlement, "get_closes", _closes({"600519.SS": stock, "000001.SS": index}))
|
||||
monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-01-13 01:12", tz="UTC"))
|
||||
|
||||
# Decided Mon 5: entry Tue 6, exit five sessions later on Tue 13, today.
|
||||
assert settlement.fetch_returns("600519.SS", "2026-01-05", 5, "000001.SS") == (None, None, None, None)
|
||||
|
||||
monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-01-14 01:12", tz="UTC"))
|
||||
assert settlement.fetch_returns("600519.SS", "2026-01-05", 5, "000001.SS")[3] == "2026-01-13"
|
||||
|
||||
def test_a_decision_before_a_long_market_closure_settles(self, monkeypatch):
|
||||
# The exchange is shut Thu 1 to Wed 7 October.
|
||||
days = pd.bdate_range("2026-09-21", "2026-10-30")
|
||||
days = days[(days < "2026-10-01") | (days > "2026-10-07")]
|
||||
stock = pd.Series([100.0 + i for i in range(len(days))], index=days)
|
||||
index = pd.Series([3000.0 + i for i in range(len(days))], index=days)
|
||||
monkeypatch.setattr(settlement, "get_closes", _closes({"600519.SS": stock, "000001.SS": index}))
|
||||
monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-11-02 08:00", tz="UTC"))
|
||||
|
||||
raw, alpha, days_held, resolved = settlement.fetch_returns("600519.SS", "2026-09-29", 5, "000001.SS")
|
||||
|
||||
# Entry Wed 30 September, then five sessions from Thu 8 October.
|
||||
assert resolved == "2026-10-14" and days_held == 5
|
||||
|
||||
def test_a_market_whose_clocks_skip_midnight_still_settles(self, monkeypatch):
|
||||
# Santiago springs forward at midnight: 2026-09-06 has no 00:00.
|
||||
days = pd.bdate_range("2026-08-21", "2026-09-04", tz="America/Santiago")
|
||||
stock = pd.Series([100.0 + i for i in range(len(days))], index=days)
|
||||
index = pd.Series([6000.0 + i for i in range(len(days))], index=days)
|
||||
monkeypatch.setattr(settlement, "get_closes", _closes({"SQM-B.SN": stock, "^IPSA": index}))
|
||||
monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-09-06 15:00", tz="UTC"))
|
||||
|
||||
assert settlement.fetch_returns("SQM-B.SN", "2026-08-24", 5, "^IPSA")[3] == "2026-09-01"
|
||||
|
||||
def test_a_benchmark_shut_past_the_window_is_fetched_again(self, monkeypatch):
|
||||
days = pd.bdate_range("2026-09-14", "2026-10-30")
|
||||
stock = pd.Series([100.0 + i for i in range(len(days))], index=days)
|
||||
shut = days[(days < "2026-09-30") | (days > "2026-10-20")]
|
||||
index = pd.Series([3000.0 + i for i in range(len(shut))], index=shut)
|
||||
monkeypatch.setattr(settlement, "get_closes", _closes({"AAPL": stock, "000001.SS": index}))
|
||||
monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-11-02 08:00", tz="UTC"))
|
||||
|
||||
assert settlement.fetch_returns("AAPL", "2026-09-29", 5, "000001.SS")[3] == "2026-10-07"
|
||||
|
||||
def test_a_series_with_no_usable_close_stays_pending(self, monkeypatch):
|
||||
# Yahoo can answer with a dated, time-zoned frame whose closes are all missing.
|
||||
days = pd.bdate_range("2026-01-02", "2026-01-30", tz="America/New_York")
|
||||
empty = pd.Series([float("nan")] * len(days), index=days)
|
||||
index = pd.Series([400.0 + i for i in range(len(days))], index=days)
|
||||
monkeypatch.setattr(settlement, "get_closes", _closes({"NVDA": empty, "SPY": index}))
|
||||
monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-02-02 12:00", tz="UTC"))
|
||||
|
||||
assert settlement.fetch_returns("NVDA", "2026-01-05", 5, "SPY") == (None, None, None, None)
|
||||
|
||||
def test_a_window_that_cannot_have_traded_asks_for_no_prices(self, monkeypatch):
|
||||
monkeypatch.setattr(settlement, "_now", lambda: pd.Timestamp("2026-01-08 22:00", tz="UTC"))
|
||||
monkeypatch.setattr(settlement, "get_closes", lambda *a: pytest.fail("fetched prices"))
|
||||
|
||||
assert settlement.fetch_returns("NVDA", "2026-01-05", 5, "SPY") == (None, None, None, None)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -1130,7 +1235,7 @@ def test_a_close_that_is_not_a_price_is_skipped_not_scored(monkeypatch):
|
||||
spy = pd.Series([400.0 + i for i in range(8)], index=idx)
|
||||
monkeypatch.setattr(settlement, "get_closes", _closes({"NVDA": stock, "SPY": spy}))
|
||||
|
||||
raw, alpha, _, _ = settlement.fetch_returns("NVDA", "2026-01-05", 5, "SPY")
|
||||
raw, alpha, _, _ = settlement.fetch_returns("NVDA", "2026-01-02", 5, "SPY")
|
||||
|
||||
assert raw == pytest.approx(105.0 / 100.0 - 1) # the window opens on the first real close
|
||||
|
||||
|
||||
@@ -78,6 +78,19 @@ class PolymarketFormatTests(unittest.TestCase):
|
||||
self.assertIn("resolves 2030-12-31", out)
|
||||
self.assertIn("1-week -4.5pp", out) # -0.045 -> -4.5pp
|
||||
|
||||
def test_a_long_shot_is_not_shown_as_impossible_or_certain(self):
|
||||
search = {"events": [{"markets": [
|
||||
_market("Long shot?", 0.003, volume=2_000, end_date="2030-12-31T00:00:00Z"),
|
||||
_market("Near certain?", 0.997, volume=1_000, end_date="2030-12-31T00:00:00Z"),
|
||||
_market("Tick from zero?", 0.0005, volume=900, end_date="2030-12-31T00:00:00Z"),
|
||||
_market("Tick from one?", 0.9995, volume=800, end_date="2030-12-31T00:00:00Z"),
|
||||
_market("All but settled?", 0.9999999, volume=700, end_date="2030-12-31T00:00:00Z"),
|
||||
]}]}
|
||||
with mock.patch.object(polymarket, "_request", return_value=search):
|
||||
out = polymarket.get_prediction_markets("anything", limit=10)
|
||||
for odds in ("Yes 0.3%", "Yes 99.7%", "Yes 0.05%", "Yes 99.95%", "Yes 99.99999%"):
|
||||
self.assertIn(odds, out)
|
||||
|
||||
def test_weekly_change_omitted_when_absent(self):
|
||||
# "Open small?" has wk=None -> no 1-week clause on its line.
|
||||
with mock.patch.object(polymarket, "_request", return_value=_SEARCH):
|
||||
|
||||
@@ -45,6 +45,20 @@ def test_a_ticker_held_under_another_spelling_is_matched():
|
||||
assert "No current position" not in text and "5" in text
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("held, analyzed, canonical", [
|
||||
("BTCUSDT", "BTC-USD", "BTC-USD"),
|
||||
("XAUUSD+", "GC=F", "GC=F"),
|
||||
("700.HK", "0700.HK", "0700.HK"),
|
||||
("0700.HK", "700.HK", "0700.HK"),
|
||||
])
|
||||
def test_a_holding_under_a_broker_alias_is_the_analyzed_instrument(held, analyzed, canonical):
|
||||
book = PortfolioContext.model_validate({"positions": [{"ticker": held, "quantity": 2}, {"ticker": "ethusdt", "quantity": 1}]})
|
||||
text = book.render(analyzed)
|
||||
assert f"Current position in {canonical}: 2 units" in text
|
||||
assert "Other positions: ETHUSDT 1" in text # as listed
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_absent_context_is_reported_as_not_provided():
|
||||
notice = get_portfolio_context_from_state({"company_of_interest": "AAPL"})
|
||||
@@ -66,6 +80,36 @@ def test_load_rejects_a_malformed_file_with_a_clear_error(tmp_path):
|
||||
load_portfolio(bad)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_one_instrument_held_under_two_spellings_shows_both_holdings():
|
||||
book = PortfolioContext.model_validate({"positions": [
|
||||
{"ticker": "BTCUSDT", "quantity": 1}, {"ticker": "BTC-USD", "quantity": 2}]})
|
||||
text = book.render("BTC-USD")
|
||||
assert "Current position in BTC-USD: 1 units (listed as BTCUSDT)" in text
|
||||
assert "Current position in BTC-USD: 2 units" in text and "Other positions" not in text
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_holding_keeps_the_instrument_it_was_listed_as():
|
||||
# WTI is also an alias of crude oil futures; listed beside AAPL it is the stock as written.
|
||||
text = PortfolioContext.model_validate({"positions": [{"ticker": "WTI", "quantity": 100}]}).render("AAPL")
|
||||
assert "Other positions: WTI 100" in text and "CL=F" not in text
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("book", [
|
||||
'{"cash": NaN}',
|
||||
'{"positions": [{"ticker": "AAPL", "quantity": Infinity}]}',
|
||||
'{"positions": [{"ticker": "AAPL", "quantity": 1, "average_price": "nan"}]}',
|
||||
])
|
||||
def test_load_rejects_a_number_that_is_not_finite(tmp_path, book):
|
||||
# Python's json writes NaN and Infinity, and they would reach the agents as "nan units".
|
||||
bad = tmp_path / "p.json"
|
||||
bad.write_text(book)
|
||||
with pytest.raises(ValueError, match="portfolio"):
|
||||
load_portfolio(bad)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_load_reads_a_valid_file(tmp_path):
|
||||
good = tmp_path / "p.json"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -49,6 +49,46 @@ def test_save_reports_explicit_path(tmp_path):
|
||||
assert out == tmp_path / "complete_report.md"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("empty_value", [None, ""])
|
||||
def test_save_reports_replaces_previous_run_sections(tmp_path, empty_value):
|
||||
graph = SimpleNamespace(run_settings=lambda: SETTINGS)
|
||||
first = dict(_state(), trade_date="2026-09-22")
|
||||
TradingAgentsGraph.save_reports(graph, first, "AAPL", save_path=tmp_path)
|
||||
(tmp_path / "notes.md").write_text("User notes", encoding="utf-8")
|
||||
(tmp_path / "1_analysts" / "notes.md").write_text("Analyst notes", encoding="utf-8")
|
||||
|
||||
second = {
|
||||
"trade_date": "2026-09-23",
|
||||
"market_report": "NEW MARKET",
|
||||
"news_report": empty_value,
|
||||
"investment_debate_state": {"bull_history": empty_value},
|
||||
}
|
||||
out = TradingAgentsGraph.save_reports(graph, second, "AAPL", save_path=tmp_path)
|
||||
|
||||
assert (tmp_path / "1_analysts" / "market.md").read_text() == "NEW MARKET"
|
||||
for filename in (
|
||||
"1_analysts/news.md", "2_research/bull.md", "2_research/manager.md",
|
||||
"3_trading/trader.md", "4_risk/neutral.md", "5_portfolio/decision.md",
|
||||
):
|
||||
assert not (tmp_path / filename).exists()
|
||||
assert (tmp_path / "notes.md").read_text() == "User notes"
|
||||
assert (tmp_path / "1_analysts" / "notes.md").read_text() == "Analyst notes"
|
||||
assert "NEW MARKET" in out.read_text(encoding="utf-8")
|
||||
assert "NEWS" not in out.read_text(encoding="utf-8")
|
||||
assert "NEWS" not in (tmp_path / "complete_report.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_empty_report_removes_previous_sections_and_disabled_html(tmp_path):
|
||||
write_report_tree(_state(), "AAPL", tmp_path)
|
||||
out = write_report_tree({}, "AAPL", tmp_path, html=False)
|
||||
|
||||
assert list(tmp_path.rglob("*.md")) == [out]
|
||||
assert not (tmp_path / "complete_report.html").exists()
|
||||
assert "Trading Analysis Report: AAPL" in out.read_text()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_save_reports_defaults_under_results_dir(tmp_path):
|
||||
graph = object.__new__(TradingAgentsGraph)
|
||||
|
||||
+51
-17
@@ -97,9 +97,10 @@ def test_the_quarter_is_not_confused_with_the_year_to_date():
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_line_the_filer_does_not_tag_is_named_unavailable():
|
||||
def test_a_line_the_filer_does_not_tag_is_named_as_such():
|
||||
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
|
||||
assert "Stockholders Equity" in out and "unavailable" in out
|
||||
untagged = [line for line in out.splitlines() if line.startswith("# Not tagged by this filer:")]
|
||||
assert len(untagged) == 1 and "Stockholders Equity" in untagged[0]
|
||||
assert "364980" in out # the rest of the statement still returns
|
||||
|
||||
|
||||
@@ -158,8 +159,7 @@ def test_a_throttle_lets_the_next_vendor_try(monkeypatch):
|
||||
def test_values_do_not_break_the_columns():
|
||||
"""Figures run to the billions; a thousands separator would split the field."""
|
||||
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
|
||||
body = [row for row in out.splitlines() if row.startswith("Total Assets")][0]
|
||||
assert body.count(",") == out.splitlines()[3].count(",")
|
||||
assert _table(out)[1]["Total Assets"][-1] == "364980"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -167,15 +167,12 @@ def test_a_per_share_figure_keeps_its_own_unit():
|
||||
"""Statements are reported in millions, but EPS is dollars per share: scaling
|
||||
it the same way prints a real figure as zero."""
|
||||
out = sec_edgar.get_income_statement("AAPL", "annual", "2024-11-15")
|
||||
row = [r for r in out.splitlines() if r.startswith("Diluted EPS")][0]
|
||||
assert "6.08" in row
|
||||
assert "USD/shares" in row or "per share" in row
|
||||
assert _table(out)[1]["Diluted EPS (USD/shares)"] == ["6.08"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_every_row_has_one_cell_per_period():
|
||||
"""An untagged line still has to line up with the columns, or the table is
|
||||
misread by position."""
|
||||
def test_every_row_has_one_cell_per_column():
|
||||
"""Every period's row lines up with the header, or the table is misread by position."""
|
||||
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
|
||||
table = [r for r in out.splitlines() if r and not r.startswith("#")]
|
||||
widths = {row.count(",") for row in table}
|
||||
@@ -237,11 +234,18 @@ def test_capital_expenditure_is_found_under_either_tag_filers_use(monkeypatch):
|
||||
monkeypatch.setattr(sec_edgar, "_fetch_json",
|
||||
lambda url: TICKER_MAP if "company_tickers" in url else facts)
|
||||
out = sec_edgar.get_cashflow("AAPL", "annual", "2025-03-01")
|
||||
assert [r for r in out.splitlines() if r.startswith("Capital Expenditure")] == ["Capital Expenditure,70"]
|
||||
assert _table(out)[1]["Capital Expenditure"] == ["70"]
|
||||
|
||||
|
||||
def _table(out):
|
||||
"""(periods, {column: cells by period}) of a statement, one row per period."""
|
||||
lines = [line for line in out.splitlines() if line and not line.startswith("#")]
|
||||
header, rows = lines[0].split(","), [line.split(",") for line in lines[1:]]
|
||||
return [row[0] for row in rows], {name: [row[i] for row in rows] for i, name in enumerate(header) if i}
|
||||
|
||||
|
||||
def _columns(out):
|
||||
return [line for line in out.splitlines() if line.startswith(",")][0].split(",")[1:]
|
||||
return _table(out)[0]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -279,7 +283,7 @@ def test_a_recast_outside_the_annual_report_still_counts_from_its_filing(monkeyp
|
||||
|
||||
def eps(date):
|
||||
out = sec_edgar.get_income_statement("AAPL", "annual", date)
|
||||
return [line for line in out.splitlines() if line.startswith("Diluted EPS")][0].split(",")[1]
|
||||
return _table(out)[1]["Diluted EPS (USD/shares)"][0]
|
||||
|
||||
assert eps("2019-01-01") == "16.97"
|
||||
assert eps("2020-01-01") == "2.12"
|
||||
@@ -301,7 +305,7 @@ def test_a_cash_flow_filed_year_to_date_is_served_with_its_span(monkeypatch):
|
||||
out = sec_edgar.get_cashflow("AAPL", "quarterly", "2026-09-28")
|
||||
|
||||
assert _columns(out) == ["2025-12-27", "2026-03-28 (6 months)", "2026-06-27 (9 months)"]
|
||||
assert [r for r in out.splitlines() if r.startswith("Operating Cash Flow")] == ["Operating Cash Flow,30000,53000,81000"]
|
||||
assert _table(out)[1]["Operating Cash Flow"] == ["30000", "53000", "81000"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -318,6 +322,36 @@ def test_a_row_filed_only_year_to_date_keeps_its_figure_beside_a_row_filed_by_qu
|
||||
out = sec_edgar.get_cashflow("AAPL", "quarterly", "2026-09-28")
|
||||
|
||||
assert _columns(out) == ["2026-06-30", "2026-06-30 (6 months)"]
|
||||
rows = {r.split(",")[0]: r.split(",")[1:] for r in out.splitlines()[3:]}
|
||||
assert rows["Operating Cash Flow"] == ["", "120"]
|
||||
assert rows["Capital Expenditure"] == ["10", ""]
|
||||
columns = _table(out)[1]
|
||||
assert columns["Operating Cash Flow"] == ["", "120"]
|
||||
assert columns["Capital Expenditure"] == ["10", ""]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_statement_serves_every_period_one_row_each(monkeypatch):
|
||||
"""A filer's whole history in a row per line item ran to 67 columns and was read
|
||||
a column off (#1517); a row per period keeps each figure beside its date."""
|
||||
quarters = [
|
||||
_fact(f"{year}-{month:02d}-30", (year * 10 + month // 3) * 1_000_000, f"{year}-{month + 1:02d}-28",
|
||||
form="10-Q", fp=f"Q{month // 3}", start=f"{year}-{month - 2:02d}-01")
|
||||
for year in range(2015, 2026) for month in (3, 6, 9)
|
||||
]
|
||||
facts = {"facts": {"us-gaap": {"Revenues": {"units": {"USD": quarters}}}}}
|
||||
monkeypatch.setattr(sec_edgar, "_fetch_json",
|
||||
lambda url: TICKER_MAP if "company_tickers" in url else facts)
|
||||
|
||||
periods, columns = _table(sec_edgar.get_income_statement("AAPL", "quarterly", "2026-01-15"))
|
||||
|
||||
assert periods == [f"{y}-{m:02d}-30" for y in range(2015, 2026) for m in (3, 6, 9)]
|
||||
assert columns["Revenue"][0] == "20151" and columns["Revenue"][-1] == "20253"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_figure_under_a_million_is_not_printed_as_zero(monkeypatch):
|
||||
facts = {"facts": {"us-gaap": {"Revenues": {"units": {"USD": [
|
||||
_fact("2025-12-31", 303_300, "2026-02-20", start="2025-01-01"),
|
||||
_fact("2024-12-31", -25_000, "2025-02-20", start="2024-01-01")]}}}}}
|
||||
monkeypatch.setattr(sec_edgar, "_fetch_json",
|
||||
lambda url: TICKER_MAP if "company_tickers" in url else facts)
|
||||
|
||||
assert _table(sec_edgar.get_income_statement("AAPL", "annual", "2026-03-01"))[1]["Revenue"] == ["-0.0250", "0.303"]
|
||||
|
||||
@@ -34,12 +34,16 @@ def test_a_test_saves_cli_selections_to_its_own_directory(tmp_path):
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_yfinance_keeps_its_cache_out_of_the_users_home():
|
||||
"""yfinance caches each symbol's time zone on disk, in the home directory by default."""
|
||||
"""yfinance caches each symbol's time zone on disk, in the home directory by default.
|
||||
|
||||
The suite's own temporary directory may itself sit under the home (``%TEMP%``
|
||||
on Windows), so the check is that the cache is the suite's, not where it is.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from yfinance.cache import _TzDBManager
|
||||
|
||||
assert not Path(_TzDBManager.get_location()).is_relative_to(Path.home())
|
||||
assert Path(_TzDBManager.get_location()).name.startswith("tradingagents-tests-yf-")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -28,6 +28,7 @@ TRADE_DATE = "2026-08-14"
|
||||
(None, TRADE_DATE), # omitted
|
||||
("Sept 1", TRADE_DATE), # unparseable
|
||||
("", TRADE_DATE),
|
||||
("2026-8-1", "2026-08-01"), # unpadded: served in its canonical form
|
||||
])
|
||||
def test_as_of_takes_the_earlier_date(requested, expected):
|
||||
assert as_of(requested, TRADE_DATE) == expected
|
||||
@@ -36,6 +37,7 @@ def test_as_of_takes_the_earlier_date(requested, expected):
|
||||
@pytest.mark.unit
|
||||
def test_as_of_without_a_trade_date_passes_the_request_through():
|
||||
assert as_of("2026-09-14", "") == "2026-09-14"
|
||||
assert as_of("2026-9-14", "") == "2026-09-14"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -43,6 +45,7 @@ def test_as_of_without_a_trade_date_passes_the_request_through():
|
||||
("2026-08-01", "2026-09-14", ("2026-08-01", TRADE_DATE)), # end clamped
|
||||
("2026-08-01", "2026-08-10", ("2026-08-01", "2026-08-10")), # inside: unchanged
|
||||
("2026-09-01", "2026-09-08", ("2026-08-07", TRADE_DATE)), # wholly later: span kept, moved back
|
||||
("2026-8-1", "2026-8-10", ("2026-08-01", "2026-08-10")), # unpadded: canonical
|
||||
])
|
||||
def test_as_of_window(start, end, expected):
|
||||
assert as_of_window(start, end, TRADE_DATE) == expected
|
||||
@@ -128,6 +131,24 @@ def test_future_window_from_the_model_is_clamped():
|
||||
assert args == ("get_stock_data", "NVDA", "2026-08-01", TRADE_DATE)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_an_unpadded_date_reaches_the_vendor_in_canonical_form():
|
||||
# Vendors compare dates as YYYY-MM-DD text: "2026-07-23" > "2026-7-3" is False,
|
||||
# so an unpadded date would let a filing made after it through.
|
||||
args = _run(tools.get_income_statement, {"curr_date": "2026-7-3"}, tools)
|
||||
assert args[-1] == "2026-07-03"
|
||||
args = _run(tools.get_news, {"start_date": "2026-7-1", "end_date": "2026-7-3"}, tools)
|
||||
assert args[-2:] == ("2026-07-01", "2026-07-03")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("tool", [tools.get_balance_sheet, tools.get_cashflow, tools.get_income_statement])
|
||||
def test_a_statement_frequency_is_one_the_vendors_serve(tool):
|
||||
# Any other word was served annual figures under the word asked for.
|
||||
schema = tool.tool_call_schema.model_json_schema()["properties"]["freq"]
|
||||
assert schema["enum"] == ["annual", "quarterly"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_direct_call_without_state_is_unchanged():
|
||||
with mock.patch.object(tools, "route_to_vendor", return_value="ok") as routed:
|
||||
@@ -146,6 +167,15 @@ def test_a_start_date_that_is_not_a_date_is_sent_back_to_the_model(tool):
|
||||
assert "YYYY-MM-DD" in out and "start_date" in out
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_window_that_ends_before_it_starts_is_sent_back_to_the_model():
|
||||
# A reversed window reached the vendor, which then reported no data for it.
|
||||
with mock.patch.object(tools, "route_to_vendor", return_value="ok") as routed:
|
||||
out = tools.get_stock_data.func("NVDA", "2026-08-12", "2026-08-01", trade_date=TRADE_DATE)
|
||||
assert not routed.called
|
||||
assert "start_date" in out and "end_date" in out
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_a_window_whose_start_is_not_a_date_is_refused():
|
||||
with pytest.raises(ValueError, match="start_date"):
|
||||
@@ -161,6 +191,8 @@ def test_propagate_rejects_a_non_canonical_date(bad):
|
||||
|
||||
with pytest.raises(ValueError, match="YYYY-MM-DD"):
|
||||
object.__new__(TradingAgentsGraph).propagate("AAPL", bad)
|
||||
with pytest.raises(ValueError, match="YYYY-MM-DD"): # the CLI starts a run here
|
||||
object.__new__(TradingAgentsGraph).create_run_state("AAPL", bad)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
||||
@@ -73,3 +73,47 @@ class TestTool:
|
||||
{"symbol": "COF", "curr_date": "2026-05-20"}
|
||||
)
|
||||
assert "Verified market data snapshot for COF" in out
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestPricesKeepTheirDigits:
|
||||
"""A price prints with its significant digits: a sub-cent coin is not 0.00, a rate keeps its pips."""
|
||||
|
||||
@pytest.mark.parametrize("value, text", [
|
||||
(0.00000503123456, "0.00000503123"),
|
||||
(1.123456, "1.12346"),
|
||||
(763.960022, "763.96"),
|
||||
(81074.1875, "81074.19"),
|
||||
(100.0, "100.00"),
|
||||
(-0.0012345678, "-0.00123457"),
|
||||
(0.0, "0.00"),
|
||||
])
|
||||
def test_price_text(self, value, text):
|
||||
from tradingagents.dataflows.vendors.yahoo.common import price_text
|
||||
assert price_text(value) == text
|
||||
|
||||
def test_the_snapshot_shows_a_sub_cent_close(self, monkeypatch):
|
||||
data = _sample_ohlcv()
|
||||
for col in ("Open", "High", "Low", "Close"):
|
||||
data[col] = data[col] * 0.0000001
|
||||
monkeypatch.setattr(validator, "load_ohlcv", lambda s, d, fill_gaps=True: data)
|
||||
text = validator.build_verified_market_snapshot("SHIB-USD", "2026-05-20")
|
||||
assert "| Close | 0.0000135 |" in text and "| Close | 0.00 |" not in text
|
||||
|
||||
def test_the_price_table_shows_a_sub_cent_close(self, monkeypatch):
|
||||
from tradingagents.dataflows.vendors.yahoo import market
|
||||
|
||||
days = pd.bdate_range("2026-05-11", "2026-05-15")
|
||||
frame = pd.DataFrame({"Open": 0.00000501, "High": 0.00000512, "Low": 0.00000499,
|
||||
"Close": 0.00000503, "Volume": 10**9}, index=days)
|
||||
|
||||
class _Ticker:
|
||||
def __init__(self, symbol):
|
||||
pass
|
||||
|
||||
def history(self, **kwargs):
|
||||
return frame
|
||||
|
||||
monkeypatch.setattr(market.yf, "Ticker", _Ticker)
|
||||
text = market.get_YFin_data_online("SHIB-USD", "2026-05-11", "2026-05-15")
|
||||
assert "0.00000503" in text and ",0.0," not in text
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""TradingAgents: multi-agent LLM financial trading framework."""
|
||||
|
||||
__version__ = "0.6.0"
|
||||
__version__ = "0.7.1"
|
||||
|
||||
from dotenv import find_dotenv, load_dotenv
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Each dated tool takes the run's ``trade_date`` from graph state (``InjectedState
|
||||
and never serves data past it, whatever date the model asks for.
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from langchain_core.tools import tool
|
||||
from langgraph.prebuilt import InjectedState
|
||||
@@ -114,7 +114,7 @@ def get_fundamentals(
|
||||
@tool
|
||||
def get_balance_sheet(
|
||||
ticker: Annotated[str, InjectedState("company_of_interest")],
|
||||
freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly",
|
||||
freq: Annotated[Literal["annual", "quarterly"], "reporting frequency"] = "quarterly",
|
||||
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
|
||||
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
||||
) -> str:
|
||||
@@ -133,7 +133,7 @@ def get_balance_sheet(
|
||||
@tool
|
||||
def get_cashflow(
|
||||
ticker: Annotated[str, InjectedState("company_of_interest")],
|
||||
freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly",
|
||||
freq: Annotated[Literal["annual", "quarterly"], "reporting frequency"] = "quarterly",
|
||||
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
|
||||
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
||||
) -> str:
|
||||
@@ -152,7 +152,7 @@ def get_cashflow(
|
||||
@tool
|
||||
def get_income_statement(
|
||||
ticker: Annotated[str, InjectedState("company_of_interest")],
|
||||
freq: Annotated[str, "reporting frequency: annual/quarterly"] = "quarterly",
|
||||
freq: Annotated[Literal["annual", "quarterly"], "reporting frequency"] = "quarterly",
|
||||
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
|
||||
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
||||
) -> str:
|
||||
|
||||
@@ -16,6 +16,7 @@ cell rather than a position carried forward.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
@@ -119,13 +120,26 @@ class BacktestSummary:
|
||||
if self.pending:
|
||||
lines.append("Pending cells are not scored above; re-run to settle them.")
|
||||
lines.append(
|
||||
f"Alpha is measured over {self.holding} after each analysis date. "
|
||||
f"Alpha is measured over {self.holding} from the first close after each analysis date. "
|
||||
"One model sampling per cell, and text feeds are not archived, so "
|
||||
"these figures are indicative rather than repeatable."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _new_run(runs: Path) -> str:
|
||||
"""A new run's id, stamped now, and its directory, claimed so no other sweep shares it."""
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
runs.mkdir(parents=True, exist_ok=True)
|
||||
for n in itertools.count(1):
|
||||
run_id = stamp if n == 1 else f"{stamp}_{n}"
|
||||
try:
|
||||
(runs / run_id).mkdir()
|
||||
return run_id
|
||||
except FileExistsError:
|
||||
continue
|
||||
|
||||
|
||||
def run_backtest(
|
||||
tickers: list[str],
|
||||
dates: list[str],
|
||||
@@ -143,11 +157,15 @@ def run_backtest(
|
||||
interrupted sweep resumes by being run again. ``progress(done, total,
|
||||
ticker, date)`` is called before each cell that runs.
|
||||
"""
|
||||
# run_id becomes a path segment, so it is validated like a ticker: an
|
||||
# absolute or dotted value would otherwise place the run outside results_dir.
|
||||
run_id = safe_ticker_component(run_id or datetime.now().strftime("%Y%m%d_%H%M%S"))
|
||||
run_dir = Path(config["results_dir"]) / "backtest" / run_id
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
runs = Path(config["results_dir"]) / "backtest"
|
||||
if run_id:
|
||||
# run_id becomes a path segment, so it is validated like a ticker: an
|
||||
# absolute or dotted value would otherwise place the run outside results_dir.
|
||||
run_id = safe_ticker_component(run_id)
|
||||
(runs / run_id).mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
run_id = _new_run(runs)
|
||||
run_dir = runs / run_id
|
||||
run_config = {**config, "results_dir": str(run_dir),
|
||||
"memory_log_path": str(run_dir / "trading_memory.md")}
|
||||
|
||||
@@ -174,7 +192,11 @@ def run_backtest(
|
||||
# last cell would stay pending without this pass.
|
||||
for ticker in tickers:
|
||||
try:
|
||||
graph.settle_pending(ticker)
|
||||
settlement = graph.settle_pending(ticker)
|
||||
result.settlement_failures.extend(
|
||||
(failed_ticker, f"{date}: {reason}")
|
||||
for failed_ticker, date, reason in settlement.failed
|
||||
)
|
||||
except Exception as exc: # reflection calls an LLM; one failure is not the sweep's
|
||||
logger.warning("Settling %s failed: %s", ticker, exc)
|
||||
result.settlement_failures.append((ticker, str(exc)))
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Point-in-time rules shared by every dated path: data is served as of the run's date.
|
||||
|
||||
- ``as_of`` / ``as_of_window`` clamp a date or window the model asks for to the
|
||||
trade date, so no tool reaches a vendor with a later one.
|
||||
trade date, so no tool reaches a vendor with a later one, and always hand it
|
||||
over as ``YYYY-MM-DD``: vendors compare dates as text.
|
||||
- ``in_window`` trims dated items (news, StockTwits, Reddit) to the analysis
|
||||
window: timestamps normalized to UTC, the upper bound exclusive at midnight
|
||||
after ``end``, and an undated item kept only when the window reaches the
|
||||
@@ -79,13 +80,14 @@ def as_of(requested: str | None, trade_date: str) -> str | None:
|
||||
"""The date a tool serves: the model's date, but never later than the run's.
|
||||
|
||||
A model can omit the date or pass today's instead of the analysis date, which
|
||||
would walk past every point-in-time guard behind the tool. An empty
|
||||
would walk past every point-in-time guard behind the tool. A date is served in
|
||||
its canonical form: "2026-7-3" compares as later than "2026-07-23". An empty
|
||||
``trade_date`` (a direct call outside a graph run) passes the request through.
|
||||
"""
|
||||
if not trade_date:
|
||||
return requested
|
||||
parsed = _parse(requested)
|
||||
return requested if parsed is not None and parsed <= _parse(trade_date) else trade_date
|
||||
if not trade_date:
|
||||
return requested if parsed is None else f"{parsed:%Y-%m-%d}"
|
||||
return f"{parsed:%Y-%m-%d}" if parsed is not None and parsed <= _parse(trade_date) else trade_date
|
||||
|
||||
|
||||
def as_of_window(start_date: str, end_date: str, trade_date: str) -> tuple[str, str]:
|
||||
@@ -93,16 +95,20 @@ def as_of_window(start_date: str, end_date: str, trade_date: str) -> tuple[str,
|
||||
|
||||
A window wholly after the run date keeps its length and moves back to end there.
|
||||
An unreadable end reads as the run date, but no window can be guessed from an
|
||||
unreadable start, so it raises ValueError for the caller to send back (#1476).
|
||||
unreadable start or from a start after the end, so those raise ValueError for
|
||||
the caller to send back (#1476).
|
||||
"""
|
||||
start, old_end = _parse(start_date), _parse(end_date)
|
||||
if start is None:
|
||||
raise ValueError(f"start_date {start_date!r} is not a date; give it as YYYY-MM-DD")
|
||||
if old_end is not None and start > old_end:
|
||||
raise ValueError(f"start_date {start_date} is after end_date {end_date}; give the earlier date first")
|
||||
end = as_of(end_date, trade_date)
|
||||
if end == end_date or start <= _parse(end):
|
||||
return start_date, end
|
||||
end_dt = _parse(end)
|
||||
if end_dt is None or end_dt == old_end or start <= end_dt:
|
||||
return f"{start:%Y-%m-%d}", end
|
||||
span = (old_end - start) if old_end is not None and old_end >= start else timedelta(0)
|
||||
return f"{_parse(end) - span:%Y-%m-%d}", end
|
||||
return f"{end_dt - span:%Y-%m-%d}", end
|
||||
|
||||
|
||||
def withhold_live_profile(as_of_date: str | None, label: str) -> str | None:
|
||||
|
||||
@@ -74,12 +74,12 @@ def _hold_windows(handle, wait: bool = True) -> Iterator[bool]:
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK if wait else msvcrt.LK_NBLCK, 1)
|
||||
break
|
||||
except OSError as exc:
|
||||
if not wait and exc.errno in (errno.EACCES, errno.EDEADLOCK):
|
||||
if not wait and exc.errno in (errno.EACCES, errno.EDEADLK):
|
||||
yield False # another writer holds it
|
||||
return
|
||||
# LK_LOCK gives up after about ten seconds of another writer's hold;
|
||||
# any other failure is not a wait.
|
||||
if exc.errno != errno.EDEADLOCK:
|
||||
if exc.errno != errno.EDEADLK:
|
||||
raise
|
||||
try:
|
||||
yield True
|
||||
|
||||
+17
-1
@@ -11,6 +11,7 @@ outcomes (a "Yes" at 0.76 means the market prices a 76% chance).
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import requests
|
||||
@@ -67,6 +68,21 @@ def _is_forward_looking(market: dict, now: datetime) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _odds(prob: float) -> str:
|
||||
"""A probability as a percentage; within 1% of 0 or 100 its distance from
|
||||
there keeps two significant digits, so 0.05% is not 0% and 99.95% not 100%."""
|
||||
def decimals(distance: float) -> int:
|
||||
return 1 - math.floor(math.log10(distance))
|
||||
|
||||
if 0 < prob < 0.01:
|
||||
percent = prob * 100
|
||||
return f"{percent:.{decimals(percent)}f}".rstrip("0").rstrip(".") + "%"
|
||||
if 0.99 < prob < 1:
|
||||
distance = (1 - prob) * 100
|
||||
return f"{100 - distance:.{decimals(distance)}f}".rstrip("0").rstrip(".") + "%"
|
||||
return f"{prob:.0%}"
|
||||
|
||||
|
||||
def get_prediction_markets(topic: str, limit: int | None = None, as_of_date: str | None = None) -> str:
|
||||
"""Return live prediction-market probabilities for an event topic.
|
||||
|
||||
@@ -142,7 +158,7 @@ def get_prediction_markets(topic: str, limit: int | None = None, as_of_date: str
|
||||
else ""
|
||||
)
|
||||
lines.append(
|
||||
f"- **{m.get('question')}** — {label} {prob:.0%} "
|
||||
f"- **{m.get('question')}** — {label} {_odds(prob)} "
|
||||
f"(${volume:,.0f} volume, resolves {end_date}{wk_str})"
|
||||
)
|
||||
|
||||
|
||||
+27
-14
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from datetime import date, datetime
|
||||
@@ -216,27 +217,39 @@ def _statement(kind: str, ticker: str, freq: str, as_of_date: str, title: str) -
|
||||
periods = sorted({(end, index) for spans_of in chosen.values() for end, index in spans_of.items()})
|
||||
if not periods:
|
||||
raise NoMarketDataError(ticker, ticker, f"no {freq} {title.lower()} filed by {as_of_date}")
|
||||
|
||||
tagged = {label: line for label, line in lines.items() if line[0]}
|
||||
untagged = [label for label in lines if label not in tagged]
|
||||
header = (
|
||||
f"# {title} for {ticker.upper()} ({freq}), USD in millions unless the row says otherwise\n"
|
||||
f"# SEC EDGAR facts filed on or before {as_of_date}, at the values filed then\n\n"
|
||||
f"# {title} for {ticker.upper()} ({freq}), USD in millions unless the column says otherwise\n"
|
||||
f"# SEC EDGAR facts filed on or before {as_of_date}, at the values filed then\n"
|
||||
+ (f"# Not tagged by this filer: {', '.join(untagged)}\n" if untagged else "")
|
||||
+ "\n"
|
||||
)
|
||||
rows = [",".join([""] + [end + names[index] for end, index in periods])]
|
||||
for label, (values, unit) in lines.items():
|
||||
# Every row spans the same columns, or a reader lines the table up wrong.
|
||||
if not values:
|
||||
rows.append(",".join([label] + ["unavailable (not tagged by this filer)"] * len(periods)))
|
||||
continue
|
||||
name = label if unit == "USD" else f"{label} ({unit})"
|
||||
# Plain numbers: a thousands separator would split the CSV field.
|
||||
# One row per period, so every figure sits beside its own date: across a
|
||||
# filer's whole history a row per line item ran to dozens of columns and was
|
||||
# read a column off (#1517).
|
||||
rows = [",".join(["Period"] + [label if unit == "USD" else f"{label} ({unit})"
|
||||
for label, (_, unit) in tagged.items()])]
|
||||
for end, index in periods:
|
||||
cells = []
|
||||
for end, index in periods:
|
||||
for label, (values, unit) in tagged.items():
|
||||
value = values.get((end, index)) if chosen[label].get(end) == index else None
|
||||
cells.append("" if value is None else f"{value / 1e6:.0f}" if unit == "USD" else f"{value:.2f}")
|
||||
rows.append(",".join([name] + cells))
|
||||
cells.append("" if value is None else _millions(value) if unit == "USD" else f"{value:.2f}")
|
||||
rows.append(",".join([end + names[index]] + cells))
|
||||
return header + "\n".join(rows) + "\n"
|
||||
|
||||
|
||||
def _millions(value: float) -> str:
|
||||
"""USD in millions; under a million, three significant digits, so it is not printed as 0.
|
||||
|
||||
Plain numbers: a thousands separator would split the CSV field.
|
||||
"""
|
||||
millions = value / 1e6
|
||||
if millions == 0 or abs(millions) >= 1:
|
||||
return f"{millions:.0f}"
|
||||
return f"{millions:.{2 - math.floor(math.log10(abs(millions)))}f}"
|
||||
|
||||
|
||||
def get_balance_sheet(ticker: str, freq: str = "quarterly", as_of_date: str | None = None) -> str:
|
||||
"""Balance sheet as filed on or before ``as_of_date``."""
|
||||
return _statement("balance_sheet", ticker, freq, as_of_date, "Balance Sheet")
|
||||
|
||||
+16
-1
@@ -1,6 +1,8 @@
|
||||
"""What every Yahoo request shares: its retry, and how a failure or an empty answer is reported."""
|
||||
"""What every Yahoo request shares: its retry, how a failure or an empty answer is
|
||||
reported, and how a price is written out."""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
|
||||
import yfinance as yf
|
||||
@@ -14,6 +16,19 @@ logger = logging.getLogger(__name__)
|
||||
YAHOO_HOST = "https://query2.finance.yahoo.com"
|
||||
|
||||
|
||||
def price_text(value: float) -> str:
|
||||
"""A price with six significant digits and at least two decimals.
|
||||
|
||||
Fixed decimals print a sub-cent coin as 0.00 and a currency pair without its
|
||||
pips; this keeps both readable.
|
||||
"""
|
||||
if not math.isfinite(value):
|
||||
return str(value)
|
||||
decimals = 2 if value == 0 else max(2, 5 - math.floor(math.log10(abs(value))))
|
||||
whole, _, fraction = f"{value:.{decimals}f}".partition(".")
|
||||
return f"{whole}.{fraction.rstrip('0').ljust(2, '0')}"
|
||||
|
||||
|
||||
def raise_for_empty(symbol: str, canonical: str, what: str) -> None:
|
||||
"""Report an empty Yahoo answer as an absence, or as an outage if it is one.
|
||||
|
||||
|
||||
+3
-5
@@ -9,7 +9,7 @@ from stockstats import wrap
|
||||
|
||||
from tradingagents.dataflows.errors import NoMarketDataError, VendorError
|
||||
from tradingagents.dataflows.symbols import normalize_symbol
|
||||
from tradingagents.dataflows.vendors.yahoo.common import raise_for_empty, yf_retry
|
||||
from tradingagents.dataflows.vendors.yahoo.common import price_text, raise_for_empty, yf_retry
|
||||
from tradingagents.dataflows.vendors.yahoo.ohlcv import _assert_ohlcv_not_stale, load_ohlcv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -48,11 +48,9 @@ def get_YFin_data_online(
|
||||
# turns into one clear unavailable signal (#1021).
|
||||
_assert_ohlcv_not_stale(data, end_date, symbol, canonical)
|
||||
|
||||
# Round numerical values to 2 decimal places for cleaner display
|
||||
numeric_columns = ["Open", "High", "Low", "Close", "Adj Close"]
|
||||
for col in numeric_columns:
|
||||
for col in ("Open", "High", "Low", "Close", "Adj Close"):
|
||||
if col in data.columns:
|
||||
data[col] = data[col].round(2)
|
||||
data[col] = data[col].map(price_text, na_action="ignore")
|
||||
|
||||
csv_string = data.to_csv()
|
||||
|
||||
|
||||
+2
-1
@@ -17,6 +17,7 @@ from stockstats import wrap
|
||||
|
||||
from tradingagents.dataflows.errors import NoMarketDataError
|
||||
from tradingagents.dataflows.symbols import normalize_symbol
|
||||
from tradingagents.dataflows.vendors.yahoo.common import price_text
|
||||
from tradingagents.dataflows.vendors.yahoo.ohlcv import load_ohlcv
|
||||
|
||||
# A fixed, common indicator set so the snapshot is the same shape every run.
|
||||
@@ -59,7 +60,7 @@ def _fmt(value) -> str:
|
||||
if isinstance(value, (int,)):
|
||||
return str(value)
|
||||
if isinstance(value, float):
|
||||
return f"{value:.2f}"
|
||||
return price_text(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -297,6 +297,7 @@ class TradingAgentsGraph:
|
||||
memory log's lessons are not here: the graph's Memory Log step settles
|
||||
and loads them alongside the analysts (see ``_memory_step``).
|
||||
"""
|
||||
trade_date = _validate_trade_date(trade_date)
|
||||
return self.propagator.create_initial_state(
|
||||
company_name,
|
||||
trade_date,
|
||||
|
||||
@@ -15,12 +15,13 @@ _PASSTHROUGH_KWARGS = (
|
||||
# Sonnet 4.6+, and the Claude 5 family (Sonnet 5, Opus 5.5, Fable 5). Sonnet 4.5 and any
|
||||
# Haiku version 400 with ``"This model does not support the effort parameter"``
|
||||
# (#831). Versions may be dotted (``opus-4-8``) or single-number (``sonnet-5``,
|
||||
# ``fable-5``); the per-family minimum below is forward-compatible.
|
||||
# ``fable-5``), and a snapshot carries its date (``sonnet-4-20250514``), which is
|
||||
# not a minor version; the per-family minimum below is forward-compatible.
|
||||
_EFFORT_EXACT = {
|
||||
"claude-mythos-preview", # non-standard preview name; effort-capable
|
||||
"claude-mythos-5", # Fable 5 twin (Project Glasswing); effort-capable
|
||||
}
|
||||
_EFFORT_MODEL = re.compile(r"^claude-(opus|sonnet|fable)-(\d+)(?:-(\d+))?$")
|
||||
_EFFORT_MODEL = re.compile(r"^claude-(opus|sonnet|fable)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$")
|
||||
_EFFORT_MIN_VERSION = {"opus": (4, 5), "sonnet": (4, 6), "fable": (5, 0)}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ from langchain_openai import AzureChatOpenAI
|
||||
from .base_client import BaseLLMClient, normalize_content
|
||||
|
||||
_PASSTHROUGH_KWARGS = (
|
||||
"timeout", "max_retries", "api_key", "reasoning_effort", "temperature",
|
||||
"timeout", "max_retries", "api_key", "reasoning_effort", "temperature", "max_tokens",
|
||||
"callbacks", "http_client", "http_async_client",
|
||||
)
|
||||
|
||||
@@ -23,7 +23,8 @@ class AzureOpenAIClient(BaseLLMClient):
|
||||
|
||||
Requires environment variables:
|
||||
AZURE_OPENAI_API_KEY: API key
|
||||
AZURE_OPENAI_ENDPOINT: Endpoint URL (e.g. https://<resource>.openai.azure.com/)
|
||||
AZURE_OPENAI_ENDPOINT: Endpoint URL (e.g. https://<resource>.openai.azure.com/),
|
||||
unless backend_url gives one
|
||||
AZURE_OPENAI_DEPLOYMENT_NAME: Deployment name
|
||||
OPENAI_API_VERSION: API version (e.g. 2025-03-01-preview)
|
||||
"""
|
||||
@@ -39,6 +40,8 @@ class AzureOpenAIClient(BaseLLMClient):
|
||||
"model": self.model,
|
||||
"azure_deployment": os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", self.model),
|
||||
}
|
||||
if self.base_url: # a configured endpoint, else AZURE_OPENAI_ENDPOINT
|
||||
llm_kwargs["azure_endpoint"] = self.base_url
|
||||
|
||||
for key in _PASSTHROUGH_KWARGS:
|
||||
if key in self.kwargs:
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,7 +7,7 @@ from .model_catalog import get_known_models
|
||||
# accepted without warning.
|
||||
_ANY_MODEL_PROVIDERS = (
|
||||
"ollama", "openrouter", "openai_compatible",
|
||||
"mistral", "kimi", "groq", "nvidia", "bedrock",
|
||||
"mistral", "kimi", "groq", "nvidia", "bedrock", "chatgpt",
|
||||
)
|
||||
|
||||
VALID_MODELS = {
|
||||
|
||||
+15
-14
@@ -109,7 +109,10 @@ class TradingMemoryLog:
|
||||
return ""
|
||||
|
||||
same, cross = [], []
|
||||
for e in reversed(entries):
|
||||
# Most recent by decision date, then by the later write: a backfill writes
|
||||
# older decisions last.
|
||||
newest = sorted(range(len(entries)), key=lambda i: (entries[i]["date"], i), reverse=True)
|
||||
for e in (entries[i] for i in newest):
|
||||
if len(same) >= n_same and len(cross) >= n_cross:
|
||||
break
|
||||
if e["ticker"] == ticker and len(same) < n_same:
|
||||
@@ -271,7 +274,7 @@ class TradingMemoryLog:
|
||||
return tag + "]"
|
||||
|
||||
def _apply_rotation(self, blocks: list[str]) -> list[str]:
|
||||
"""Drop oldest resolved blocks when their count exceeds max_entries.
|
||||
"""Drop the oldest-dated resolved blocks when their count exceeds max_entries.
|
||||
|
||||
Pending blocks are always kept (they represent unprocessed work).
|
||||
Returns ``blocks`` unchanged when rotation is disabled or under cap.
|
||||
@@ -279,12 +282,12 @@ class TradingMemoryLog:
|
||||
if not self._max_entries or self._max_entries <= 0:
|
||||
return blocks
|
||||
|
||||
# Tag each block with (kept, is_resolved) by parsing tag-line markers.
|
||||
# Tag each block with (block, is_resolved, decision date) from its tag line.
|
||||
decisions = []
|
||||
for block in blocks:
|
||||
stripped = block.strip()
|
||||
if not stripped:
|
||||
decisions.append((block, False))
|
||||
decisions.append((block, False, ""))
|
||||
continue
|
||||
tag_line = stripped.splitlines()[0].strip()
|
||||
is_resolved = (
|
||||
@@ -292,20 +295,18 @@ class TradingMemoryLog:
|
||||
and tag_line.endswith("]")
|
||||
and not tag_line.endswith("| pending]")
|
||||
)
|
||||
decisions.append((block, is_resolved))
|
||||
decisions.append((block, is_resolved, tag_line[1:].split("|")[0].strip()))
|
||||
|
||||
resolved_count = sum(1 for _, r in decisions if r)
|
||||
resolved_count = sum(1 for _, r, _ in decisions if r)
|
||||
if resolved_count <= self._max_entries:
|
||||
return blocks
|
||||
|
||||
to_drop = resolved_count - self._max_entries
|
||||
kept: list[str] = []
|
||||
for block, is_resolved in decisions:
|
||||
if is_resolved and to_drop > 0:
|
||||
to_drop -= 1
|
||||
continue
|
||||
kept.append(block)
|
||||
return kept
|
||||
# The oldest by decision date, then by position: a backfill writes older
|
||||
# decisions last.
|
||||
resolved = [i for i, (_, is_resolved, _) in enumerate(decisions) if is_resolved]
|
||||
dated = sorted(resolved, key=lambda i: (decisions[i][2], i))
|
||||
dropped = set(dated[:resolved_count - self._max_entries])
|
||||
return [block for i, (block, _, _) in enumerate(decisions) if i not in dropped]
|
||||
|
||||
def _parse_entry(self, raw: str) -> dict | None:
|
||||
lines = raw.strip().splitlines()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -5,6 +5,8 @@ import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from tradingagents.dataflows.symbols import normalize_symbol
|
||||
from tradingagents.dataflows.vendors.yahoo.market import get_closes
|
||||
|
||||
@@ -35,25 +37,34 @@ def resolve_benchmark(ticker: str, config: dict) -> str:
|
||||
return benchmark_map.get("", "SPY")
|
||||
|
||||
|
||||
def _now() -> pd.Timestamp:
|
||||
return pd.Timestamp.now(tz="UTC")
|
||||
|
||||
|
||||
def _by_day(closes):
|
||||
"""Closes keyed by the calendar day of their bar, missing closes dropped.
|
||||
"""Final closes keyed by the calendar day of their bar, missing closes dropped.
|
||||
|
||||
Yahoo stamps a daily bar at midnight in its market's time zone (UTC for a
|
||||
coin, New York for SPY), so two series meet on their dates, not their
|
||||
instants. A missing or non-positive close is no price at all, not a bar to
|
||||
score on.
|
||||
score on. Today's bar, in its market's time zone, is the session still
|
||||
trading: its close is the live price, so it is not scored.
|
||||
"""
|
||||
closes = closes[closes > 0]
|
||||
if getattr(closes.index, "tz", None) is not None:
|
||||
closes = closes.tz_localize(None)
|
||||
return closes
|
||||
tz = getattr(closes.index, "tz", None)
|
||||
if not closes.empty:
|
||||
closes = closes[closes.index.date < _now().tz_convert(tz).date()]
|
||||
return closes if tz is None else closes.tz_localize(None)
|
||||
|
||||
|
||||
def fetch_returns(
|
||||
ticker: str, trade_date: str, holding_days: int = 5,
|
||||
benchmark: str = "SPY",
|
||||
) -> tuple[float | None, float | None, int | None, str | None]:
|
||||
"""Fetch raw and alpha return for ticker over holding_days from trade_date.
|
||||
"""Fetch raw and alpha return for ticker over holding_days after trade_date.
|
||||
|
||||
The position is entered at the first close after ``trade_date``: a run dated
|
||||
``trade_date`` reads news published after that day's close (#1518).
|
||||
|
||||
``benchmark`` is the index used as the alpha baseline (resolved by the
|
||||
caller via ``resolve_benchmark``). Returns ``(raw_return, alpha_return,
|
||||
@@ -64,16 +75,32 @@ def fetch_returns(
|
||||
to report.
|
||||
"""
|
||||
start = datetime.strptime(trade_date, "%Y-%m-%d")
|
||||
# holding_days counts trading days, so ask for the calendar span they
|
||||
# occupy (about 7 for every 5) plus a week for holidays.
|
||||
end = start + timedelta(days=round(holding_days * 7 / 5) + 7)
|
||||
end_str = end.strftime("%Y-%m-%d")
|
||||
|
||||
# Closes for the instrument the analysis priced (XAUUSD -> GC=F, #984).
|
||||
stock = _by_day(get_closes(ticker, trade_date, end_str))
|
||||
today = _now().tz_localize(None).normalize()
|
||||
# The entry and holding_days more sessions all close after the decision date
|
||||
# and before today, with a day's slack for markets east of UTC: until then
|
||||
# the window cannot have traded and no prices are asked for.
|
||||
if (today - start).days < holding_days + 1:
|
||||
return None, None, None, None
|
||||
# holding_days counts trading days after the entry session, so ask for the
|
||||
# calendar span they occupy (about 7 for every 5) plus a week for holidays.
|
||||
end = start + timedelta(days=round((holding_days + 1) * 7 / 5) + 7)
|
||||
# From a week earlier, so the benchmark has a close on or before entry.
|
||||
bench_start = (start - timedelta(days=7)).strftime("%Y-%m-%d")
|
||||
bench = _by_day(get_closes(benchmark, bench_start, end_str))
|
||||
|
||||
def closes(until):
|
||||
# Closes for the instrument the analysis priced (XAUUSD -> GC=F, #984),
|
||||
# from the first session after the decision date.
|
||||
stock = _by_day(get_closes(ticker, trade_date, until.strftime("%Y-%m-%d")))
|
||||
return stock[stock.index > start], _by_day(get_closes(benchmark, bench_start, until.strftime("%Y-%m-%d")))
|
||||
|
||||
def traded(stock, bench):
|
||||
return len(stock) > holding_days and not bench.empty and bench.index[-1] >= stock.index[holding_days]
|
||||
|
||||
stock, bench = closes(end)
|
||||
# A window that has passed without trading through spans a closure longer than
|
||||
# the week allowed for holidays (a national holiday, a halt): ask through today.
|
||||
if not traded(stock, bench) and end < today:
|
||||
stock, bench = closes(today + timedelta(days=1))
|
||||
|
||||
# Require the full holding window to have traded. A rerun before it has
|
||||
# leaves the entry pending to retry next run, rather than settling on a
|
||||
|
||||
+27
-10
@@ -17,35 +17,52 @@ import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from tradingagents.dataflows.symbols import normalize_symbol
|
||||
|
||||
|
||||
class Position(BaseModel):
|
||||
model_config = ConfigDict(allow_inf_nan=False)
|
||||
|
||||
ticker: str = Field(description="Instrument symbol, e.g. AAPL")
|
||||
quantity: float = Field(description="Signed units held; negative is short")
|
||||
average_price: float | None = Field(default=None, description="Average entry price per unit")
|
||||
|
||||
|
||||
class PortfolioContext(BaseModel):
|
||||
model_config = ConfigDict(allow_inf_nan=False)
|
||||
|
||||
cash: float | None = Field(default=None, description="Free cash available")
|
||||
currency: str | None = Field(default=None, description="Currency label for cash and prices")
|
||||
positions: list[Position] = Field(default_factory=list)
|
||||
|
||||
def positions_in(self, ticker: str) -> list[Position]:
|
||||
"""The holdings of the instrument the run prices for ``ticker``, under any
|
||||
broker spelling (BTCUSDT, 700.HK). Holdings keep the spelling they were
|
||||
given: a symbol can also name another instrument (WTI the stock)."""
|
||||
symbol = normalize_symbol(ticker)
|
||||
return [p for p in self.positions if normalize_symbol(p.ticker) == symbol]
|
||||
|
||||
def position_in(self, ticker: str) -> Position | None:
|
||||
return next((p for p in self.positions if p.ticker.upper() == ticker.strip().upper()), None)
|
||||
held = self.positions_in(ticker)
|
||||
return held[0] if held else None
|
||||
|
||||
def render(self, ticker: str) -> str:
|
||||
"""The portfolio block for the decision agents, led by the analyzed instrument."""
|
||||
symbol = ticker.strip().upper()
|
||||
held = self.position_in(symbol)
|
||||
if held is None:
|
||||
lines = [f"- No current position in {symbol}"]
|
||||
else:
|
||||
price = f", average price {held.average_price:,.2f}" if held.average_price is not None else ""
|
||||
lines = [f"- Current position in {symbol}: {held.quantity:,.4g} units{price}"]
|
||||
symbol = normalize_symbol(ticker)
|
||||
held = self.positions_in(symbol)
|
||||
lines = []
|
||||
for position in held:
|
||||
price = f", average price {position.average_price:,.2f}" if position.average_price is not None else ""
|
||||
spelled = position.ticker.strip().upper()
|
||||
listed = f" (listed as {position.ticker})" if spelled != symbol else ""
|
||||
lines.append(f"- Current position in {symbol}: {position.quantity:,.4g} units{price}{listed}")
|
||||
if not held:
|
||||
lines.append(f"- No current position in {symbol}")
|
||||
if self.cash is not None:
|
||||
lines.append(f"- Cash available: {self.cash:,.2f}{' ' + self.currency if self.currency else ''}")
|
||||
others = [p for p in self.positions if p is not held]
|
||||
others = [p for p in self.positions if all(p is not h for h in held)]
|
||||
if others:
|
||||
lines.append("- Other positions: " + ", ".join(f"{p.ticker.upper()} {p.quantity:,.4g}" for p in others))
|
||||
return "Portfolio at the analysis date:\n" + "\n".join(lines)
|
||||
|
||||
@@ -96,6 +96,8 @@ def write_report_tree(final_state: dict, ticker: str, save_path, settings: dict
|
||||
``settings`` (``TradingAgentsGraph.run_settings()``) adds what produced the run
|
||||
to the report's header. ``html`` also writes ``complete_report.html``, one
|
||||
self-contained page of the same report (#1419).
|
||||
Reusing a directory replaces the generated report files, removing sections
|
||||
absent from this run and disabled HTML. Other files and directories are kept.
|
||||
"""
|
||||
save_path = Path(save_path)
|
||||
save_path.mkdir(parents=True, exist_ok=True)
|
||||
@@ -117,4 +119,18 @@ def write_report_tree(final_state: dict, ticker: str, save_path, settings: dict
|
||||
(save_path / "complete_report.html").write_text(
|
||||
render_report(ticker, final_state, settings, parts), encoding="utf-8"
|
||||
)
|
||||
else:
|
||||
(save_path / "complete_report.html").unlink(missing_ok=True)
|
||||
|
||||
# Reconcile only the filenames owned by this writer; never clear the tree,
|
||||
# since callers may keep notes or other artifacts alongside the reports.
|
||||
current_files = {
|
||||
(folder, filename)
|
||||
for _heading, folder, written in parts
|
||||
for _agent, filename, _text in written
|
||||
}
|
||||
for _heading, folder, agents in _SECTIONS:
|
||||
for _agent, filename, _path in agents:
|
||||
if (folder, filename) not in current_files:
|
||||
(save_path / folder / filename).unlink(missing_ok=True)
|
||||
return save_path / "complete_report.md"
|
||||
|
||||
Reference in New Issue
Block a user