mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-11-03 16:12:44 +02:00
Merge pull request #1613 from TauricResearch/v0.7.1
TradingAgents v0.7.1 release
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
.git
|
.git
|
||||||
.venv
|
.venv
|
||||||
.env
|
**/.env*
|
||||||
.claude
|
.claude
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
|
|||||||
@@ -6,6 +6,37 @@ 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).
|
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.
|
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
|
## [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.
|
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.
|
||||||
|
|||||||
@@ -32,15 +32,16 @@
|
|||||||
## News
|
## News
|
||||||
|
|
||||||
<!-- news:start -->
|
<!-- 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.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-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.
|
|
||||||
|
|
||||||
Full release notes are in [CHANGELOG.md](CHANGELOG.md).
|
Full release notes are in [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Earlier news</summary>
|
<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.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-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-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.
|
||||||
@@ -182,7 +183,7 @@ export ALPHA_VANTAGE_API_KEY=... # Alpha Vantage
|
|||||||
export TYPESAFE_API_KEY=... # Jev social-post screening (optional)
|
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`.
|
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`.
|
||||||
|
|
||||||
|
|||||||
+12
-6
@@ -11,6 +11,7 @@ from rich.markup import escape
|
|||||||
|
|
||||||
from cli.display import console
|
from cli.display import console
|
||||||
from cli.models import AnalystType, AssetType
|
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.api_key_env import get_api_key_env
|
||||||
from tradingagents.llm_clients.model_catalog import get_model_options
|
from tradingagents.llm_clients.model_catalog import get_model_options
|
||||||
from tradingagents.llm_clients.openai_client import OPENAI_COMPATIBLE_PROVIDERS, SIGNED_IN_PROVIDERS
|
from tradingagents.llm_clients.openai_client import OPENAI_COMPATIBLE_PROVIDERS, SIGNED_IN_PROVIDERS
|
||||||
@@ -36,14 +37,19 @@ _MENU_STYLE = questionary.Style([
|
|||||||
|
|
||||||
|
|
||||||
def is_valid_ticker_input(value: str) -> bool:
|
def is_valid_ticker_input(value: str) -> bool:
|
||||||
"""Whether a ticker entry is acceptable (charset + length).
|
"""Whether a ticker entry names a symbol the run can use.
|
||||||
|
|
||||||
Allows the characters Yahoo symbols use, including ``=`` for futures/forex
|
The symbol it resolves to (``XAUUSD+`` -> ``GC=F``) must pass the data
|
||||||
like ``GC=F`` and ``EURUSD=X`` (#980), and ``^`` for indices. Empty input is
|
layer's own check, so the prompt refuses exactly what the run would.
|
||||||
allowed (it defaults to SPY downstream).
|
Empty input is allowed (it defaults to SPY downstream).
|
||||||
"""
|
"""
|
||||||
v = value.strip()
|
if not value.strip():
|
||||||
return not v or (all(ch.isalnum() or ch in "._-^=" for ch in v) and len(v) <= 32)
|
return True
|
||||||
|
try:
|
||||||
|
safe_ticker_component(normalize_ticker_symbol(value))
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def get_ticker() -> str:
|
def get_ticker() -> str:
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ class TestEffortGate:
|
|||||||
"claude-haiku-4-5", "claude-haiku-5-0", "claude-haiku-4-7-preview",
|
"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.
|
# Sonnet 4.5 (and earlier) 400 on effort — only Sonnet 4.6+ supports it.
|
||||||
"claude-sonnet-4-5", "claude-sonnet-4-0",
|
"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):
|
def test_unsupported_models_do_not_receive_effort(self, monkeypatch, model):
|
||||||
@@ -39,7 +41,7 @@ class TestEffortGate:
|
|||||||
"model",
|
"model",
|
||||||
[
|
[
|
||||||
"claude-opus-4-5", "claude-opus-4-6", "claude-opus-4-7",
|
"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):
|
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.backtest import iter_grid, run_backtest, summarize
|
||||||
from tradingagents.memory import TradingMemoryLog
|
from tradingagents.memory import TradingMemoryLog
|
||||||
|
from tradingagents.memory.settlement import Settlement
|
||||||
|
|
||||||
DECISION = "Rating: Buy\n\nbuy it"
|
DECISION = "Rating: Buy\n\nbuy it"
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ class _FakeGraph:
|
|||||||
|
|
||||||
def settle_pending(self, ticker):
|
def settle_pending(self, ticker):
|
||||||
self.settled.append(ticker)
|
self.settled.append(ticker)
|
||||||
|
return Settlement()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@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"]
|
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
|
@pytest.mark.unit
|
||||||
def test_a_run_id_cannot_escape_the_results_directory(tmp_path):
|
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."""
|
"""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":
|
if ticker == "NVDA":
|
||||||
raise RuntimeError("reflector timed out")
|
raise RuntimeError("reflector timed out")
|
||||||
settled.append(ticker)
|
settled.append(ticker)
|
||||||
|
return Settlement()
|
||||||
|
|
||||||
monkeypatch.setattr(_FakeGraph, "settle_pending", _settle, raising=False)
|
monkeypatch.setattr(_FakeGraph, "settle_pending", _settle, raising=False)
|
||||||
result = run_backtest(["NVDA", "AAPL"], ["2026-01-05"], _config(tmp_path))
|
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")]
|
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
|
@pytest.mark.unit
|
||||||
def test_pending_note_appears_only_when_something_is_pending(tmp_path):
|
def test_pending_note_appears_only_when_something_is_pending(tmp_path):
|
||||||
settled = [("NVDA", "2026-01-05", DECISION, (0.1, 0.05))]
|
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 errno
|
||||||
import sys
|
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)
|
monkeypatch.setitem(sys.modules, "msvcrt", fake)
|
||||||
|
|
||||||
with open(tmp_path / "log.md.lock", "a+b") as handle, files._hold_windows(handle):
|
with open(tmp_path / "log.md.lock", "a+b") as handle, files._hold_windows(handle):
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ check of the sign-in (signature, issuer, audience, nonce, plan scope) runs for r
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import socket
|
import socket
|
||||||
import stat
|
import stat
|
||||||
import threading
|
import threading
|
||||||
@@ -165,6 +166,7 @@ def test_a_token_near_expiry_is_renewed_with_the_stored_refresh_token(home, open
|
|||||||
assert url == auth.TOKEN_URL and sent["refresh_token"] == "r1" and sent["client_id"] == CLIENT
|
assert url == auth.TOKEN_URL and sent["refresh_token"] == "r1" and sent["client_id"] == CLIENT
|
||||||
stored = json.loads(auth.AUTH_FILE.read_text())
|
stored = json.loads(auth.AUTH_FILE.read_text())
|
||||||
assert stored["tokens"]["refresh_token"] == "r2" and stored["account"]["subject"] == SUBJECT
|
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
|
assert stat.S_IMODE(auth.AUTH_FILE.stat().st_mode) == 0o600
|
||||||
|
|
||||||
|
|
||||||
@@ -245,6 +247,7 @@ def test_signing_in_by_pasting_the_redirect_saves_a_private_sign_in(home, openai
|
|||||||
stored = json.loads(auth.AUTH_FILE.read_text())
|
stored = json.loads(auth.AUTH_FILE.read_text())
|
||||||
assert stored["account"] == {"client_id": CLIENT, "email": "a@example.com", "subject": SUBJECT}
|
assert stored["account"] == {"client_id": CLIENT, "email": "a@example.com", "subject": SUBJECT}
|
||||||
assert stored["host_id"].startswith("urn:uuid:")
|
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
|
assert stat.S_IMODE(auth.AUTH_FILE.stat().st_mode) == 0o600
|
||||||
asked = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(printed[1]).query))
|
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["client_id"] == auth.DYNAMIC_CLIENT_ID and asked["agent_name_hint"] == "TradingAgents"
|
||||||
|
|||||||
@@ -83,12 +83,15 @@ class _Summary:
|
|||||||
def test_every_command_is_registered_when_run_as_a_module():
|
def test_every_command_is_registered_when_run_as_a_module():
|
||||||
"""README documents `python -m cli.main`, which executes the file top to
|
"""README documents `python -m cli.main`, which executes the file top to
|
||||||
bottom, so a command defined after the __main__ block would not exist."""
|
bottom, so a command defined after the __main__ block would not exist."""
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
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"],
|
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:]
|
assert out.returncode == 0, out.stderr[-400:]
|
||||||
# Where the terminal takes colour, help styles each option and splits
|
# Where the terminal takes colour, help styles each option and splits
|
||||||
# "--start" across escape sequences, so read the text without them.
|
# "--start" across escape sequences, so read the text without them.
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ def test_normalize_symbol_crypto_and_passthrough(raw, expected):
|
|||||||
("", True), # empty -> defaults to SPY downstream
|
("", True), # empty -> defaults to SPY downstream
|
||||||
("bad symbol!", False), # space + '!' rejected
|
("bad symbol!", False), # space + '!' rejected
|
||||||
("A" * 40, False), # too long
|
("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):
|
def test_ticker_input_validation(value, ok):
|
||||||
assert is_valid_ticker_input(value) is ok
|
assert is_valid_ticker_input(value) is ok
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ def _dist(direct=None, installer="pip"):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@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):
|
def test_the_upgrade_command_follows_the_install(tmp_path, monkeypatch):
|
||||||
clone = tmp_path / "TradingAgents"
|
clone = tmp_path / "TradingAgents"
|
||||||
(clone / ".git").mkdir(parents=True)
|
(clone / ".git").mkdir(parents=True)
|
||||||
@@ -314,6 +315,7 @@ def test_remote_text_cannot_carry_c1_or_direction_controls():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@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):
|
def test_a_uv_git_install_is_upgraded_with_uv(monkeypatch):
|
||||||
monkeypatch.delenv("TRADINGAGENTS_IN_DOCKER", raising=False)
|
monkeypatch.delenv("TRADINGAGENTS_IN_DOCKER", raising=False)
|
||||||
monkeypatch.setattr(updates.sys, "executable", "/venv/bin/python")
|
monkeypatch.setattr(updates.sys, "executable", "/venv/bin/python")
|
||||||
|
|||||||
@@ -300,6 +300,16 @@ class TestTradingMemoryLogCore:
|
|||||||
assert "Buy entry 0" not in ctx
|
assert "Buy entry 0" not in ctx
|
||||||
assert "Buy entry 5" 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):
|
def test_n_cross_limit_respected(self, tmp_path):
|
||||||
"""Only the n_cross most recent cross-ticker entries are included."""
|
"""Only the n_cross most recent cross-ticker entries are included."""
|
||||||
log = make_log(tmp_path)
|
log = make_log(tmp_path)
|
||||||
@@ -341,6 +351,16 @@ class TestTradingMemoryLogCore:
|
|||||||
dates = [e["date"] for e in entries]
|
dates = [e["date"] for e in entries]
|
||||||
assert dates == ["2026-01-03", "2026-01-04", "2026-01-05"]
|
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):
|
def test_rotation_never_prunes_pending(self, tmp_path):
|
||||||
"""Pending entries (unresolved) are kept regardless of the cap."""
|
"""Pending entries (unresolved) are kept regardless of the cap."""
|
||||||
log = TradingMemoryLog({
|
log = TradingMemoryLog({
|
||||||
@@ -1138,6 +1158,75 @@ class TestSettlementWindow:
|
|||||||
assert alpha == pytest.approx(raw - (spy["2026-01-14"] / spy["2026-01-07"] - 1))
|
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
|
@pytest.mark.unit
|
||||||
def test_a_close_that_is_not_a_price_is_skipped_not_scored(monkeypatch):
|
def test_a_close_that_is_not_a_price_is_skipped_not_scored(monkeypatch):
|
||||||
"""A zero close would score as an infinite return and be written to the memory log."""
|
"""A zero close would score as an infinite return and be written to the memory log."""
|
||||||
|
|||||||
@@ -78,6 +78,19 @@ class PolymarketFormatTests(unittest.TestCase):
|
|||||||
self.assertIn("resolves 2030-12-31", out)
|
self.assertIn("resolves 2030-12-31", out)
|
||||||
self.assertIn("1-week -4.5pp", out) # -0.045 -> -4.5pp
|
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):
|
def test_weekly_change_omitted_when_absent(self):
|
||||||
# "Open small?" has wk=None -> no 1-week clause on its line.
|
# "Open small?" has wk=None -> no 1-week clause on its line.
|
||||||
with mock.patch.object(polymarket, "_request", return_value=_SEARCH):
|
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
|
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
|
@pytest.mark.unit
|
||||||
def test_absent_context_is_reported_as_not_provided():
|
def test_absent_context_is_reported_as_not_provided():
|
||||||
notice = get_portfolio_context_from_state({"company_of_interest": "AAPL"})
|
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)
|
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
|
@pytest.mark.unit
|
||||||
def test_load_reads_a_valid_file(tmp_path):
|
def test_load_reads_a_valid_file(tmp_path):
|
||||||
good = tmp_path / "p.json"
|
good = tmp_path / "p.json"
|
||||||
|
|||||||
@@ -49,6 +49,46 @@ def test_save_reports_explicit_path(tmp_path):
|
|||||||
assert out == tmp_path / "complete_report.md"
|
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
|
@pytest.mark.unit
|
||||||
def test_save_reports_defaults_under_results_dir(tmp_path):
|
def test_save_reports_defaults_under_results_dir(tmp_path):
|
||||||
graph = object.__new__(TradingAgentsGraph)
|
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
|
@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")
|
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
|
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():
|
def test_values_do_not_break_the_columns():
|
||||||
"""Figures run to the billions; a thousands separator would split the field."""
|
"""Figures run to the billions; a thousands separator would split the field."""
|
||||||
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
|
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 _table(out)[1]["Total Assets"][-1] == "364980"
|
||||||
assert body.count(",") == out.splitlines()[3].count(",")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@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
|
"""Statements are reported in millions, but EPS is dollars per share: scaling
|
||||||
it the same way prints a real figure as zero."""
|
it the same way prints a real figure as zero."""
|
||||||
out = sec_edgar.get_income_statement("AAPL", "annual", "2024-11-15")
|
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 _table(out)[1]["Diluted EPS (USD/shares)"] == ["6.08"]
|
||||||
assert "6.08" in row
|
|
||||||
assert "USD/shares" in row or "per share" in row
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_every_row_has_one_cell_per_period():
|
def test_every_row_has_one_cell_per_column():
|
||||||
"""An untagged line still has to line up with the columns, or the table is
|
"""Every period's row lines up with the header, or the table is misread by position."""
|
||||||
misread by position."""
|
|
||||||
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
|
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
|
||||||
table = [r for r in out.splitlines() if r and not r.startswith("#")]
|
table = [r for r in out.splitlines() if r and not r.startswith("#")]
|
||||||
widths = {row.count(",") for row in table}
|
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",
|
monkeypatch.setattr(sec_edgar, "_fetch_json",
|
||||||
lambda url: TICKER_MAP if "company_tickers" in url else facts)
|
lambda url: TICKER_MAP if "company_tickers" in url else facts)
|
||||||
out = sec_edgar.get_cashflow("AAPL", "annual", "2025-03-01")
|
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):
|
def _columns(out):
|
||||||
return [line for line in out.splitlines() if line.startswith(",")][0].split(",")[1:]
|
return _table(out)[0]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -279,7 +283,7 @@ def test_a_recast_outside_the_annual_report_still_counts_from_its_filing(monkeyp
|
|||||||
|
|
||||||
def eps(date):
|
def eps(date):
|
||||||
out = sec_edgar.get_income_statement("AAPL", "annual", 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("2019-01-01") == "16.97"
|
||||||
assert eps("2020-01-01") == "2.12"
|
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")
|
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 _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
|
@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")
|
out = sec_edgar.get_cashflow("AAPL", "quarterly", "2026-09-28")
|
||||||
|
|
||||||
assert _columns(out) == ["2026-06-30", "2026-06-30 (6 months)"]
|
assert _columns(out) == ["2026-06-30", "2026-06-30 (6 months)"]
|
||||||
rows = {r.split(",")[0]: r.split(",")[1:] for r in out.splitlines()[3:]}
|
columns = _table(out)[1]
|
||||||
assert rows["Operating Cash Flow"] == ["", "120"]
|
assert columns["Operating Cash Flow"] == ["", "120"]
|
||||||
assert rows["Capital Expenditure"] == ["10", ""]
|
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
|
@pytest.mark.unit
|
||||||
def test_yfinance_keeps_its_cache_out_of_the_users_home():
|
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 pathlib import Path
|
||||||
|
|
||||||
from yfinance.cache import _TzDBManager
|
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
|
@pytest.mark.unit
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ TRADE_DATE = "2026-08-14"
|
|||||||
(None, TRADE_DATE), # omitted
|
(None, TRADE_DATE), # omitted
|
||||||
("Sept 1", TRADE_DATE), # unparseable
|
("Sept 1", TRADE_DATE), # unparseable
|
||||||
("", TRADE_DATE),
|
("", TRADE_DATE),
|
||||||
|
("2026-8-1", "2026-08-01"), # unpadded: served in its canonical form
|
||||||
])
|
])
|
||||||
def test_as_of_takes_the_earlier_date(requested, expected):
|
def test_as_of_takes_the_earlier_date(requested, expected):
|
||||||
assert as_of(requested, TRADE_DATE) == 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
|
@pytest.mark.unit
|
||||||
def test_as_of_without_a_trade_date_passes_the_request_through():
|
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-09-14", "") == "2026-09-14"
|
||||||
|
assert as_of("2026-9-14", "") == "2026-09-14"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@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-09-14", ("2026-08-01", TRADE_DATE)), # end clamped
|
||||||
("2026-08-01", "2026-08-10", ("2026-08-01", "2026-08-10")), # inside: unchanged
|
("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-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):
|
def test_as_of_window(start, end, expected):
|
||||||
assert as_of_window(start, end, TRADE_DATE) == 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)
|
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
|
@pytest.mark.unit
|
||||||
def test_direct_call_without_state_is_unchanged():
|
def test_direct_call_without_state_is_unchanged():
|
||||||
with mock.patch.object(tools, "route_to_vendor", return_value="ok") as routed:
|
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
|
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
|
@pytest.mark.unit
|
||||||
def test_a_window_whose_start_is_not_a_date_is_refused():
|
def test_a_window_whose_start_is_not_a_date_is_refused():
|
||||||
with pytest.raises(ValueError, match="start_date"):
|
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"):
|
with pytest.raises(ValueError, match="YYYY-MM-DD"):
|
||||||
object.__new__(TradingAgentsGraph).propagate("AAPL", bad)
|
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
|
@pytest.mark.unit
|
||||||
|
|||||||
@@ -73,3 +73,47 @@ class TestTool:
|
|||||||
{"symbol": "COF", "curr_date": "2026-05-20"}
|
{"symbol": "COF", "curr_date": "2026-05-20"}
|
||||||
)
|
)
|
||||||
assert "Verified market data snapshot for COF" in out
|
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."""
|
"""TradingAgents: multi-agent LLM financial trading framework."""
|
||||||
|
|
||||||
__version__ = "0.7.0"
|
__version__ = "0.7.1"
|
||||||
|
|
||||||
from dotenv import find_dotenv, load_dotenv
|
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.
|
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 langchain_core.tools import tool
|
||||||
from langgraph.prebuilt import InjectedState
|
from langgraph.prebuilt import InjectedState
|
||||||
@@ -114,7 +114,7 @@ def get_fundamentals(
|
|||||||
@tool
|
@tool
|
||||||
def get_balance_sheet(
|
def get_balance_sheet(
|
||||||
ticker: Annotated[str, InjectedState("company_of_interest")],
|
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,
|
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
|
||||||
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -133,7 +133,7 @@ def get_balance_sheet(
|
|||||||
@tool
|
@tool
|
||||||
def get_cashflow(
|
def get_cashflow(
|
||||||
ticker: Annotated[str, InjectedState("company_of_interest")],
|
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,
|
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
|
||||||
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -152,7 +152,7 @@ def get_cashflow(
|
|||||||
@tool
|
@tool
|
||||||
def get_income_statement(
|
def get_income_statement(
|
||||||
ticker: Annotated[str, InjectedState("company_of_interest")],
|
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,
|
curr_date: Annotated[str, "current date you are trading at, yyyy-mm-dd"] = None,
|
||||||
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
trade_date: Annotated[str, InjectedState("trade_date")] = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ cell rather than a position carried forward.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -126,6 +127,19 @@ class BacktestSummary:
|
|||||||
return "\n".join(lines)
|
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(
|
def run_backtest(
|
||||||
tickers: list[str],
|
tickers: list[str],
|
||||||
dates: list[str],
|
dates: list[str],
|
||||||
@@ -143,11 +157,15 @@ def run_backtest(
|
|||||||
interrupted sweep resumes by being run again. ``progress(done, total,
|
interrupted sweep resumes by being run again. ``progress(done, total,
|
||||||
ticker, date)`` is called before each cell that runs.
|
ticker, date)`` is called before each cell that runs.
|
||||||
"""
|
"""
|
||||||
|
runs = Path(config["results_dir"]) / "backtest"
|
||||||
|
if run_id:
|
||||||
# run_id becomes a path segment, so it is validated like a ticker: an
|
# 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.
|
# 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_id = safe_ticker_component(run_id)
|
||||||
run_dir = Path(config["results_dir"]) / "backtest" / run_id
|
(runs / run_id).mkdir(parents=True, exist_ok=True)
|
||||||
run_dir.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),
|
run_config = {**config, "results_dir": str(run_dir),
|
||||||
"memory_log_path": str(run_dir / "trading_memory.md")}
|
"memory_log_path": str(run_dir / "trading_memory.md")}
|
||||||
|
|
||||||
@@ -174,7 +192,11 @@ def run_backtest(
|
|||||||
# last cell would stay pending without this pass.
|
# last cell would stay pending without this pass.
|
||||||
for ticker in tickers:
|
for ticker in tickers:
|
||||||
try:
|
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
|
except Exception as exc: # reflection calls an LLM; one failure is not the sweep's
|
||||||
logger.warning("Settling %s failed: %s", ticker, exc)
|
logger.warning("Settling %s failed: %s", ticker, exc)
|
||||||
result.settlement_failures.append((ticker, str(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.
|
"""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
|
- ``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
|
- ``in_window`` trims dated items (news, StockTwits, Reddit) to the analysis
|
||||||
window: timestamps normalized to UTC, the upper bound exclusive at midnight
|
window: timestamps normalized to UTC, the upper bound exclusive at midnight
|
||||||
after ``end``, and an undated item kept only when the window reaches the
|
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.
|
"""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
|
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.
|
``trade_date`` (a direct call outside a graph run) passes the request through.
|
||||||
"""
|
"""
|
||||||
if not trade_date:
|
|
||||||
return requested
|
|
||||||
parsed = _parse(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]:
|
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.
|
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
|
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)
|
start, old_end = _parse(start_date), _parse(end_date)
|
||||||
if start is None:
|
if start is None:
|
||||||
raise ValueError(f"start_date {start_date!r} is not a date; give it as YYYY-MM-DD")
|
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)
|
end = as_of(end_date, trade_date)
|
||||||
if end == end_date or start <= _parse(end):
|
end_dt = _parse(end)
|
||||||
return start_date, 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)
|
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:
|
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)
|
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK if wait else msvcrt.LK_NBLCK, 1)
|
||||||
break
|
break
|
||||||
except OSError as exc:
|
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
|
yield False # another writer holds it
|
||||||
return
|
return
|
||||||
# LK_LOCK gives up after about ten seconds of another writer's hold;
|
# LK_LOCK gives up after about ten seconds of another writer's hold;
|
||||||
# any other failure is not a wait.
|
# any other failure is not a wait.
|
||||||
if exc.errno != errno.EDEADLOCK:
|
if exc.errno != errno.EDEADLK:
|
||||||
raise
|
raise
|
||||||
try:
|
try:
|
||||||
yield True
|
yield True
|
||||||
|
|||||||
+17
-1
@@ -11,6 +11,7 @@ outcomes (a "Yes" at 0.76 means the market prices a 76% chance).
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
import requests
|
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:
|
def get_prediction_markets(topic: str, limit: int | None = None, as_of_date: str | None = None) -> str:
|
||||||
"""Return live prediction-market probabilities for an event topic.
|
"""Return live prediction-market probabilities for an event topic.
|
||||||
|
|
||||||
@@ -142,7 +158,7 @@ def get_prediction_markets(topic: str, limit: int | None = None, as_of_date: str
|
|||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
lines.append(
|
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})"
|
f"(${volume:,.0f} volume, resolves {end_date}{wk_str})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+27
-14
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import date, datetime
|
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()})
|
periods = sorted({(end, index) for spans_of in chosen.values() for end, index in spans_of.items()})
|
||||||
if not periods:
|
if not periods:
|
||||||
raise NoMarketDataError(ticker, ticker, f"no {freq} {title.lower()} filed by {as_of_date}")
|
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 = (
|
header = (
|
||||||
f"# {title} for {ticker.upper()} ({freq}), USD in millions unless the row says otherwise\n"
|
f"# {title} for {ticker.upper()} ({freq}), USD in millions unless the column says otherwise\n"
|
||||||
f"# SEC EDGAR facts filed on or before {as_of_date}, at the values filed then\n\n"
|
f"# SEC EDGAR facts filed on or before {as_of_date}, at the values filed then\n"
|
||||||
|
+ (f"# Not tagged by this filer: {', '.join(untagged)}\n" if untagged else "")
|
||||||
|
+ "\n"
|
||||||
)
|
)
|
||||||
rows = [",".join([""] + [end + names[index] for end, index in periods])]
|
# One row per period, so every figure sits beside its own date: across a
|
||||||
for label, (values, unit) in lines.items():
|
# filer's whole history a row per line item ran to dozens of columns and was
|
||||||
# Every row spans the same columns, or a reader lines the table up wrong.
|
# read a column off (#1517).
|
||||||
if not values:
|
rows = [",".join(["Period"] + [label if unit == "USD" else f"{label} ({unit})"
|
||||||
rows.append(",".join([label] + ["unavailable (not tagged by this filer)"] * len(periods)))
|
for label, (_, unit) in tagged.items()])]
|
||||||
continue
|
|
||||||
name = label if unit == "USD" else f"{label} ({unit})"
|
|
||||||
# Plain numbers: a thousands separator would split the CSV field.
|
|
||||||
cells = []
|
|
||||||
for end, index in periods:
|
for end, index in periods:
|
||||||
|
cells = []
|
||||||
|
for label, (values, unit) in tagged.items():
|
||||||
value = values.get((end, index)) if chosen[label].get(end) == index else None
|
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}")
|
cells.append("" if value is None else _millions(value) if unit == "USD" else f"{value:.2f}")
|
||||||
rows.append(",".join([name] + cells))
|
rows.append(",".join([end + names[index]] + cells))
|
||||||
return header + "\n".join(rows) + "\n"
|
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:
|
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``."""
|
"""Balance sheet as filed on or before ``as_of_date``."""
|
||||||
return _statement("balance_sheet", ticker, freq, as_of_date, "Balance Sheet")
|
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 logging
|
||||||
|
import math
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import yfinance as yf
|
import yfinance as yf
|
||||||
@@ -14,6 +16,19 @@ logger = logging.getLogger(__name__)
|
|||||||
YAHOO_HOST = "https://query2.finance.yahoo.com"
|
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:
|
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.
|
"""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.errors import NoMarketDataError, VendorError
|
||||||
from tradingagents.dataflows.symbols import normalize_symbol
|
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
|
from tradingagents.dataflows.vendors.yahoo.ohlcv import _assert_ohlcv_not_stale, load_ohlcv
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -48,11 +48,9 @@ def get_YFin_data_online(
|
|||||||
# turns into one clear unavailable signal (#1021).
|
# turns into one clear unavailable signal (#1021).
|
||||||
_assert_ohlcv_not_stale(data, end_date, symbol, canonical)
|
_assert_ohlcv_not_stale(data, end_date, symbol, canonical)
|
||||||
|
|
||||||
# Round numerical values to 2 decimal places for cleaner display
|
for col in ("Open", "High", "Low", "Close", "Adj Close"):
|
||||||
numeric_columns = ["Open", "High", "Low", "Close", "Adj Close"]
|
|
||||||
for col in numeric_columns:
|
|
||||||
if col in data.columns:
|
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()
|
csv_string = data.to_csv()
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -17,6 +17,7 @@ from stockstats import wrap
|
|||||||
|
|
||||||
from tradingagents.dataflows.errors import NoMarketDataError
|
from tradingagents.dataflows.errors import NoMarketDataError
|
||||||
from tradingagents.dataflows.symbols import normalize_symbol
|
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
|
from tradingagents.dataflows.vendors.yahoo.ohlcv import load_ohlcv
|
||||||
|
|
||||||
# A fixed, common indicator set so the snapshot is the same shape every run.
|
# 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,)):
|
if isinstance(value, (int,)):
|
||||||
return str(value)
|
return str(value)
|
||||||
if isinstance(value, float):
|
if isinstance(value, float):
|
||||||
return f"{value:.2f}"
|
return price_text(value)
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ class TradingAgentsGraph:
|
|||||||
memory log's lessons are not here: the graph's Memory Log step settles
|
memory log's lessons are not here: the graph's Memory Log step settles
|
||||||
and loads them alongside the analysts (see ``_memory_step``).
|
and loads them alongside the analysts (see ``_memory_step``).
|
||||||
"""
|
"""
|
||||||
|
trade_date = _validate_trade_date(trade_date)
|
||||||
return self.propagator.create_initial_state(
|
return self.propagator.create_initial_state(
|
||||||
company_name,
|
company_name,
|
||||||
trade_date,
|
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
|
# 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"``
|
# 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``,
|
# (#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 = {
|
_EFFORT_EXACT = {
|
||||||
"claude-mythos-preview", # non-standard preview name; effort-capable
|
"claude-mythos-preview", # non-standard preview name; effort-capable
|
||||||
"claude-mythos-5", # Fable 5 twin (Project Glasswing); 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)}
|
_EFFORT_MIN_VERSION = {"opus": (4, 5), "sonnet": (4, 6), "fable": (5, 0)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from langchain_openai import AzureChatOpenAI
|
|||||||
from .base_client import BaseLLMClient, normalize_content
|
from .base_client import BaseLLMClient, normalize_content
|
||||||
|
|
||||||
_PASSTHROUGH_KWARGS = (
|
_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",
|
"callbacks", "http_client", "http_async_client",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,7 +23,8 @@ class AzureOpenAIClient(BaseLLMClient):
|
|||||||
|
|
||||||
Requires environment variables:
|
Requires environment variables:
|
||||||
AZURE_OPENAI_API_KEY: API key
|
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
|
AZURE_OPENAI_DEPLOYMENT_NAME: Deployment name
|
||||||
OPENAI_API_VERSION: API version (e.g. 2025-03-01-preview)
|
OPENAI_API_VERSION: API version (e.g. 2025-03-01-preview)
|
||||||
"""
|
"""
|
||||||
@@ -39,6 +40,8 @@ class AzureOpenAIClient(BaseLLMClient):
|
|||||||
"model": self.model,
|
"model": self.model,
|
||||||
"azure_deployment": os.environ.get("AZURE_OPENAI_DEPLOYMENT_NAME", 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:
|
for key in _PASSTHROUGH_KWARGS:
|
||||||
if key in self.kwargs:
|
if key in self.kwargs:
|
||||||
|
|||||||
+15
-14
@@ -109,7 +109,10 @@ class TradingMemoryLog:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
same, cross = [], []
|
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:
|
if len(same) >= n_same and len(cross) >= n_cross:
|
||||||
break
|
break
|
||||||
if e["ticker"] == ticker and len(same) < n_same:
|
if e["ticker"] == ticker and len(same) < n_same:
|
||||||
@@ -271,7 +274,7 @@ class TradingMemoryLog:
|
|||||||
return tag + "]"
|
return tag + "]"
|
||||||
|
|
||||||
def _apply_rotation(self, blocks: list[str]) -> list[str]:
|
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).
|
Pending blocks are always kept (they represent unprocessed work).
|
||||||
Returns ``blocks`` unchanged when rotation is disabled or under cap.
|
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:
|
if not self._max_entries or self._max_entries <= 0:
|
||||||
return blocks
|
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 = []
|
decisions = []
|
||||||
for block in blocks:
|
for block in blocks:
|
||||||
stripped = block.strip()
|
stripped = block.strip()
|
||||||
if not stripped:
|
if not stripped:
|
||||||
decisions.append((block, False))
|
decisions.append((block, False, ""))
|
||||||
continue
|
continue
|
||||||
tag_line = stripped.splitlines()[0].strip()
|
tag_line = stripped.splitlines()[0].strip()
|
||||||
is_resolved = (
|
is_resolved = (
|
||||||
@@ -292,20 +295,18 @@ class TradingMemoryLog:
|
|||||||
and tag_line.endswith("]")
|
and tag_line.endswith("]")
|
||||||
and not tag_line.endswith("| pending]")
|
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:
|
if resolved_count <= self._max_entries:
|
||||||
return blocks
|
return blocks
|
||||||
|
|
||||||
to_drop = resolved_count - self._max_entries
|
# The oldest by decision date, then by position: a backfill writes older
|
||||||
kept: list[str] = []
|
# decisions last.
|
||||||
for block, is_resolved in decisions:
|
resolved = [i for i, (_, is_resolved, _) in enumerate(decisions) if is_resolved]
|
||||||
if is_resolved and to_drop > 0:
|
dated = sorted(resolved, key=lambda i: (decisions[i][2], i))
|
||||||
to_drop -= 1
|
dropped = set(dated[:resolved_count - self._max_entries])
|
||||||
continue
|
return [block for i, (block, _, _) in enumerate(decisions) if i not in dropped]
|
||||||
kept.append(block)
|
|
||||||
return kept
|
|
||||||
|
|
||||||
def _parse_entry(self, raw: str) -> dict | None:
|
def _parse_entry(self, raw: str) -> dict | None:
|
||||||
lines = raw.strip().splitlines()
|
lines = raw.strip().splitlines()
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import logging
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
from tradingagents.dataflows.symbols import normalize_symbol
|
from tradingagents.dataflows.symbols import normalize_symbol
|
||||||
from tradingagents.dataflows.vendors.yahoo.market import get_closes
|
from tradingagents.dataflows.vendors.yahoo.market import get_closes
|
||||||
|
|
||||||
@@ -35,18 +37,24 @@ def resolve_benchmark(ticker: str, config: dict) -> str:
|
|||||||
return benchmark_map.get("", "SPY")
|
return benchmark_map.get("", "SPY")
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> pd.Timestamp:
|
||||||
|
return pd.Timestamp.now(tz="UTC")
|
||||||
|
|
||||||
|
|
||||||
def _by_day(closes):
|
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
|
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
|
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
|
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]
|
closes = closes[closes > 0]
|
||||||
if getattr(closes.index, "tz", None) is not None:
|
tz = getattr(closes.index, "tz", None)
|
||||||
closes = closes.tz_localize(None)
|
if not closes.empty:
|
||||||
return closes
|
closes = closes[closes.index.date < _now().tz_convert(tz).date()]
|
||||||
|
return closes if tz is None else closes.tz_localize(None)
|
||||||
|
|
||||||
|
|
||||||
def fetch_returns(
|
def fetch_returns(
|
||||||
@@ -67,18 +75,32 @@ def fetch_returns(
|
|||||||
to report.
|
to report.
|
||||||
"""
|
"""
|
||||||
start = datetime.strptime(trade_date, "%Y-%m-%d")
|
start = datetime.strptime(trade_date, "%Y-%m-%d")
|
||||||
|
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
|
# 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.
|
# calendar span they occupy (about 7 for every 5) plus a week for holidays.
|
||||||
end = start + timedelta(days=round((holding_days + 1) * 7 / 5) + 7)
|
end = start + timedelta(days=round((holding_days + 1) * 7 / 5) + 7)
|
||||||
end_str = end.strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
# Closes for the instrument the analysis priced (XAUUSD -> GC=F, #984),
|
|
||||||
# from the first session after the decision date.
|
|
||||||
stock = _by_day(get_closes(ticker, trade_date, end_str))
|
|
||||||
stock = stock[stock.index > start]
|
|
||||||
# From a week earlier, so the benchmark has a close on or before entry.
|
# 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_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
|
# 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
|
# leaves the entry pending to retry next run, rather than settling on a
|
||||||
|
|||||||
+27
-10
@@ -17,35 +17,52 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
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):
|
class Position(BaseModel):
|
||||||
|
model_config = ConfigDict(allow_inf_nan=False)
|
||||||
|
|
||||||
ticker: str = Field(description="Instrument symbol, e.g. AAPL")
|
ticker: str = Field(description="Instrument symbol, e.g. AAPL")
|
||||||
quantity: float = Field(description="Signed units held; negative is short")
|
quantity: float = Field(description="Signed units held; negative is short")
|
||||||
average_price: float | None = Field(default=None, description="Average entry price per unit")
|
average_price: float | None = Field(default=None, description="Average entry price per unit")
|
||||||
|
|
||||||
|
|
||||||
class PortfolioContext(BaseModel):
|
class PortfolioContext(BaseModel):
|
||||||
|
model_config = ConfigDict(allow_inf_nan=False)
|
||||||
|
|
||||||
cash: float | None = Field(default=None, description="Free cash available")
|
cash: float | None = Field(default=None, description="Free cash available")
|
||||||
currency: str | None = Field(default=None, description="Currency label for cash and prices")
|
currency: str | None = Field(default=None, description="Currency label for cash and prices")
|
||||||
positions: list[Position] = Field(default_factory=list)
|
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:
|
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:
|
def render(self, ticker: str) -> str:
|
||||||
"""The portfolio block for the decision agents, led by the analyzed instrument."""
|
"""The portfolio block for the decision agents, led by the analyzed instrument."""
|
||||||
symbol = ticker.strip().upper()
|
symbol = normalize_symbol(ticker)
|
||||||
held = self.position_in(symbol)
|
held = self.positions_in(symbol)
|
||||||
if held is None:
|
lines = []
|
||||||
lines = [f"- No current position in {symbol}"]
|
for position in held:
|
||||||
else:
|
price = f", average price {position.average_price:,.2f}" if position.average_price is not None else ""
|
||||||
price = f", average price {held.average_price:,.2f}" if held.average_price is not None else ""
|
spelled = position.ticker.strip().upper()
|
||||||
lines = [f"- Current position in {symbol}: {held.quantity:,.4g} units{price}"]
|
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:
|
if self.cash is not None:
|
||||||
lines.append(f"- Cash available: {self.cash:,.2f}{' ' + self.currency if self.currency else ''}")
|
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:
|
if others:
|
||||||
lines.append("- Other positions: " + ", ".join(f"{p.ticker.upper()} {p.quantity:,.4g}" for p in 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)
|
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
|
``settings`` (``TradingAgentsGraph.run_settings()``) adds what produced the run
|
||||||
to the report's header. ``html`` also writes ``complete_report.html``, one
|
to the report's header. ``html`` also writes ``complete_report.html``, one
|
||||||
self-contained page of the same report (#1419).
|
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 = Path(save_path)
|
||||||
save_path.mkdir(parents=True, exist_ok=True)
|
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(
|
(save_path / "complete_report.html").write_text(
|
||||||
render_report(ticker, final_state, settings, parts), encoding="utf-8"
|
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"
|
return save_path / "complete_report.md"
|
||||||
|
|||||||
Reference in New Issue
Block a user