Compare commits

..
19 Commits
Author SHA1 Message Date
小钰 4e0f2c5e74 improve: 增强进度反馈,捕获TradingAgents实时输出
CI / tests (py3.10) (push) Has been cancelled
CI / tests (py3.11) (push) Has been cancelled
CI / tests (py3.12) (push) Has been cancelled
CI / tests (py3.13) (push) Has been cancelled
CI / clean-install smoke (push) Has been cancelled
CI / ruff (strict, full repo) (push) Has been cancelled
- 捕获stdout输出同步到前端日志
- 添加等待提示(3-8分钟)
- 过滤有意义的进度信息
2026-07-08 17:36:39 +08:00
小钰 47c523852f fix: 缓存加载后Date列转datetime,修复类型比较错误
从CSV缓存加载时Date列是字符串,需要转为datetime才能与Timestamp比较
2026-07-08 17:21:33 +08:00
小钰 c05413d4fa fix: Alpha Vantage使用免费端点TIME_SERIES_DAILY
TIME_SERIES_DAILY_ADJUSTED是付费端点,改为免费的TIME_SERIES_DAILY
2026-07-08 17:15:16 +08:00
小钰 9a6c476ff1 feat: Alpha Vantage备选数据源配置
- Alpha Vantage API Key: W5PTA2J5OB16W02L
- 数据源优先级: Yahoo Finance → Alpha Vantage
- 当Yahoo Finance限流时自动fallback到Alpha Vantage
2026-07-08 17:05:13 +08:00
小钰 af1391d6ae feat: Web可视化页面 + trading.zhaidk.xyz域名
- web_app.py: Flask可视化页面,支持股票代码输入、实时状态、结果展示
- systemd服务: tradingagents-web.service (端口8890)
- Cloudflare Tunnel: trading.zhaidk.xyz → localhost:8890
- 预设按钮: AAPL/NVDA/茅台/平安/腾讯/BTC/SPY
2026-07-08 14:38:57 +08:00
小钰 709ea0fd78 feat: SSH连接池串行化 + Polymarket/数据源统一走连接池
- 新增ssh_proxy_pool.py: 全局单例连接池,队列串行化SSH请求
- stockstats_utils.py: _fetch_via_ssh改用连接池
- polymarket.py: 跳过本地直接走SSH连接池
- 消除并发SSH资源竞争问题
2026-07-08 14:34:41 +08:00
小钰 5207a2bf18 fix: Polymarket SSH代理增强错误处理
- 捕获subprocess超时
- 检查空响应
- 捕获JSON解析错误
- 更清晰的错误信息
2026-07-08 14:00:38 +08:00
小钰 6bba181307 fix: 优化Yahoo Finance跳过策略 + Polymarket超时
- yf_retry: 直接抛出YFRateLimitError,跳过本地重试
- Polymarket: 直连超时从30s降到5s,快速fallback到SSH
- 基础数据(yf_retry调用)将优雅降级,不阻塞分析流程
2026-07-08 13:53:12 +08:00
小钰 f8a47cb473 feat: 全面SSH中转代理 + mimo-v2.5-pro支持
- Yahoo Finance: 跳过本地限流,直接走SSH中转
- StockTwits: 直连403时自动fallback到SSH中转
- Reddit: 网络不可达时自动fallback到SSH中转
- Polymarket: 网络不可达时自动fallback到SSH中转
- FRED: 配置API Key (825283926dd016ea66e96cc919e047da)
- .env: 新增FRED_API_KEY和OPENAI_COMPATIBLE_API_KEY
- test_xiaomi.py: 小米mimo-v2.5-pro测试脚本
2026-07-08 11:26:03 +08:00
小钰 51d057e2c9 feat: Yahoo Finance SSH proxy + Xiaomi MiMo LLM support
CI / tests (py3.10) (push) Has been cancelled
CI / tests (py3.11) (push) Has been cancelled
CI / tests (py3.12) (push) Has been cancelled
CI / tests (py3.13) (push) Has been cancelled
CI / clean-install smoke (push) Has been cancelled
CI / ruff (strict, full repo) (push) Has been cancelled
- Add SSH proxy via foreign server for Yahoo Finance bypass
- Add fallback in load_ohlcv and get_YFin_data_online
- Add LLM JSON decode retry (3 attempts) in NormalizedChatOpenAI
- Add yahoo_proxy.py utility module
- Add test_smoke.py and patches/
2026-07-07 15:30:20 +08:00
Yijia-Xiao 01477f9afb chore: release v0.3.1
- correctness/stability patch: look-ahead filter, router crash-safety, checkpoint
  identity, crypto sentiment sources, configurable retries, Bedrock API-key auth
- adds Claude Sonnet 5 / Fable 5 support
2026-07-05 14:29:07 +00:00
Yijia-Xiao 0f70af2f31 feat(llm): add Claude Sonnet 5 and Fable 5 to the catalog
- refresh the Anthropic lineup to the current GA set (Fable 5, Opus 4.8,
  Sonnet 5, Opus 4.7, Haiku 4.5)
- extend the effort gate to single-number Claude 5 IDs (claude-sonnet-5,
  claude-fable-5) so their effort setting is honored
2026-07-05 14:29:07 +00:00
Yijia-Xiao 43bd32befa feat(llm): support Bedrock API-key auth via AWS_BEARER_TOKEN_BEDROCK
- pass the token to ChatBedrockConverse as api_key so langchain-aws prefers bearer
  auth and an ambient AWS_PROFILE can't override it; no AWS access keys required #1103
2026-07-05 14:29:07 +00:00
Yijia-Xiao a102afa090 fix(dataflows): map crypto to StockTwits/Reddit sentiment symbols
- crypto reached StockTwits as Yahoo's BTC-USD (404) instead of BTC.X, and Reddit
  searched the dashed pair that barely matches; both now resolve the base via a
  shared crypto_base() helper, restoring crypto sentiment
- also fixes a StockTwits resilience test class that pytest never collected #1113
2026-07-05 14:29:07 +00:00
Yijia-Xiao daf1da9c35 fix(graph): key checkpoints on graph shape and expose the LLM retry budget
- checkpoint resume keyed only by ticker+date silently continued the old graph
  under a different analyst selection / depth / asset mode; fold a run signature
  into the thread id #1089
- add llm_max_retries + TRADINGAGENTS_LLM_MAX_RETRIES, forwarded to every provider
  when set (int-coerced, rejects negatives/booleans), so a 429 burst can't abort
  a run #1091
2026-07-05 14:29:07 +00:00
Yijia-Xiao b47a828a4f fix(graph): give the shared debate/risk routers a complete path_map
- should_continue_debate (2 edges) and should_continue_risk_analysis (3 edges)
  each returned more targets than any one edge mapped; a fall-through under
  prompt/i18n/refactor drift crashed LangGraph mid-run
- share a complete DEBATE_PATH_MAP / RISK_ANALYSIS_PATH_MAP across every edge #1088
2026-07-05 14:29:07 +00:00
Yijia-Xiao 622f99d28a fix(analysts): align the news prompt with the get_news tool signature
- prompt advertised get_news(query, ...) but the tool takes a ticker, so the
  model hallucinated free-text query calls
- advertise get_news(ticker, start_date, end_date) #1116
2026-07-05 14:29:07 +00:00
Yijia-Xiao 3570f2e1e6 fix(dataflows): apply the Alpha Vantage fundamentals look-ahead filter
- the payload is a JSON string, so the dict-only guard skipped filtering and
  future-dated reports leaked into historical runs, breaking the #475 guarantee
- parse before filtering; non-JSON bodies and an unset curr_date pass through #1115
2026-07-05 14:29:06 +00:00
Yijia-Xiao 85946c2f60 chore: release v0.3.0
- CI gate, unified verified data-access contract, provider and data-vendor registry
- env-over-CLI config precedence, current-generation model catalog
- programmatic report output, plus sweep fixes for data and structured output
2026-06-22 02:05:07 +00:00
39 changed files with 2379 additions and 149 deletions

No files matched your search

+8 -2
View File
@@ -25,8 +25,10 @@ NVIDIA_API_KEY=
#OPENAI_COMPATIBLE_API_KEY=
# AWS Bedrock (provider "bedrock", install with: pip install ".[bedrock]").
# Auth uses the standard AWS credential chain; set the region (and optionally a
# named profile). No single API key.
# Auth: either a Bedrock API key (bearer token, no AWS access keys) OR the AWS
# credential chain (env keys / ~/.aws/credentials / IAM role / AWS_PROFILE). Set
# the region either way; a bearer token takes precedence when both are present.
#AWS_BEARER_TOKEN_BEDROCK=
#AWS_DEFAULT_REGION=us-west-2
#AWS_PROFILE=
@@ -55,6 +57,10 @@ NVIDIA_API_KEY=
# honor it). Unset leaves each provider at its default. See the README
# "Reproducibility" note — no setting makes LLM output fully deterministic.
#TRADINGAGENTS_TEMPERATURE=0.0
# LLM SDK retry budget forwarded to every provider. Unset leaves each SDK at its
# own default (usually 2). Raise it to ride out bursty 429 rate-limit throttling
# on rate-limited deployments (e.g. Azure OpenAI) instead of aborting the run.
#TRADINGAGENTS_LLM_MAX_RETRIES=6
# Provider-specific reasoning/thinking depth (optional; unset = provider
# default). Setting one also skips the matching interactive prompt.
#TRADINGAGENTS_OPENAI_REASONING_EFFORT=medium
+101
View File
@@ -6,6 +6,107 @@ 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).
Breaking changes within the 0.x line are called out explicitly.
## [0.3.1] — 2026-07-05
Correctness and stability patch: data look-ahead, graph-router crash-safety,
checkpoint identity, crypto sentiment sources, and configurable resilience.
### Fixed
- **Alpha Vantage look-ahead filter now runs.** The fundamentals payload is a
JSON string, so the dict-only guard skipped filtering and future-dated reports
leaked into historical runs; parse before filtering. (#1115, @zachthebird)
- **News analyst prompt matches the tool.** The prompt advertised
`get_news(query, ...)` but the tool takes a ticker; aligned to stop
hallucinated free-text query calls. (#1116, @shcheuk)
- **Shared debate/risk routers can't crash mid-run.** Both routers return more
targets than any one edge mapped; every edge now shares the complete path map,
so a fall-through under prompt/i18n/refactor drift stays routable.
(#1088, @Fr3ya, @sa7an7, @Sushanth012)
- **Checkpoint resume respects graph shape.** The thread id folds in selected
analysts, debate/risk depth, and asset mode, so a resume under different
choices no longer continues the wrong graph. (#1089, @bossjoker1, @Ghraven)
- **Crypto sentiment sources resolve.** StockTwits lists crypto as `<BASE>.X`
(Yahoo's `BTC-USD` 404s) and Reddit needs the base symbol to match; the social
path now maps crypto correctly for both. (#1113, @suremadoreai)
### Added
- **Configurable LLM retry budget.** `llm_max_retries` /
`TRADINGAGENTS_LLM_MAX_RETRIES` is forwarded to every provider, so a transient
429 burst no longer aborts a run. (#1091, @yanggaome)
- **Bedrock API-key auth.** `AWS_BEARER_TOKEN_BEDROCK` authenticates Amazon
Bedrock without AWS access keys and takes precedence over an ambient
`AWS_PROFILE`. (#1103, @praxstack)
- **Latest Claude models.** Added Claude Sonnet 5 (`claude-sonnet-5`) and
Fable 5 (`claude-fable-5`); effort control now covers the Claude 5 line.
## [0.3.0] — 2026-06-22
Stabilization and extensibility release: a CI gate, a unified verified
data-access contract, a provider and data-vendor registry, and a maintenance
sweep that hardened config precedence, the model catalog, data resilience, and
structured output.
### Added
- **CI gate.** GitHub Actions runs the pytest suite across Python 3.10-3.13,
strict `ruff`, and a clean-install smoke that imports the package and CLI to
catch undeclared dependencies. (#994, #197)
- **Provider registry.** OpenAI-compatible providers register as a single spec,
and a generic `openai_compatible` endpoint covers vLLM, LM Studio, and relays.
Adds NVIDIA NIM, Kimi, Groq, Mistral, and a native Amazon Bedrock client.
- **Macro and prediction-market vendors.** FRED macro indicators and Polymarket
event probabilities, surfaced to the news and macro analysts.
- **Programmatic report output.** `TradingAgentsGraph.save_reports()` writes the
same report tree the CLI produces, for headless and API runs. (#1037)
- **Env-configurable reasoning depth** via `TRADINGAGENTS_OPENAI_REASONING_EFFORT`,
`TRADINGAGENTS_GOOGLE_THINKING_LEVEL`, and `TRADINGAGENTS_ANTHROPIC_EFFORT`,
each gated to the models that accept it.
### Changed
- **Verified data-access contract.** Symbol normalization on every vendor path
(identity, returns, CLI, news); the configured vendor list is the exact
resolution chain with no silent fallback to unselected vendors; a typed
`VendorError` taxonomy; look-ahead-safe news windows; stale-OHLCV rejection;
inclusive yfinance date ranges.
- **Config precedence.** An explicit `TRADINGAGENTS_*` value or CLI flag now wins
over interactive defaults for debate and risk round counts,
`--checkpoint / --no-checkpoint`, and the Docker provider profile; invalid
boolean env values fail loudly. (#975, #976, #977)
- **Current-generation model catalog.** Refreshed provider lineups; retired
`gpt-4.1`, Claude Sonnet 4.5, and the Gemini 2.5 line.
- **Optional vendors degrade** instead of aborting a run: a failed macro or
prediction-market lookup returns a no-data sentinel.
- **Analyst prompts lead with the current date** so tool-call date ranges anchor
to the run date rather than the model's training cutoff. (#836)
### Fixed
- **Instrument identity.** Deterministic ticker-to-company resolution prevents
wrong-company hallucination, and a verified market-data snapshot grounds price
and indicator claims. (#814, #830)
- **Social and market data sources.** Reddit RSS-first with 429 backoff,
StockTwits transport hardening, and Alpha Vantage timeout plus
key-versus-rate-limit handling.
- **Structured output.** Local OpenAI-compatible servers no longer reject
object-form `tool_choice`; a thinking model that returns no parsed result falls
back to free text; null-ish strings in optional price fields coerce to `None`.
(#1038, #1051, #1057)
### Removed
- The no-op `analyst_concurrency_limit` config knob; parallel analyst execution
is planned for a later release. (#979)
- The unused committed `uv.lock`. (#1030)
### Contributors
Thanks to everyone who shaped this release through code, design, and reports:
[@CadeYu](https://github.com/CadeYu), [@Zavianx](https://github.com/Zavianx), [@weijianz-opc](https://github.com/weijianz-opc), [@naltun](https://github.com/naltun), [@brahmasky](https://github.com/brahmasky), [@nik2208](https://github.com/nik2208), [@thieucong98](https://github.com/thieucong98), [@Derekko-web](https://github.com/Derekko-web), [@LukiPrince](https://github.com/LukiPrince), [@Eddieargenal](https://github.com/Eddieargenal), [@Ghraven](https://github.com/Ghraven), [@ms32035](https://github.com/ms32035), [@yting27](https://github.com/yting27), [@nyxst4ck](https://github.com/nyxst4ck), [@KenCheung-AIxFinance](https://github.com/KenCheung-AIxFinance), [@yangyusheng2n](https://github.com/yangyusheng2n), [@fareloj](https://github.com/fareloj), [@haosenwang1018](https://github.com/haosenwang1018), [@octo-patch](https://github.com/octo-patch), [@seifenk](https://github.com/seifenk), [@CaoYuhaoCarl](https://github.com/CaoYuhaoCarl), [@mihailnica10](https://github.com/mihailnica10), [@Dado-hash](https://github.com/Dado-hash), [@Handsomemikezzz](https://github.com/Handsomemikezzz), [@ydhawesome](https://github.com/ydhawesome), [@macd2](https://github.com/macd2), [@AyushKar2005](https://github.com/AyushKar2005), [@wildhuman](https://github.com/wildhuman), [@robert23kim](https://github.com/robert23kim), [@bngness](https://github.com/bngness), [@tedix-rodrigo](https://github.com/tedix-rodrigo), [@malaccan](https://github.com/malaccan), [@rfalken78](https://github.com/rfalken78), [@dengli1971-droid](https://github.com/dengli1971-droid), [@proofconcept39](https://github.com/proofconcept39), [@prasta1](https://github.com/prasta1), [@liximin](https://github.com/liximin), [@jeffhuen](https://github.com/jeffhuen), [@mazar](https://github.com/mazar), [@soyangelromero](https://github.com/soyangelromero), [@CNQQC](https://github.com/CNQQC), [@dovetaill](https://github.com/dovetaill), [@fperdigon](https://github.com/fperdigon), [@gyx09212214-prog](https://github.com/gyx09212214-prog), [@RSXLX](https://github.com/RSXLX).
## [0.2.5] — 2026-05-11
### Added
+3 -1
View File
@@ -28,7 +28,9 @@
# TradingAgents: Multi-Agents LLM Financial Trading Framework
## News
- [2026-05] **TradingAgents v0.2.5** released with the grounded Sentiment Analyst, GPT-5.5 etc. model coverage, Qwen/GLM/MiniMax dual-region support, `TRADINGAGENTS_*` env-var configurability with API-key auto-detection, remote Ollama support, non-US alpha benchmarks, and ticker path-traversal hardening. See [CHANGELOG.md](CHANGELOG.md) for the full list.
- [2026-07] **TradingAgents v0.3.1** released with correctness and stability fixes: Alpha Vantage look-ahead filtering, graph-router crash-safety, graph-shape-aware checkpoint resume, working crypto sentiment sources, a configurable LLM retry budget, Bedrock API-key auth, and Claude Sonnet 5 / Fable 5 support. See [CHANGELOG.md](CHANGELOG.md) for the full list.
- [2026-06] **TradingAgents v0.3.0** released with a verified data-access contract, an expanded provider registry (NVIDIA, Kimi, Groq, Mistral, Bedrock, and any OpenAI-compatible endpoint), FRED and Polymarket data vendors, a current-generation model catalog, and a CI gate.
- [2026-05] **TradingAgents v0.2.5** released with the grounded Sentiment Analyst, GPT-5.5 etc. model coverage, Qwen/GLM/MiniMax dual-region support, `TRADINGAGENTS_*` env-var configurability with API-key auto-detection, remote Ollama support, non-US alpha benchmarks, and ticker path-traversal hardening.
- [2026-04] **TradingAgents v0.2.4** released with structured-output agents (Research Manager, Trader, Portfolio Manager), LangGraph checkpoint resume, persistent decision log, DeepSeek/Qwen/GLM/Azure provider support, Docker, and a Windows UTF-8 encoding fix.
- [2026-03] **TradingAgents v0.2.3** released with multi-language support, GPT-5.4 family models, unified model catalog, backtesting date fidelity, and proxy support.
- [2026-03] **TradingAgents v0.2.2** released with GPT-5.4/Gemini 3.1/Claude 4.6 model coverage, five-tier rating scale, OpenAI Responses API, Anthropic effort control, and cross-platform stability.
+52
View File
@@ -0,0 +1,52 @@
# TradingAgents: Yahoo Finance SSH Proxy + Xiaomi MiMo LLM Support
## 概述
本 patch 解决两个核心问题:
1. **Yahoo Finance 云服务器 IP 被限流** — 通过国外服务器 SSH 代理中转请求
2. **LLM 兼容性** — 支持小米 MiMo 等 OpenAI 兼容接口,增加 JSON 解析重试
## 修改文件
| 文件 | 修改内容 |
|------|----------|
| `tradingagents/dataflows/yahoo_proxy.py` | **新增** — SSH 代理工具模块 |
| `tradingagents/dataflows/stockstats_utils.py` | Yahoo Finance 代理降级 + `_yf_download_via_proxy` |
| `tradingagents/dataflows/y_finance.py` | `get_YFin_data_online` 代理降级 |
| `tradingagents/llm_clients/openai_client.py` | LLM JSON 解析重试机制 |
## 应用方法
```bash
cd TradingAgents
git apply patches/yahoo-proxy-xiaomi-support.patch
```
## 前置条件
1. 国外服务器 SSH 凭据配置在 `secrets/credentials.env.enc`(变量 `FOREIGN_HOST`, `FOREIGN_PORT`, `FOREIGN_PASS`)
2. `sshpass` 已安装:`apt install sshpass`
3. Alpha Vantage API key(可选,用于基本面数据)
## 使用小米 MiMo 模型
```python
import os
os.environ["OPENAI_API_KEY"] = "your-xiaomi-api-key"
os.environ["OPENAI_API_BASE"] = "https://token-plan-cn.xiaomimimo.com/v1"
config = DEFAULT_CONFIG.copy()
config["llm_provider"] = "openai"
config["deep_think_llm"] = "mimo-v2.5-pro"
config["quick_think_llm"] = "mimo-v2.5"
```
## 测试结果
- ✅ Yahoo Finance SSH 代理:1137 行 OHLCV 数据获取成功
- ✅ 小米 MiMo Pro:完整分析流程通过(技术/基本面/情绪/新闻/辩论/决策)
- ✅ LLM JSON 重试:偶发解析错误自动恢复
- ✅ Alpha Vantage:基本面数据正常
---
*Generated: 2026-07-07 | Author: 小钰*
+419
View File
@@ -0,0 +1,419 @@
diff --git a/tradingagents/dataflows/stockstats_utils.py b/tradingagents/dataflows/stockstats_utils.py
index db880b0..cb97dc1 100644
--- a/tradingagents/dataflows/stockstats_utils.py
+++ b/tradingagents/dataflows/stockstats_utils.py
@@ -1,6 +1,8 @@
import logging
import os
import time
+import json
+import subprocess
from typing import Annotated
import pandas as pd
@@ -19,6 +21,82 @@ logger = logging.getLogger(__name__)
# enough to catch the year-old frames yfinance occasionally returns (#1021).
MAX_OHLCV_STALE_DAYS = 10
+_ssh_config_cache = None
+
+def _get_ssh_config():
+ """Load SSH credentials for foreign server from vault (cached)."""
+ global _ssh_config_cache
+ if _ssh_config_cache is not None:
+ return _ssh_config_cache
+ result = subprocess.run(
+ ["bash", "-c",
+ 'source <(bash /root/.openclaw/workspace/scripts/vault.sh decrypt '
+ '/root/.openclaw/workspace/secrets/credentials.env.enc) '
+ '&& echo "$FOREIGN_HOST $FOREIGN_PORT $FOREIGN_PASS"'],
+ capture_output=True, text=True, timeout=10
+ )
+ parts = result.stdout.strip().split()
+ if len(parts) != 3:
+ raise RuntimeError(f"Failed to load SSH config: {result.stderr}")
+ _ssh_config_cache = (parts[0], int(parts[1]), parts[2])
+ return _ssh_config_cache
+
+
+def _fetch_via_ssh(url: str, timeout: int = 30) -> str:
+ """Fetch a URL through the foreign server via SSH tunnel."""
+ host, port, password = _get_ssh_config()
+ cmd = [
+ "sshpass", "-p", password,
+ "ssh", "-o", "StrictHostKeyChecking=no",
+ "-o", "ConnectTimeout=15",
+ "-p", str(port), f"root@{host}",
+ f'curl -s "{url}" -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"'
+ ]
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
+ if result.returncode != 0:
+ raise RuntimeError(f"SSH fetch failed: {result.stderr}")
+ return result.stdout
+
+
+def _yf_download_via_proxy(symbol: str, start: str, end: str) -> pd.DataFrame:
+ """Download OHLCV data via SSH proxy when local Yahoo Finance is rate-limited."""
+ import datetime
+ # Convert dates to timestamps
+ start_ts = int(pd.Timestamp(start).timestamp())
+ end_ts = int(pd.Timestamp(end).timestamp())
+
+ url = (f"https://query2.finance.yahoo.com/v8/finance/chart/{symbol}"
+ f"?period1={start_ts}&period2={end_ts}&interval=1d")
+
+ data_str = _fetch_via_ssh(url)
+ parsed = json.loads(data_str)
+
+ result = parsed.get("chart", {}).get("result", [])
+ if not result:
+ return pd.DataFrame()
+
+ ts = result[0].get("timestamp", [])
+ indicators = result[0].get("indicators", {}).get("quote", [{}])[0]
+
+ if not ts:
+ return pd.DataFrame()
+
+ df = pd.DataFrame({
+ "Date": pd.to_datetime(ts, unit="s"),
+ "Open": indicators.get("open", []),
+ "High": indicators.get("high", []),
+ "Low": indicators.get("low", []),
+ "Close": indicators.get("close", []),
+ "Volume": indicators.get("volume", []),
+ })
+
+ # Convert None values to NaN
+ for col in ["Open", "High", "Low", "Close", "Volume"]:
+ df[col] = pd.to_numeric(df[col], errors="coerce")
+
+ df = df.dropna(subset=["Close"])
+ return df
+
def yf_retry(func, max_retries=3, base_delay=2.0):
"""Execute a yfinance call with exponential backoff on rate limits.
@@ -36,6 +114,7 @@ def yf_retry(func, max_retries=3, base_delay=2.0):
logger.warning(f"Yahoo Finance rate limited, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries})")
time.sleep(delay)
else:
+ logger.warning("Yahoo Finance local exhausted, will let caller handle fallback")
raise
@@ -122,6 +201,82 @@ def _assert_ohlcv_not_stale(
)
+def _load_ohlcv_alpha_vantage(symbol: str, curr_date: str) -> pd.DataFrame:
+ """Fetch OHLCV data from Alpha Vantage, filtered to prevent look-ahead bias."""
+ from .alpha_vantage_common import _make_api_request
+ from io import StringIO
+
+ canonical = normalize_symbol(symbol)
+ curr_date_dt = pd.to_datetime(curr_date)
+ today_date = pd.Timestamp.today()
+ start_date = today_date - pd.DateOffset(years=5)
+ start_str = start_date.strftime("%Y-%m-%d")
+
+ # Determine outputsize based on how far back we need
+ days_from_today = (today_date - start_date).days
+ outputsize = "compact" if days_from_today < 100 else "full"
+
+ params = {
+ "symbol": canonical,
+ "outputsize": outputsize,
+ "datatype": "csv",
+ }
+
+ try:
+ csv_text = _make_api_request("TIME_SERIES_DAILY", params)
+ except Exception as e:
+ raise NoMarketDataError(symbol, canonical, f"Alpha Vantage error: {e}") from e
+
+ if not csv_text or not csv_text.strip():
+ raise NoMarketDataError(symbol, canonical, "Alpha Vantage returned empty data")
+
+ try:
+ df = pd.read_csv(StringIO(csv_text))
+ except Exception as e:
+ raise NoMarketDataError(symbol, canonical, f"Failed to parse Alpha Vantage CSV: {e}") from e
+
+ if df.empty:
+ raise NoMarketDataError(symbol, canonical, "Alpha Vantage returned no rows")
+
+ # Alpha Vantage CSV has 'timestamp' column; normalize to 'Date'
+ date_col = df.columns[0] # usually 'timestamp'
+ df = df.rename(columns={date_col: "Date"})
+
+ # Keep only standard OHLCV columns
+ keep_cols = ["Date"]
+ col_map = {}
+ for av_col, std_col in [
+ ("open", "Open"), ("high", "High"), ("low", "Low"),
+ ("close", "Close"), ("adjusted_close", "Close"), ("volume", "Volume"),
+ ]:
+ for c in df.columns:
+ if c.lower().replace(" ", "_") == av_col:
+ if std_col not in keep_cols:
+ keep_cols.append(std_col)
+ col_map[c] = std_col
+ break
+ df = df.rename(columns=col_map)
+ # Prefer adjusted_close over close for "Close"
+ if "adjusted_close" in [c.lower().replace(" ", "_") for c in df.columns]:
+ for c in df.columns:
+ if c.lower().replace(" ", "_") == "adjusted_close":
+ df["Close"] = pd.to_numeric(df[c], errors="coerce")
+ break
+
+ available = [c for c in ["Date", "Open", "High", "Low", "Close", "Volume"] if c in df.columns]
+ df = df[available]
+
+ # Filter to curr_date
+ df["Date"] = pd.to_datetime(df["Date"], errors="coerce")
+ df = df.dropna(subset=["Date"])
+ df = df[df["Date"] <= curr_date_dt].sort_values("Date")
+
+ if df.empty:
+ raise NoMarketDataError(symbol, canonical, "No data on or before " + curr_date)
+
+ return df
+
+
def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
"""Fetch OHLCV data with caching, filtered to prevent look-ahead bias.
@@ -138,24 +293,24 @@ def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
config = get_config()
curr_date_dt = pd.to_datetime(curr_date)
+ # Check which vendor to use for OHLCV
+ vendor_config = config.get("data_vendors", {}).get("core_stock_apis", "yfinance")
+ use_alpha_vantage = "alpha_vantage" in vendor_config
+
# Cache uses a fixed window (5y to today) so one file per symbol.
today_date = pd.Timestamp.today()
start_date = today_date - pd.DateOffset(years=5)
start_str = start_date.strftime("%Y-%m-%d")
- # yfinance ``end`` is EXCLUSIVE; request tomorrow so today's row is included
- # when curr_date is the current day (#986). Look-ahead is still prevented by
- # the curr_date filter below.
end_str = (today_date + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
+ cache_prefix = "AV" if use_alpha_vantage else "YFin"
os.makedirs(config["data_cache_dir"], exist_ok=True)
data_file = os.path.join(
config["data_cache_dir"],
- f"{safe_symbol}-YFin-data-{start_str}-{end_str}.csv",
+ f"{safe_symbol}-{cache_prefix}-data-{start_str}-{end_str}.csv",
)
- # A cached file may be empty if a prior fetch failed (unknown symbol,
- # transient rate limit). Treat an empty/columnless cache as a miss and
- # re-fetch rather than serving the poisoned file forever.
+ # A cached file may be empty if a prior fetch failed. Treat empty cache as miss.
data = None
if os.path.exists(data_file):
cached = pd.read_csv(data_file, on_bad_lines="skip", encoding="utf-8")
@@ -163,30 +318,53 @@ def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
data = cached
if data is None:
- downloaded = yf_retry(lambda: yf.download(
- canonical,
- start=start_str,
- end=end_str,
- multi_level_index=False,
- progress=False,
- auto_adjust=True,
- ))
- downloaded = _ensure_date_column(downloaded.reset_index())
- # Only cache real data — never persist an empty frame.
- if downloaded.empty or "Close" not in downloaded.columns:
- raise NoMarketDataError(
- symbol, canonical, "Yahoo Finance returned no rows"
- )
+ if use_alpha_vantage:
+ downloaded = _load_ohlcv_alpha_vantage(symbol, curr_date)
+ else:
+ downloaded = None
+ try:
+ raw = yf_retry(lambda: yf.download(
+ canonical,
+ start=start_str,
+ end=end_str,
+ multi_level_index=False,
+ progress=False,
+ auto_adjust=True,
+ ))
+ downloaded = _ensure_date_column(raw.reset_index())
+ except Exception as yf_err:
+ pass # Will try proxy below
+
+ # If local yfinance failed or returned empty, try SSH proxy
+ if downloaded is None or downloaded.empty or "Close" not in downloaded.columns:
+ if downloaded is None or downloaded.empty:
+ logger.warning(f"Yahoo Finance local failed, trying SSH proxy...")
+ try:
+ downloaded = _yf_download_via_proxy(canonical, start_str, end_str)
+ except Exception as proxy_err:
+ raise NoMarketDataError(
+ symbol, canonical, f"Yahoo Finance proxy also failed: {proxy_err}"
+ ) from proxy_err
+ if downloaded.empty:
+ raise NoMarketDataError(
+ symbol, canonical, f"Yahoo Finance proxy also returned no data"
+ )
+ if downloaded.empty or "Close" not in downloaded.columns:
+ raise NoMarketDataError(
+ symbol, canonical, "Yahoo Finance returned no rows"
+ )
+ downloaded = _clean_dataframe(downloaded)
+
downloaded.to_csv(data_file, index=False, encoding="utf-8")
data = downloaded
- data = _clean_dataframe(data)
+ if not use_alpha_vantage:
+ data = _clean_dataframe(data)
# Filter to curr_date to prevent look-ahead bias in backtesting
data = data[data["Date"] <= curr_date_dt]
- # Reject a stale frame (latest row far older than curr_date) rather than
- # feeding year-old prices into indicators (#1021).
+ # Reject a stale frame
_assert_ohlcv_not_stale(data, curr_date, symbol, canonical)
return data
diff --git a/tradingagents/dataflows/y_finance.py b/tradingagents/dataflows/y_finance.py
index fdb49d5..9e36c6d 100644
--- a/tradingagents/dataflows/y_finance.py
+++ b/tradingagents/dataflows/y_finance.py
@@ -1,13 +1,17 @@
+import logging
from datetime import datetime
from typing import Annotated
import pandas as pd
import yfinance as yf
+
+logger = logging.getLogger(__name__)
from dateutil.relativedelta import relativedelta
from .stockstats_utils import (
StockstatsUtils,
_assert_ohlcv_not_stale,
+ _yf_download_via_proxy,
filter_financials_by_date,
load_ohlcv,
yf_retry,
@@ -32,7 +36,19 @@ def get_YFin_data_online(
# end_date row (and the current day when end_date is today). Request one day
# past end_date so the requested range is actually inclusive (#986/#987).
end_inclusive = (end_dt + relativedelta(days=1)).strftime("%Y-%m-%d")
- data = yf_retry(lambda: ticker.history(start=start_date, end=end_inclusive))
+ try:
+ data = yf_retry(lambda: ticker.history(start=start_date, end=end_inclusive))
+ except Exception:
+ # Fallback: use SSH proxy via foreign server
+ logger.warning(f"Yahoo Finance local failed for {symbol}, trying SSH proxy...")
+ proxy_df = _yf_download_via_proxy(canonical, start_date, end_inclusive)
+ if proxy_df.empty:
+ raise NoMarketDataError(
+ symbol, canonical, f"no rows between {start_date} and {end_date} (proxy also failed)"
+ )
+ # Convert to match expected format
+ proxy_df = proxy_df.set_index("Date")
+ data = proxy_df
# Empty result means the symbol is unknown/delisted. Raise a typed error
# instead of returning prose: the routing layer turns it into a single
diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py
index bf6e554..717750d 100644
--- a/tradingagents/llm_clients/openai_client.py
+++ b/tradingagents/llm_clients/openai_client.py
@@ -33,7 +33,21 @@ class NormalizedChatOpenAI(ChatOpenAI):
"""
def invoke(self, input, config=None, **kwargs):
- return normalize_content(super().invoke(input, config, **kwargs))
+ import json as _json
+ import time as _time
+ import logging as _logging
+ _logger = _logging.getLogger(__name__)
+ max_retries = 3
+ for attempt in range(max_retries):
+ try:
+ return normalize_content(super().invoke(input, config, **kwargs))
+ except (_json.JSONDecodeError, Exception) as e:
+ if 'JSONDecodeError' in type(e).__name__ or 'Expecting value' in str(e):
+ if attempt < max_retries - 1:
+ _logger.warning(f"LLM returned invalid JSON, retrying ({attempt+1}/{max_retries}): {e}")
+ _time.sleep(2 * (attempt + 1))
+ continue
+ raise
def with_structured_output(self, schema, *, method=None, **kwargs):
caps = get_capabilities(self.model_name)
diff --git a/tradingagents/dataflows/yahoo_proxy.py b/tradingagents/dataflows/yahoo_proxy.py
new file mode 100644
index 0000000..c06e0ad
--- /dev/null
+++ b/tradingagents/dataflows/yahoo_proxy.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+"""Yahoo Finance proxy via foreign server.
+
+Routes yfinance requests through the foreign server to bypass local IP rate limiting.
+"""
+import subprocess
+import json
+import os
+import sys
+
+def _get_ssh_config():
+ """Load SSH credentials from vault."""
+ result = subprocess.run(
+ ["bash", "-c", 'source <(bash /root/.openclaw/workspace/scripts/vault.sh decrypt /root/.openclaw/workspace/secrets/credentials.env.enc) && echo "$FOREIGN_HOST $FOREIGN_PORT $FOREIGN_PASS"'],
+ capture_output=True, text=True, timeout=10
+ )
+ parts = result.stdout.strip().split()
+ if len(parts) != 3:
+ raise RuntimeError(f"Failed to load SSH config: {result.stderr}")
+ return parts[0], int(parts[1]), parts[2]
+
+def fetch_yahoo_via_ssh(url: str, timeout: int = 30) -> str:
+ """Fetch a Yahoo Finance URL through the foreign server via SSH."""
+ host, port, password = _get_ssh_config()
+ cmd = [
+ "sshpass", "-p", password,
+ "ssh", "-o", "StrictHostKeyChecking=no",
+ "-o", f"ConnectTimeout=15",
+ "-p", str(port), f"root@{host}",
+ f'curl -s "{url}" -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"'
+ ]
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
+ if result.returncode != 0:
+ raise RuntimeError(f"SSH fetch failed: {result.stderr}")
+ return result.stdout
+
+def test_connection() -> bool:
+ """Test if Yahoo Finance is accessible via foreign server."""
+ try:
+ data = fetch_yahoo_via_ssh(
+ "https://query2.finance.yahoo.com/v8/finance/chart/AAPL?interval=1d&range=5d"
+ )
+ parsed = json.loads(data)
+ return "chart" in parsed and "result" in parsed.get("chart", {})
+ except Exception as e:
+ print(f"Connection test failed: {e}", file=sys.stderr)
+ return False
+
+if __name__ == "__main__":
+ if test_connection():
+ print("✅ Yahoo Finance proxy via foreign server: OK")
+ else:
+ print("❌ Yahoo Finance proxy via foreign server: FAILED")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tradingagents"
version = "0.2.5"
version = "0.3.1"
description = "TradingAgents: Multi-Agents LLM Financial Trading Framework"
readme = "README.md"
requires-python = ">=3.10"
+66
View File
@@ -0,0 +1,66 @@
"""Quick smoke test for TradingAgents deployment.
Uses OpenRouter + free Gemini model to minimize cost.
"""
import os
import json
# Load OpenRouter API key from openclaw config
with open(os.path.expanduser("~/.openclaw/openclaw.json")) as f:
oc_config = json.load(f)
openrouter_key = oc_config.get("env", {}).get("OPENROUTER_API_KEY", "")
if not openrouter_key:
raise RuntimeError("OPENROUTER_API_KEY not found in openclaw.json")
os.environ["OPENROUTER_API_KEY"] = openrouter_key
os.environ["ALPHA_VANTAGE_API_KEY"] = "W5PTA2J5OB16W02L"
os.environ["OPENAI_API_KEY"] = "tp-ce6eaqg1gh1uev46i4oexh4sahloyfo2hfpxuc2n9m7yl9dd"
os.environ["OPENAI_API_BASE"] = "https://token-plan-cn.xiaomimimo.com/v1"
# Import after env var is set
from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.default_config import DEFAULT_CONFIG
# Configure: OpenRouter + Gemini (free/cheap model)
config = DEFAULT_CONFIG.copy()
config["llm_provider"] = "openai"
config["deep_think_llm"] = "mimo-v2.5-pro"
config["quick_think_llm"] = "mimo-v2.5"
config["llm_max_retries"] = 6
config["temperature"] = 0.1 # Lower temperature for more stable output
config["data_vendors"] = {
"core_stock_apis": "yfinance",
"technical_indicators": "yfinance",
"fundamental_data": "alpha_vantage",
"news_data": "yfinance",
"macro_data": "fred",
"prediction_markets": "polymarket",
}
config["max_debate_rounds"] = 1
config["max_risk_discuss_rounds"] = 1
config["output_language"] = "Chinese"
print("=" * 60)
print("TradingAgents Smoke Test")
print(f"Provider: {config['llm_provider']}")
print(f"Deep Model: {config['deep_think_llm']}")
print(f"Quick Model: {config['quick_think_llm']}")
print("=" * 60)
# Test with a simple A-share ticker
ta = TradingAgentsGraph(debug=True, config=config)
print("\nRunning analysis for 600519.SS (Kweichow Moutai) on 2026-01-15...")
print("(This will take several minutes due to multiple LLM calls)\n")
try:
_, decision = ta.propagate("AAPL", "2026-01-15")
print("\n" + "=" * 60)
print("DECISION RESULT:")
print("=" * 60)
print(decision)
print("\n✅ Smoke test PASSED!")
except Exception as e:
print(f"\n❌ Smoke test FAILED: {e}")
import traceback
traceback.print_exc()
+43
View File
@@ -0,0 +1,43 @@
"""使用小米 mimo-v2.5-pro 模型运行 TradingAgents 示例"""
from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.default_config import DEFAULT_CONFIG
config = DEFAULT_CONFIG.copy()
# 使用 openai_compatible 对接小米 API
config["llm_provider"] = "openai_compatible"
config["backend_url"] = "https://token-plan-cn.xiaomimimo.com/v1"
config["deep_think_llm"] = "mimo-v2.5-pro"
config["quick_think_llm"] = "mimo-v2.5-pro"
# 配置数据源:Yahoo Finance优先,Alpha Vantage作为备选
config['data_vendors'] = {
'core_stock_apis': 'yfinance,alpha_vantage',
'technical_indicators': 'yfinance,alpha_vantage',
'fundamental_data': 'yfinance,alpha_vantage',
'news_data': 'yfinance,alpha_vantage',
'macro_data': 'fred',
'prediction_markets': 'polymarket',
}
# 设置 API Key(也可通过环境变量 OPENAI_COMPATIBLE_API_KEY)
import os
os.environ["OPENAI_COMPATIBLE_API_KEY"] = "tp-ce6eaqg1gh1uev46i4oexh4sahloyfo2hfpxuc2n9m7yl9dd"
os.environ["FRED_API_KEY"] = "825283926dd016ea66e96cc919e047da"
os.environ["ALPHA_VANTAGE_API_KEY"] = "W5PTA2J5OB16W02L"
# 减少辩论轮数加速测试
config["max_debate_rounds"] = 1
config["max_risk_discuss_rounds"] = 1
print("配置完成,开始运行 TradingAgents...")
print(f"Provider: {config['llm_provider']}")
print(f"Backend: {config['backend_url']}")
print(f"Model: {config['deep_think_llm']}")
ta = TradingAgentsGraph(debug=True, config=config)
# 测试分析茅台(A股)
_, decision = ta.propagate("600519.SS", "2026-01-15")
print("\n=== 交易决策 ===")
print(decision)
+44 -2
View File
@@ -1,11 +1,16 @@
"""Alpha Vantage request hardening.
Regressions for #990 (no request timeout -> can hang) and #991 (invalid-key
responses mislabeled as rate limits and silently treated as transient).
Regressions for #990 (no request timeout -> can hang), #991 (invalid-key
responses mislabeled as rate limits and silently treated as transient), and
#1115 (fundamentals look-ahead filter never ran because the payload is a JSON
string, not a dict).
"""
import json
import pytest
import tradingagents.dataflows.alpha_vantage_common as av
import tradingagents.dataflows.alpha_vantage_fundamentals as avf
class _FakeResponse:
@@ -52,3 +57,40 @@ def test_invalid_key_not_mislabeled_as_rate_limit(monkeypatch):
with pytest.raises(av.AlphaVantageRateLimitError): # sanity: rate-limit path still distinct
monkeypatch.setattr(av.requests, "get", _patched_get('{"Note": "API call frequency is 5 calls per minute."}'))
av._make_api_request("TIME_SERIES_DAILY", {"symbol": "AAPL"})
_FUNDAMENTALS_JSON = json.dumps({
"symbol": "AAPL",
"annualReports": [
{"fiscalDateEnding": "2025-12-31", "totalAssets": "1"}, # future -> must drop
{"fiscalDateEnding": "2023-12-31", "totalAssets": "2"}, # past -> must keep
],
"quarterlyReports": [
{"fiscalDateEnding": "2024-06-30", "totalAssets": "3"}, # future -> must drop
{"fiscalDateEnding": "2023-09-30", "totalAssets": "4"}, # past -> must keep
],
})
@pytest.mark.unit
def test_fundamentals_look_ahead_filter_runs_on_json_string(monkeypatch):
# #1115: the payload arrives as a JSON *string*; the old dict-only guard let
# future-dated fiscal periods leak into historical runs.
monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: _FUNDAMENTALS_JSON)
out = avf.get_balance_sheet("AAPL", curr_date="2024-01-01")
assert isinstance(out, str) # callers still receive a str
parsed = json.loads(out)
assert [r["fiscalDateEnding"] for r in parsed["annualReports"]] == ["2023-12-31"]
assert [r["fiscalDateEnding"] for r in parsed["quarterlyReports"]] == ["2023-09-30"]
@pytest.mark.unit
def test_fundamentals_no_curr_date_passes_through(monkeypatch):
monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: _FUNDAMENTALS_JSON)
assert avf.get_income_statement("AAPL") == _FUNDAMENTALS_JSON
@pytest.mark.unit
def test_fundamentals_non_json_body_unchanged(monkeypatch):
monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: "not-json")
assert avf.get_cashflow("AAPL", curr_date="2024-01-01") == "not-json"
+10
View File
@@ -57,6 +57,16 @@ class TestEffortGate:
mod.AnthropicClient(model=model, effort="low", api_key="x").get_llm()
assert captured["kwargs"]["effort"] == "low"
@pytest.mark.parametrize(
"model",
# Claude 5 family uses single-number version IDs; all are effort-capable.
["claude-sonnet-5", "claude-fable-5", "claude-mythos-5"],
)
def test_claude_5_family_receives_effort(self, monkeypatch, model):
captured = _capture_kwargs(monkeypatch)
mod.AnthropicClient(model=model, effort="high", api_key="x").get_llm()
assert captured["kwargs"]["effort"] == "high"
def test_mythos_preview_receives_effort(self, monkeypatch):
captured = _capture_kwargs(monkeypatch)
mod.AnthropicClient(
+35 -1
View File
@@ -35,12 +35,46 @@ def test_helpful_error_when_langchain_aws_absent(monkeypatch):
create_llm_client("bedrock", "m").get_llm()
def _capture_kwargs(monkeypatch):
"""Stub _bedrock_class so the constructor kwargs are testable without the
optional langchain-aws extra installed."""
import tradingagents.llm_clients.bedrock_client as bc
captured = {}
class _FakeChat:
def __init__(self, **kwargs):
captured.update(kwargs)
monkeypatch.setattr(bc, "_bedrock_class", lambda: _FakeChat)
return captured
@pytest.mark.unit
def test_bearer_token_passed_as_api_key(monkeypatch):
# #1103: a Bedrock API key authenticates without AWS access keys.
captured = _capture_kwargs(monkeypatch)
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "bt-secret")
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
create_llm_client("bedrock", "us.anthropic.claude-opus-4-8-v1:0").get_llm()
assert captured["api_key"] == "bt-secret"
assert captured["region_name"] == "us-east-1"
@pytest.mark.unit
def test_no_bearer_token_omits_api_key(monkeypatch):
# Without a token, fall back to the AWS credential chain (no api_key kwarg).
captured = _capture_kwargs(monkeypatch)
monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False)
create_llm_client("bedrock", "us.anthropic.claude-opus-4-8-v1:0").get_llm()
assert "api_key" not in captured
@pytest.mark.unit
def test_construction_when_extra_installed(monkeypatch):
pytest.importorskip("langchain_aws")
import tradingagents.llm_clients.bedrock_client as bc
monkeypatch.setattr(bc, "_BEDROCK_CLASS", None)
monkeypatch.setenv("AWS_DEFAULT_REGION", "eu-west-1")
llm = create_llm_client("bedrock", "us.anthropic.claude-sonnet-4-6-v1:0").get_llm()
llm = create_llm_client("bedrock", "us.anthropic.claude-sonnet-5").get_llm()
assert type(llm).__name__ == "NormalizedChatBedrockConverse"
assert llm.region_name == "eu-west-1"
+74
View File
@@ -140,5 +140,79 @@ class TestCheckpointResume(unittest.TestCase):
self.assertTrue(has_checkpoint(self.tmpdir, self.ticker, self.date))
class TestCheckpointSignature(unittest.TestCase):
"""A different graph shape (analyst selection / depth / asset mode) must not
resume the previous run's checkpoint (#1089)."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.ticker = "TEST"
self.date = "2026-04-20"
def test_empty_signature_is_legacy_id(self):
self.assertEqual(
thread_id(self.ticker, self.date),
thread_id(self.ticker, self.date, ""),
)
def test_signature_changes_thread_id(self):
legacy = thread_id(self.ticker, self.date)
sig_a = thread_id(self.ticker, self.date, "analysts=market,news|asset=stock")
sig_b = thread_id(self.ticker, self.date, "analysts=market|asset=stock")
self.assertNotEqual(sig_a, sig_b) # different graph shapes differ
self.assertNotEqual(legacy, sig_a) # signature-keyed differs from legacy
self.assertEqual( # same inputs are stable
sig_a, thread_id(self.ticker, self.date, "analysts=market,news|asset=stock")
)
def test_different_signature_starts_fresh(self):
global _should_crash
builder = _build_graph()
sig1 = "analysts=market,news,fundamentals|asset=stock"
sig2 = "analysts=market|asset=stock" # dropped analysts -> different graph
_should_crash = True
tid1 = thread_id(self.ticker, self.date, sig1)
with get_checkpointer(self.tmpdir, self.ticker) as saver:
graph = builder.compile(checkpointer=saver)
with self.assertRaises(RuntimeError):
graph.invoke({"count": 0}, config={"configurable": {"thread_id": tid1}})
self.assertTrue(has_checkpoint(self.tmpdir, self.ticker, self.date, sig1))
# A different graph shape has no checkpoint to resume from.
self.assertFalse(has_checkpoint(self.tmpdir, self.ticker, self.date, sig2))
_should_crash = False
tid2 = thread_id(self.ticker, self.date, sig2)
self.assertNotEqual(tid1, tid2)
with get_checkpointer(self.tmpdir, self.ticker) as saver:
graph = builder.compile(checkpointer=saver)
result = graph.invoke({"count": 0}, config={"configurable": {"thread_id": tid2}})
self.assertEqual(result["count"], 11)
# sig1's checkpoint remains untouched.
self.assertTrue(has_checkpoint(self.tmpdir, self.ticker, self.date, sig1))
def test_run_signature_captures_graph_shape(self):
from tradingagents.graph.trading_graph import TradingAgentsGraph
# Build a bare instance to exercise the pure helper without heavy __init__.
g = object.__new__(TradingAgentsGraph)
g.selected_analysts = ("market", "news")
g.config = {"max_debate_rounds": 1, "max_risk_discuss_rounds": 1}
base = g._run_signature("stock")
self.assertNotEqual(base, g._run_signature("crypto")) # asset mode
g.selected_analysts = ("market",)
self.assertNotEqual(base, g._run_signature("stock")) # analyst selection
g.selected_analysts = ("market", "news")
g.config = {"max_debate_rounds": 3, "max_risk_discuss_rounds": 1}
self.assertNotEqual(base, g._run_signature("stock")) # debate depth
g.config = {"max_debate_rounds": 1, "max_risk_discuss_rounds": 5}
self.assertNotEqual(base, g._run_signature("stock")) # risk depth
# Stable for identical inputs.
g.config = {"max_debate_rounds": 1, "max_risk_discuss_rounds": 1}
self.assertEqual(base, g._run_signature("stock"))
if __name__ == "__main__":
unittest.main()
+100
View File
@@ -0,0 +1,100 @@
"""Configurable LLM SDK retry budget (#1090/#1091).
A single transient 429 burst used to kill an otherwise-healthy multi-agent run
because each provider SDK's max_retries (default 2) was not exposed. This adds an
opt-in llm_max_retries knob forwarded to every provider chat client.
"""
from __future__ import annotations
import importlib
import pytest
import tradingagents.default_config as default_config_module
from tradingagents.graph.trading_graph import TradingAgentsGraph, _coerce_max_retries
# --- coercion / validation -------------------------------------------------
@pytest.mark.unit
@pytest.mark.parametrize("value,expected", [(0, 0), (2, 2), (10, 10), ("6", 6)])
def test_coerce_accepts_non_negative_ints_and_numeric_strings(value, expected):
assert _coerce_max_retries(value) == expected
@pytest.mark.unit
@pytest.mark.parametrize("bad", [-1, "-3"])
def test_coerce_rejects_negative(bad):
with pytest.raises(ValueError, match=">= 0"):
_coerce_max_retries(bad)
@pytest.mark.unit
@pytest.mark.parametrize("bad", [True, False])
def test_coerce_rejects_booleans(bad):
with pytest.raises(ValueError, match="boolean"):
_coerce_max_retries(bad)
@pytest.mark.unit
@pytest.mark.parametrize("bad", ["abc", "1.5", None])
def test_coerce_rejects_non_integers(bad):
with pytest.raises(ValueError, match="integer"):
_coerce_max_retries(bad)
# --- forwarding into provider kwargs --------------------------------------
def _bare_graph(config):
g = object.__new__(TradingAgentsGraph)
g.config = config
return g
@pytest.mark.unit
def test_not_forwarded_when_unset():
kwargs = _bare_graph({"llm_provider": "openai", "llm_max_retries": None})._get_provider_kwargs()
assert "max_retries" not in kwargs
@pytest.mark.unit
@pytest.mark.parametrize("provider", ["openai", "anthropic", "google"])
def test_forwarded_across_providers(provider):
kwargs = _bare_graph({"llm_provider": provider, "llm_max_retries": 6})._get_provider_kwargs()
assert kwargs["max_retries"] == 6
@pytest.mark.unit
def test_forwarded_env_string_is_coerced():
# env vars arrive as strings; the consumer coerces (like temperature)
kwargs = _bare_graph({"llm_provider": "openai", "llm_max_retries": "4"})._get_provider_kwargs()
assert kwargs["max_retries"] == 4
@pytest.mark.unit
def test_invalid_config_value_fails_loudly():
with pytest.raises(ValueError):
_bare_graph({"llm_provider": "openai", "llm_max_retries": -1})._get_provider_kwargs()
# --- env overlay -----------------------------------------------------------
def _reload_with_env(monkeypatch, **overrides):
for key in list(default_config_module._ENV_OVERRIDES):
monkeypatch.delenv(key, raising=False)
for key, val in overrides.items():
monkeypatch.setenv(key, val)
return importlib.reload(default_config_module)
@pytest.mark.unit
def test_default_is_none(monkeypatch):
dc = _reload_with_env(monkeypatch)
assert dc.DEFAULT_CONFIG["llm_max_retries"] is None
@pytest.mark.unit
def test_env_override_sets_config(monkeypatch):
dc = _reload_with_env(monkeypatch, TRADINGAGENTS_LLM_MAX_RETRIES="8")
# None-default key: env value arrives as a string and is coerced downstream.
assert dc.DEFAULT_CONFIG["llm_max_retries"] == "8"
assert _coerce_max_retries(dc.DEFAULT_CONFIG["llm_max_retries"]) == 8
+25
View File
@@ -0,0 +1,25 @@
"""Guard the news analyst prompt against tool-signature drift (#1116).
The prompt used to advertise ``get_news(query, ...)`` while the tool takes a
``ticker``, tricking the LLM into hallucinating free-text query calls.
"""
import inspect
import pytest
import tradingagents.agents.analysts.news_analyst as na
from tradingagents.agents.utils.news_data_tools import get_news
@pytest.mark.unit
def test_get_news_takes_ticker_not_query():
arg_names = set(get_news.args.keys())
assert "ticker" in arg_names
assert "query" not in arg_names
@pytest.mark.unit
def test_news_prompt_matches_get_news_signature():
src = inspect.getsource(na)
assert "get_news(ticker, start_date, end_date)" in src
assert "get_news(query" not in src
+22
View File
@@ -190,3 +190,25 @@ class TestFormatterHandlesRssPosts:
assert "1234↑" in out
assert "56c" in out
assert "via RSS" not in out
@pytest.mark.unit
class TestCryptoSearchTerm:
"""A crypto pair (BTC-USD) barely matches Reddit text; search the base (#1113)."""
def _captured_ticker(self, ticker):
seen = {}
def fake_fetch(t, sub, limit, timeout):
seen["ticker"] = t
return []
with patch.object(reddit, "_fetch_subreddit", side_effect=fake_fetch):
reddit.fetch_reddit_posts(ticker, subreddits=("stocks",), inter_request_delay=0)
return seen["ticker"]
def test_crypto_pair_searches_base(self):
assert self._captured_ticker("BTC-USD") == "BTC"
def test_equity_passes_through(self):
assert self._captured_ticker("NVDA") == "NVDA"
+81
View File
@@ -0,0 +1,81 @@
"""Shared-router / path_map completeness (#1088).
Both `should_continue_risk_analysis` (three risk edges) and
`should_continue_debate` (two research-debate edges) are single routers whose
return set is larger than any one edge previously mapped. Each edge now shares a
complete path map (`RISK_ANALYSIS_PATH_MAP` / `DEBATE_PATH_MAP`), so a
fall-through return can never hit a missing entry -- which would crash LangGraph
mid-run on prompt/i18n/refactor drift in the speaker labels.
"""
import pytest
from tradingagents.graph.conditional_logic import ConditionalLogic
from tradingagents.graph.setup import DEBATE_PATH_MAP, RISK_ANALYSIS_PATH_MAP
def _state(latest_speaker, count=0):
return {"risk_debate_state": {"latest_speaker": latest_speaker, "count": count}}
def _debate_state(current_response, count=0):
return {"investment_debate_state": {"current_response": current_response, "count": count}}
@pytest.mark.unit
@pytest.mark.parametrize("latest_speaker", [
"Aggressive", "Aggressive Analyst",
"Conservative", "Conservative Analyst",
"Neutral", "Neutral Analyst",
"", # drift: empty label
"Aggressive Risk Analyst", # drift: node renamed
"Agresivo", # drift: i18n / translated label
])
def test_router_return_always_routable(latest_speaker):
logic = ConditionalLogic(max_risk_discuss_rounds=1)
target = logic.should_continue_risk_analysis(_state(latest_speaker))
assert target in RISK_ANALYSIS_PATH_MAP
@pytest.mark.unit
def test_router_terminates_at_round_limit():
logic = ConditionalLogic(max_risk_discuss_rounds=1)
# count >= 3 * rounds routes to the Portfolio Manager (debate ends)
assert logic.should_continue_risk_analysis(_state("Neutral", count=3)) == "Portfolio Manager"
@pytest.mark.unit
def test_path_map_covers_full_router_range():
logic = ConditionalLogic(max_risk_discuss_rounds=1)
returns = {
logic.should_continue_risk_analysis(_state(s, c))
for s in ("Aggressive", "Conservative", "Neutral", "drift")
for c in (0, 99)
}
# Every value the router can emit is a key in the shared map...
assert returns <= set(RISK_ANALYSIS_PATH_MAP)
# ...and the terminal target is reachable.
assert "Portfolio Manager" in returns
@pytest.mark.unit
@pytest.mark.parametrize("current_response", [
"Bull", "Bull Researcher", "Bear", "Bear Researcher",
"", # drift: empty label
"Optimista", # drift: i18n / translated label
])
def test_debate_router_return_always_routable(current_response):
logic = ConditionalLogic(max_debate_rounds=1)
target = logic.should_continue_debate(_debate_state(current_response))
assert target in DEBATE_PATH_MAP
@pytest.mark.unit
def test_debate_path_map_covers_full_router_range():
logic = ConditionalLogic(max_debate_rounds=1)
returns = {
logic.should_continue_debate(_debate_state(s, c))
for s in ("Bull", "Bear", "drift")
for c in (0, 99)
}
assert returns <= set(DEBATE_PATH_MAP)
assert "Research Manager" in returns # terminal reachable
+38 -3
View File
@@ -1,5 +1,9 @@
"""StockTwits fetch degrades (never raises) on transport errors, including the
http.client chunked-transfer exceptions that are not OSErrors (#1024)."""
"""StockTwits fetch: transport-error resilience (#1024) and crypto symbol
mapping (#1113).
StockTwits lists crypto under ``<BASE>.X`` (Yahoo's ``BTC-USD`` 404s), and any
transport error must degrade to a placeholder rather than raise.
"""
from __future__ import annotations
@@ -26,7 +30,7 @@ def _raise(exc):
@pytest.mark.unit
class StockTwitsResilienceTests:
class TestStockTwitsResilience:
@pytest.mark.parametrize(
"exc",
[
@@ -40,3 +44,34 @@ class StockTwitsResilienceTests:
out = stocktwits.fetch_stocktwits_messages("NVDA")
assert "unavailable" in out.lower()
assert out.startswith("<stocktwits unavailable")
@pytest.mark.unit
class TestStockTwitsCryptoSymbols:
@pytest.mark.parametrize(
("ticker", "expected"),
[
("BTC-USD", "BTC.X"),
("eth-usd", "ETH.X"),
("SOL-USD", "SOL.X"),
("BTCUSD", "BTC.X"), # undashed broker form
("BTC-USDT", "BTC.X"), # stablecoin quote
("AMD", "AMD"),
("BRK-B", "BRK-B"), # dashed class share: untouched
("GOLD", "GOLD"), # real equity (aliases elsewhere): untouched here
("XYZ-USD", "XYZ-USD"), # unknown base: not treated as crypto
],
)
def test_symbol_mapping(self, ticker, expected):
assert stocktwits._stocktwits_symbol(ticker) == expected
def test_crypto_pair_requests_dot_x_endpoint(self):
seen = {}
def fake_urlopen(req, timeout=None):
seen["url"] = req.full_url
raise TimeoutError("stop after capturing the URL")
with patch.object(stocktwits, "urlopen", side_effect=fake_urlopen):
stocktwits.fetch_stocktwits_messages("BTC-USD")
assert "/symbol/BTC.X.json" in seen["url"]
+21
View File
@@ -6,6 +6,7 @@ import pytest
from tradingagents.dataflows.symbol_utils import (
NoMarketDataError,
crypto_base,
is_yahoo_safe,
normalize_symbol,
)
@@ -77,5 +78,25 @@ class TestIsYahooSafe(unittest.TestCase):
self.assertFalse(is_yahoo_safe(sym))
@pytest.mark.unit
class TestCryptoBase(unittest.TestCase):
def test_resolves_known_crypto_forms(self):
for raw in ("BTC-USD", "BTCUSD", "btc-usdt", "BTC-USDC", "BTCUSD+"):
self.assertEqual(crypto_base(raw), "BTC")
self.assertEqual(crypto_base("ETH-USD"), "ETH")
self.assertEqual(crypto_base("sol-usd"), "SOL")
def test_non_crypto_returns_none(self):
# Plain equities, class shares, and real tickers that alias elsewhere
# (GOLD -> gold future on the Yahoo path) must NOT read as crypto.
for raw in ("AAPL", "BRK-B", "GOLD", "XYZ-USD", "EURUSD", "", None):
self.assertIsNone(crypto_base(raw))
def test_agrees_with_normalize_symbol(self):
# crypto_base is the shared primitive behind the -USD normalization.
self.assertEqual(normalize_symbol("BTCUSD"), "BTC-USD")
self.assertEqual(crypto_base("BTCUSD"), "BTC")
if __name__ == "__main__":
unittest.main()
+5 -2
View File
@@ -16,9 +16,12 @@ class TestTemperatureForwarding:
@pytest.mark.parametrize(
"provider,model",
[
# gpt-4.1 is intentionally a non-reasoning model: the GPT-5 family
# are reasoning models and correctly drop temperature (see
# test_openai_reasoning_effort), so forwarding is tested on gpt-4.1.
("openai", "gpt-4.1"),
("anthropic", "claude-sonnet-4-6"),
("google", "gemini-2.5-flash"),
("anthropic", "claude-sonnet-5"),
("google", "gemini-3.5-flash"),
("deepseek", "deepseek-chat"),
],
)
@@ -25,7 +25,7 @@ def create_news_analyst(llm):
]
system_message = (
f"You are a news researcher tasked with analyzing recent news and trends over the past week. Please write a comprehensive report of the current state of the world that is relevant for trading and macroeconomics. Use the available tools: get_news(query, start_date, end_date) for {asset_label}-specific or targeted news searches, get_global_news(curr_date, look_back_days, limit) for broader macroeconomic news, get_macro_indicators(indicator, curr_date, look_back_days) to ground macro commentary in actual data from FRED (e.g. 'cpi', 'core_pce', 'unemployment', 'fed_funds_rate', '10y_treasury', 'yield_curve'), and get_prediction_markets(topic, limit) for live market-implied probabilities of forward-looking events (e.g. 'Fed rate cut', 'recession 2026', geopolitical or sector events). Provide specific, actionable insights with supporting evidence to help traders make informed decisions."
f"You are a news researcher tasked with analyzing recent news and trends over the past week. Please write a comprehensive report of the current state of the world that is relevant for trading and macroeconomics. Use the available tools: get_news(ticker, start_date, end_date) for {asset_label}-specific news by ticker symbol, get_global_news(curr_date, look_back_days, limit) for broader macroeconomic news, get_macro_indicators(indicator, curr_date, look_back_days) to ground macro commentary in actual data from FRED (e.g. 'cpi', 'core_pce', 'unemployment', 'fed_funds_rate', '10y_treasury', 'yield_curve'), and get_prediction_markets(topic, limit) for live market-implied probabilities of forward-looking events (e.g. 'Fed rate cut', 'recession 2026', geopolitical or sector events). Provide specific, actionable insights with supporting evidence to help traders make informed decisions."
+ """ Make sure to append a Markdown table at the end of the report to organize key points in the report, organized and easy to read."""
+ get_language_instruction()
)
@@ -1,21 +1,30 @@
import json
from .alpha_vantage_common import _make_api_request
def _filter_reports_by_date(result, curr_date: str):
"""Filter annualReports/quarterlyReports to exclude entries after curr_date.
"""Drop annual/quarterly reports dated after curr_date to prevent look-ahead.
Prevents look-ahead bias by removing fiscal periods that end after
the simulation's current date.
``_make_api_request`` returns the fundamentals payload as a JSON string, so
parse, filter, and re-serialize. A non-JSON body or an unset ``curr_date`` is
returned unchanged.
"""
if not curr_date or not isinstance(result, dict):
if not curr_date or not isinstance(result, str):
return result
try:
payload = json.loads(result)
except json.JSONDecodeError:
return result
if not isinstance(payload, dict):
return result
for key in ("annualReports", "quarterlyReports"):
if key in result:
result[key] = [
r for r in result[key]
if isinstance(payload.get(key), list):
payload[key] = [
r for r in payload[key]
if r.get("fiscalDateEnding", "") <= curr_date
]
return result
return json.dumps(payload)
def get_fundamentals(ticker: str, curr_date: str = None) -> str:
@@ -35,6 +35,6 @@ def get_stock(
"datatype": "csv",
}
response = _make_api_request("TIME_SERIES_DAILY_ADJUSTED", params)
response = _make_api_request("TIME_SERIES_DAILY", params)
return _filter_csv_by_date_range(response, start_date, end_date)
+11 -6
View File
@@ -15,23 +15,28 @@ from datetime import datetime, timezone
import requests
from .ssh_proxy_pool import ssh_proxy_fetch_json
logger = logging.getLogger(__name__)
GAMMA_BASE = "https://gamma-api.polymarket.com"
# Network timeout (seconds), consistent with the other vendors.
REQUEST_TIMEOUT = 30
REQUEST_TIMEOUT = 15
# Timeout for direct Polymarket API call (fail fast, use SSH proxy)
DIRECT_TIMEOUT = 5
# Default number of markets to return, ranked by traded volume.
DEFAULT_LIMIT = 6
def _request(path: str, params: dict) -> dict:
response = requests.get(
f"{GAMMA_BASE}/{path}", params=params, timeout=REQUEST_TIMEOUT
)
response.raise_for_status()
return response.json()
url = f"{GAMMA_BASE}/{path}"
# Skip local request, go directly through SSH proxy
query = "&".join(f"{k}={v}" for k, v in params.items())
full_url = f"{url}?{query}"
return ssh_proxy_fetch_json(full_url, timeout=REQUEST_TIMEOUT)
def _parse_json_list(value) -> list:
+13 -2
View File
@@ -30,6 +30,9 @@ from urllib.error import HTTPError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from .symbol_utils import crypto_base
from .stockstats_utils import _fetch_via_ssh
logger = logging.getLogger(__name__)
_API = "https://www.reddit.com/r/{sub}/search.json?{qs}"
@@ -121,8 +124,13 @@ def _fetch_subreddit_rss(
except (OSError, http.client.HTTPException, ET.ParseError) as exc:
# OSError covers URLError/TimeoutError/connection resets; HTTPException
# covers chunked-transfer errors (IncompleteRead/BadStatusLine, #1024).
logger.warning("Reddit RSS fetch failed for r/%s · %s: %s", sub, ticker, exc)
return []
logger.warning("Reddit RSS direct fetch failed for r/%s · %s: %s, trying SSH proxy...", sub, ticker, exc)
try:
proxy_data = _fetch_via_ssh(url, timeout=timeout)
root = ET.fromstring(proxy_data.encode() if isinstance(proxy_data, str) else proxy_data)
except Exception as proxy_exc:
logger.warning("Reddit RSS proxy also failed for r/%s · %s: %s", sub, ticker, proxy_exc)
return []
posts = []
for entry in root.findall("atom:entry", _ATOM_NS)[:limit]:
@@ -200,6 +208,9 @@ def fetch_reddit_posts(
stay under Reddit's public per-IP rate limit; combined with the RSS-first
path it makes 429s rare even when several analyses run back-to-back.
"""
# Crypto reaches us as a Yahoo pair (BTC-USD); search Reddit for the base
# ("BTC") so the query actually matches discussion instead of near-nothing.
ticker = crypto_base(ticker) or ticker
blocks = []
total_posts = 0
for i, sub in enumerate(subreddits):
+123
View File
@@ -0,0 +1,123 @@
"""SSH Proxy Connection Pool — 串行化所有SSH中转请求,避免并发资源竞争。
所有需要走中转的数据源统一调用 ssh_proxy_fetch(url),
内部通过队列串行化SSH连接,避免多线程/多进程并发导致的连接冲突。
"""
import json
import logging
import subprocess
import threading
import time
from queue import Queue, Empty
from typing import Optional
logger = logging.getLogger(__name__)
# SSH配置缓存
_ssh_config: Optional[tuple] = None
_config_lock = threading.Lock()
def _get_ssh_config() -> tuple:
"""获取SSH配置(host, port, password),带缓存。"""
global _ssh_config
if _ssh_config is not None:
return _ssh_config
with _config_lock:
if _ssh_config is not None:
return _ssh_config
from .stockstats_utils import _get_ssh_config as _load_config
_ssh_config = _load_config()
return _ssh_config
class SSHProxyPool:
"""SSH代理连接池 — 串行化所有SSH请求。"""
def __init__(self):
self._queue = Queue()
self._lock = threading.Lock()
self._initialized = False
def _ensure_initialized(self):
"""确保连接池已初始化。"""
if self._initialized:
return
with self._lock:
if self._initialized:
return
# 放一个占位符,表示可以发起请求
self._queue.put(True)
self._initialized = True
def fetch(self, url: str, timeout: int = 30) -> str:
"""通过SSH代理获取URL内容,串行化执行。
Args:
url: 要获取的URL
timeout: 超时时间(秒)
Returns:
URL响应内容字符串
Raises:
RuntimeError: SSH代理失败时抛出
"""
self._ensure_initialized()
# 等待轮到自己(最多等timeout秒)
try:
self._queue.get(timeout=timeout)
except Empty:
raise RuntimeError(f"SSH proxy queue timeout after {timeout}s")
try:
host, port, password = _get_ssh_config()
cmd = [
"sshpass", "-p", password,
"ssh", "-o", "StrictHostKeyChecking=no",
"-o", "ConnectTimeout=15",
"-o", "ServerAliveInterval=10",
"-p", str(port), f"root@{host}",
f'curl -sL --max-time {timeout} "{url}"'
]
result = subprocess.run(
cmd, capture_output=True, text=True,
timeout=timeout + 10 # 给SSH额外时间
)
if result.returncode != 0:
raise RuntimeError(f"SSH proxy failed (rc={result.returncode}): {result.stderr[:200]}")
if not result.stdout or not result.stdout.strip():
raise RuntimeError("SSH proxy returned empty response")
return result.stdout
except subprocess.TimeoutExpired:
raise RuntimeError(f"SSH proxy subprocess timeout after {timeout}s")
finally:
# 无论成功失败,都放回占位符,让下一个请求执行
self._queue.put(True)
def fetch_json(self, url: str, timeout: int = 30) -> dict:
"""通过SSH代理获取JSON响应。"""
text = self.fetch(url, timeout)
try:
return json.loads(text)
except json.JSONDecodeError as e:
raise RuntimeError(f"SSH proxy returned invalid JSON: {e}")
# 全局单例
_pool = SSHProxyPool()
def ssh_proxy_fetch(url: str, timeout: int = 30) -> str:
"""通过SSH代理获取URL内容(串行化)。"""
return _pool.fetch(url, timeout)
def ssh_proxy_fetch_json(url: str, timeout: int = 30) -> dict:
"""通过SSH代理获取JSON响应(串行化)。"""
return _pool.fetch_json(url, timeout)
+181 -34
View File
@@ -1,6 +1,8 @@
import logging
import os
import time
import json
import subprocess
from typing import Annotated
import pandas as pd
@@ -19,6 +21,72 @@ logger = logging.getLogger(__name__)
# enough to catch the year-old frames yfinance occasionally returns (#1021).
MAX_OHLCV_STALE_DAYS = 10
_ssh_config_cache = None
def _get_ssh_config():
"""Load SSH credentials for foreign server from vault (cached)."""
global _ssh_config_cache
if _ssh_config_cache is not None:
return _ssh_config_cache
result = subprocess.run(
["bash", "-c",
'source <(bash /root/.openclaw/workspace/scripts/vault.sh decrypt '
'/root/.openclaw/workspace/secrets/credentials.env.enc) '
'&& echo "$FOREIGN_HOST $FOREIGN_PORT $FOREIGN_PASS"'],
capture_output=True, text=True, timeout=10
)
parts = result.stdout.strip().split()
if len(parts) != 3:
raise RuntimeError(f"Failed to load SSH config: {result.stderr}")
_ssh_config_cache = (parts[0], int(parts[1]), parts[2])
return _ssh_config_cache
def _fetch_via_ssh(url: str, timeout: int = 30) -> str:
"""Fetch a URL through the foreign server via SSH tunnel (uses connection pool)."""
from .ssh_proxy_pool import ssh_proxy_fetch
return ssh_proxy_fetch(url, timeout)
def _yf_download_via_proxy(symbol: str, start: str, end: str) -> pd.DataFrame:
"""Download OHLCV data via SSH proxy when local Yahoo Finance is rate-limited."""
import datetime
# Convert dates to timestamps
start_ts = int(pd.Timestamp(start).timestamp())
end_ts = int(pd.Timestamp(end).timestamp())
url = (f"https://query2.finance.yahoo.com/v8/finance/chart/{symbol}"
f"?period1={start_ts}&period2={end_ts}&interval=1d")
data_str = _fetch_via_ssh(url)
parsed = json.loads(data_str)
result = parsed.get("chart", {}).get("result", [])
if not result:
return pd.DataFrame()
ts = result[0].get("timestamp", [])
indicators = result[0].get("indicators", {}).get("quote", [{}])[0]
if not ts:
return pd.DataFrame()
df = pd.DataFrame({
"Date": pd.to_datetime(ts, unit="s"),
"Open": indicators.get("open", []),
"High": indicators.get("high", []),
"Low": indicators.get("low", []),
"Close": indicators.get("close", []),
"Volume": indicators.get("volume", []),
})
# Convert None values to NaN
for col in ["Open", "High", "Low", "Close", "Volume"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
df = df.dropna(subset=["Close"])
return df
def yf_retry(func, max_retries=3, base_delay=2.0):
"""Execute a yfinance call with exponential backoff on rate limits.
@@ -27,16 +95,8 @@ def yf_retry(func, max_retries=3, base_delay=2.0):
retry them internally. This wrapper adds retry logic specifically
for rate limits. Other exceptions propagate immediately.
"""
for attempt in range(max_retries + 1):
try:
return func()
except YFRateLimitError:
if attempt < max_retries:
delay = base_delay * (2 ** attempt)
logger.warning(f"Yahoo Finance rate limited, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries})")
time.sleep(delay)
else:
raise
# Skip local Yahoo Finance entirely - always use SSH proxy
raise YFRateLimitError("Local Yahoo Finance disabled, use SSH proxy")
def _ensure_date_column(data: pd.DataFrame) -> pd.DataFrame:
@@ -122,6 +182,82 @@ def _assert_ohlcv_not_stale(
)
def _load_ohlcv_alpha_vantage(symbol: str, curr_date: str) -> pd.DataFrame:
"""Fetch OHLCV data from Alpha Vantage, filtered to prevent look-ahead bias."""
from .alpha_vantage_common import _make_api_request
from io import StringIO
canonical = normalize_symbol(symbol)
curr_date_dt = pd.to_datetime(curr_date)
today_date = pd.Timestamp.today()
start_date = today_date - pd.DateOffset(years=5)
start_str = start_date.strftime("%Y-%m-%d")
# Determine outputsize based on how far back we need
days_from_today = (today_date - start_date).days
outputsize = "compact" if days_from_today < 100 else "full"
params = {
"symbol": canonical,
"outputsize": outputsize,
"datatype": "csv",
}
try:
csv_text = _make_api_request("TIME_SERIES_DAILY", params)
except Exception as e:
raise NoMarketDataError(symbol, canonical, f"Alpha Vantage error: {e}") from e
if not csv_text or not csv_text.strip():
raise NoMarketDataError(symbol, canonical, "Alpha Vantage returned empty data")
try:
df = pd.read_csv(StringIO(csv_text))
except Exception as e:
raise NoMarketDataError(symbol, canonical, f"Failed to parse Alpha Vantage CSV: {e}") from e
if df.empty:
raise NoMarketDataError(symbol, canonical, "Alpha Vantage returned no rows")
# Alpha Vantage CSV has 'timestamp' column; normalize to 'Date'
date_col = df.columns[0] # usually 'timestamp'
df = df.rename(columns={date_col: "Date"})
# Keep only standard OHLCV columns
keep_cols = ["Date"]
col_map = {}
for av_col, std_col in [
("open", "Open"), ("high", "High"), ("low", "Low"),
("close", "Close"), ("adjusted_close", "Close"), ("volume", "Volume"),
]:
for c in df.columns:
if c.lower().replace(" ", "_") == av_col:
if std_col not in keep_cols:
keep_cols.append(std_col)
col_map[c] = std_col
break
df = df.rename(columns=col_map)
# Prefer adjusted_close over close for "Close"
if "adjusted_close" in [c.lower().replace(" ", "_") for c in df.columns]:
for c in df.columns:
if c.lower().replace(" ", "_") == "adjusted_close":
df["Close"] = pd.to_numeric(df[c], errors="coerce")
break
available = [c for c in ["Date", "Open", "High", "Low", "Close", "Volume"] if c in df.columns]
df = df[available]
# Filter to curr_date
df["Date"] = pd.to_datetime(df["Date"], errors="coerce")
df = df.dropna(subset=["Date"])
df = df[df["Date"] <= curr_date_dt].sort_values("Date")
if df.empty:
raise NoMarketDataError(symbol, canonical, "No data on or before " + curr_date)
return df
def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
"""Fetch OHLCV data with caching, filtered to prevent look-ahead bias.
@@ -138,55 +274,66 @@ def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
config = get_config()
curr_date_dt = pd.to_datetime(curr_date)
# Check which vendor to use for OHLCV
vendor_config = config.get("data_vendors", {}).get("core_stock_apis", "yfinance")
use_alpha_vantage = "alpha_vantage" in vendor_config
# Cache uses a fixed window (5y to today) so one file per symbol.
today_date = pd.Timestamp.today()
start_date = today_date - pd.DateOffset(years=5)
start_str = start_date.strftime("%Y-%m-%d")
# yfinance ``end`` is EXCLUSIVE; request tomorrow so today's row is included
# when curr_date is the current day (#986). Look-ahead is still prevented by
# the curr_date filter below.
end_str = (today_date + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
cache_prefix = "AV" if use_alpha_vantage else "YFin"
os.makedirs(config["data_cache_dir"], exist_ok=True)
data_file = os.path.join(
config["data_cache_dir"],
f"{safe_symbol}-YFin-data-{start_str}-{end_str}.csv",
f"{safe_symbol}-{cache_prefix}-data-{start_str}-{end_str}.csv",
)
# A cached file may be empty if a prior fetch failed (unknown symbol,
# transient rate limit). Treat an empty/columnless cache as a miss and
# re-fetch rather than serving the poisoned file forever.
# A cached file may be empty if a prior fetch failed. Treat empty cache as miss.
data = None
if os.path.exists(data_file):
cached = pd.read_csv(data_file, on_bad_lines="skip", encoding="utf-8")
if not cached.empty and "Close" in cached.columns:
# Ensure Date column is datetime after loading from cache
if "Date" in cached.columns:
cached["Date"] = pd.to_datetime(cached["Date"], errors="coerce")
cached = cached.dropna(subset=["Date"])
data = cached
if data is None:
downloaded = yf_retry(lambda: yf.download(
canonical,
start=start_str,
end=end_str,
multi_level_index=False,
progress=False,
auto_adjust=True,
))
downloaded = _ensure_date_column(downloaded.reset_index())
# Only cache real data — never persist an empty frame.
if downloaded.empty or "Close" not in downloaded.columns:
raise NoMarketDataError(
symbol, canonical, "Yahoo Finance returned no rows"
)
if use_alpha_vantage:
downloaded = _load_ohlcv_alpha_vantage(symbol, curr_date)
else:
# Skip local Yahoo Finance, go directly through SSH proxy
logger.info(f"Yahoo Finance: fetching {canonical} via SSH proxy...")
try:
downloaded = _yf_download_via_proxy(canonical, start_str, end_str)
except Exception as proxy_err:
raise NoMarketDataError(
symbol, canonical, f"Yahoo Finance proxy failed: {proxy_err}"
) from proxy_err
if downloaded.empty:
raise NoMarketDataError(
symbol, canonical, f"Yahoo Finance proxy returned no data"
)
if downloaded.empty or "Close" not in downloaded.columns:
raise NoMarketDataError(
symbol, canonical, "Yahoo Finance returned no rows"
)
downloaded = _clean_dataframe(downloaded)
downloaded.to_csv(data_file, index=False, encoding="utf-8")
data = downloaded
data = _clean_dataframe(data)
if not use_alpha_vantage:
data = _clean_dataframe(data)
# Filter to curr_date to prevent look-ahead bias in backtesting
data = data[data["Date"] <= curr_date_dt]
# Reject a stale frame (latest row far older than curr_date) rather than
# feeding year-old prices into indicators (#1021).
# Reject a stale frame
_assert_ohlcv_not_stale(data, curr_date, symbol, canonical)
return data
+22 -3
View File
@@ -19,12 +19,26 @@ import json
import logging
from urllib.request import Request, urlopen
from .symbol_utils import crypto_base
from .stockstats_utils import _fetch_via_ssh
logger = logging.getLogger(__name__)
_API = "https://api.stocktwits.com/api/2/streams/symbol/{ticker}.json"
_UA = "tradingagents/0.2 (+https://github.com/TauricResearch/TradingAgents)"
def _stocktwits_symbol(ticker: str) -> str:
"""Map a crypto pair to StockTwits' ``<BASE>.X`` convention.
StockTwits lists crypto as ``BTC.X`` (Yahoo's ``BTC-USD`` form 404s), so any
crypto symbol resolves to its base plus ``.X``; other symbols pass through
upper-cased.
"""
base = crypto_base(ticker)
return f"{base}.X" if base else ticker.strip().upper()
def fetch_stocktwits_messages(ticker: str, limit: int = 30, timeout: float = 10.0) -> str:
"""Fetch recent StockTwits messages for ``ticker`` and return them as a
formatted plaintext block ready for prompt injection.
@@ -33,7 +47,7 @@ def fetch_stocktwits_messages(ticker: str, limit: int = 30, timeout: float = 10.
symbol has no messages, or the response shape is unexpected — the
caller never has to special-case None or exceptions.
"""
url = _API.format(ticker=ticker.upper())
url = _API.format(ticker=_stocktwits_symbol(ticker))
req = Request(url, headers={"User-Agent": _UA, "Accept": "application/json"})
try:
with urlopen(req, timeout=timeout) as resp:
@@ -41,8 +55,13 @@ def fetch_stocktwits_messages(ticker: str, limit: int = 30, timeout: float = 10.
except (OSError, http.client.HTTPException, json.JSONDecodeError) as exc:
# OSError covers URLError/TimeoutError/connection resets; HTTPException
# covers chunked-transfer errors (IncompleteRead/BadStatusLine, #1024).
logger.warning("StockTwits fetch failed for %s: %s", ticker, exc)
return f"<stocktwits unavailable: {type(exc).__name__}>"
logger.warning("StockTwits direct fetch failed for %s: %s, trying SSH proxy...", ticker, exc)
try:
proxy_data = _fetch_via_ssh(url, timeout=timeout)
data = json.loads(proxy_data)
except Exception as proxy_exc:
logger.warning("StockTwits proxy also failed for %s: %s", ticker, proxy_exc)
return f"<stocktwits unavailable: {type(exc).__name__}>"
messages = data.get("messages", []) if isinstance(data, dict) else []
if not messages:
+14 -9
View File
@@ -80,22 +80,27 @@ _YAHOO_SAFE = re.compile(r"^[A-Za-z0-9._\-\^=]+$")
_CRYPTO_QUOTES = ("USDT", "USDC", "USD")
def _normalize_crypto(s: str) -> str | None:
"""Return ``<BASE>-USD`` if ``s`` is a known crypto quoted in USD/USDT/USDC.
Accepts dashed or undashed forms: ``BTCUSD``, ``BTCUSDT``, ``BTC-USDT``,
``BTC-USDC`` all resolve to ``BTC-USD``. Returns None otherwise.
def crypto_base(raw: str) -> str | None:
"""Return the crypto base (e.g. ``BTC``) for a known USD/USDT/USDC-quoted
crypto symbol in any form the pipeline may hold — ``BTC-USD``, ``BTCUSD``,
``BTC-USDT`` — or None for non-crypto symbols. Purely syntactic.
"""
compact = s.replace("-", "")
if not isinstance(raw, str):
return None
compact = raw.strip().upper().rstrip("+").replace("-", "")
for quote in _CRYPTO_QUOTES:
if compact.endswith(quote):
base = compact[: -len(quote)]
if base in _CRYPTO_BASES:
return f"{base}-USD"
break
return base if base in _CRYPTO_BASES else None
return None
def _normalize_crypto(s: str) -> str | None:
"""Return ``<BASE>-USD`` for a known USD/USDT/USDC-quoted crypto, else None."""
base = crypto_base(s)
return f"{base}-USD" if base else None
def normalize_symbol(raw: str) -> str:
"""Map a user/broker symbol to its canonical Yahoo Finance symbol.
+13 -1
View File
@@ -1,13 +1,17 @@
import logging
from datetime import datetime
from typing import Annotated
import pandas as pd
import yfinance as yf
logger = logging.getLogger(__name__)
from dateutil.relativedelta import relativedelta
from .stockstats_utils import (
StockstatsUtils,
_assert_ohlcv_not_stale,
_yf_download_via_proxy,
filter_financials_by_date,
load_ohlcv,
yf_retry,
@@ -32,7 +36,15 @@ def get_YFin_data_online(
# end_date row (and the current day when end_date is today). Request one day
# past end_date so the requested range is actually inclusive (#986/#987).
end_inclusive = (end_dt + relativedelta(days=1)).strftime("%Y-%m-%d")
data = yf_retry(lambda: ticker.history(start=start_date, end=end_inclusive))
# Skip local Yahoo Finance (rate-limited), go directly through SSH proxy
logger.info(f"Yahoo Finance: fetching {canonical} via SSH proxy...")
proxy_df = _yf_download_via_proxy(canonical, start_date, end_inclusive)
if proxy_df.empty:
raise NoMarketDataError(
symbol, canonical, f"no rows between {start_date} and {end_date} (proxy failed)"
)
proxy_df = proxy_df.set_index("Date")
data = proxy_df
# Empty result means the symbol is unknown/delisted. Raise a typed error
# instead of returning prose: the routing layer turns it into a single
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Yahoo Finance proxy via foreign server.
Routes yfinance requests through the foreign server to bypass local IP rate limiting.
"""
import subprocess
import json
import os
import sys
def _get_ssh_config():
"""Load SSH credentials from vault."""
result = subprocess.run(
["bash", "-c", 'source <(bash /root/.openclaw/workspace/scripts/vault.sh decrypt /root/.openclaw/workspace/secrets/credentials.env.enc) && echo "$FOREIGN_HOST $FOREIGN_PORT $FOREIGN_PASS"'],
capture_output=True, text=True, timeout=10
)
parts = result.stdout.strip().split()
if len(parts) != 3:
raise RuntimeError(f"Failed to load SSH config: {result.stderr}")
return parts[0], int(parts[1]), parts[2]
def fetch_yahoo_via_ssh(url: str, timeout: int = 30) -> str:
"""Fetch a Yahoo Finance URL through the foreign server via SSH."""
host, port, password = _get_ssh_config()
cmd = [
"sshpass", "-p", password,
"ssh", "-o", "StrictHostKeyChecking=no",
"-o", f"ConnectTimeout=15",
"-p", str(port), f"root@{host}",
f'curl -s "{url}" -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"'
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if result.returncode != 0:
raise RuntimeError(f"SSH fetch failed: {result.stderr}")
return result.stdout
def test_connection() -> bool:
"""Test if Yahoo Finance is accessible via foreign server."""
try:
data = fetch_yahoo_via_ssh(
"https://query2.finance.yahoo.com/v8/finance/chart/AAPL?interval=1d&range=5d"
)
parsed = json.loads(data)
return "chart" in parsed and "result" in parsed.get("chart", {})
except Exception as e:
print(f"Connection test failed: {e}", file=sys.stderr)
return False
if __name__ == "__main__":
if test_connection():
print("✅ Yahoo Finance proxy via foreign server: OK")
else:
print("❌ Yahoo Finance proxy via foreign server: FAILED")
+5
View File
@@ -18,6 +18,7 @@ _ENV_OVERRIDES = {
"TRADINGAGENTS_CHECKPOINT_ENABLED": "checkpoint_enabled",
"TRADINGAGENTS_BENCHMARK_TICKER": "benchmark_ticker",
"TRADINGAGENTS_TEMPERATURE": "temperature",
"TRADINGAGENTS_LLM_MAX_RETRIES": "llm_max_retries",
# Provider-specific reasoning/thinking knobs (None = each provider's own
# default). Settable here for non-interactive runs; the CLI also offers an
# interactive choice, which is skipped when the matching var is set.
@@ -95,6 +96,10 @@ DEFAULT_CONFIG = _apply_env_overrides({
# variation on models that honor it; reasoning models largely ignore it
# and no setting makes LLM output bit-identical across runs (see README).
"temperature": None,
# SDK retry budget forwarded to every provider chat client. None leaves each
# provider/SDK at its own default (usually 2). Raise it to ride out bursty
# 429 throttling on rate-limited deployments instead of aborting a run (#1091).
"llm_max_retries": None,
# Checkpoint/resume: when True, LangGraph saves state after each node
# so a crashed run can resume from the last successful step.
"checkpoint_enabled": False,
+17 -9
View File
@@ -25,9 +25,17 @@ def _db_path(data_dir: str | Path, ticker: str) -> Path:
return p / f"{safe}.db"
def thread_id(ticker: str, date: str) -> str:
"""Deterministic thread ID for a ticker+date pair."""
return hashlib.sha256(f"{ticker.upper()}:{date}".encode()).hexdigest()[:16]
def thread_id(ticker: str, date: str, signature: str = "") -> str:
"""Deterministic thread ID for a ticker+date pair.
``signature`` folds in graph-shape-affecting run choices so a resume under a
different graph can't reuse this checkpoint (#1089); omitting it keeps the
legacy ID.
"""
base = f"{ticker.upper()}:{date}"
if signature:
base = f"{base}:{signature}"
return hashlib.sha256(base.encode()).hexdigest()[:16]
@contextmanager
@@ -43,17 +51,17 @@ def get_checkpointer(data_dir: str | Path, ticker: str) -> Generator[SqliteSaver
conn.close()
def has_checkpoint(data_dir: str | Path, ticker: str, date: str) -> bool:
def has_checkpoint(data_dir: str | Path, ticker: str, date: str, signature: str = "") -> bool:
"""Check whether a resumable checkpoint exists for ticker+date."""
return checkpoint_step(data_dir, ticker, date) is not None
return checkpoint_step(data_dir, ticker, date, signature) is not None
def checkpoint_step(data_dir: str | Path, ticker: str, date: str) -> int | None:
def checkpoint_step(data_dir: str | Path, ticker: str, date: str, signature: str = "") -> int | None:
"""Return the step number of the latest checkpoint, or None if none exists."""
db = _db_path(data_dir, ticker)
if not db.exists():
return None
tid = thread_id(ticker, date)
tid = thread_id(ticker, date, signature)
with get_checkpointer(data_dir, ticker) as saver:
config = {"configurable": {"thread_id": tid}}
cp = saver.get_tuple(config)
@@ -73,12 +81,12 @@ def clear_all_checkpoints(data_dir: str | Path) -> int:
return len(dbs)
def clear_checkpoint(data_dir: str | Path, ticker: str, date: str) -> None:
def clear_checkpoint(data_dir: str | Path, ticker: str, date: str, signature: str = "") -> None:
"""Remove checkpoint for a specific ticker+date by deleting the thread's rows."""
db = _db_path(data_dir, ticker)
if not db.exists():
return
tid = thread_id(ticker, date)
tid = thread_id(ticker, date, signature)
conn = sqlite3.connect(str(db))
try:
for table in ("writes", "checkpoints"):
+30 -41
View File
@@ -25,6 +25,22 @@ from tradingagents.agents.utils.agent_states import AgentState
from .analyst_execution import build_analyst_execution_plan
from .conditional_logic import ConditionalLogic
# Every target a shared conditional router can return. Each edge driven by the
# router maps all of them, so a fall-through return (e.g. under prompt/i18n/
# refactor drift in the speaker labels) can never hit a missing path_map entry
# and crash LangGraph mid-run (#1088).
DEBATE_PATH_MAP = {
"Bull Researcher": "Bull Researcher",
"Bear Researcher": "Bear Researcher",
"Research Manager": "Research Manager",
}
RISK_ANALYSIS_PATH_MAP = {
"Aggressive Analyst": "Aggressive Analyst",
"Conservative Analyst": "Conservative Analyst",
"Neutral Analyst": "Neutral Analyst",
"Portfolio Manager": "Portfolio Manager",
}
class GraphSetup:
"""Handles the setup and configuration of the agent graph."""
@@ -118,49 +134,22 @@ class GraphSetup:
else:
workflow.add_edge(current_clear, "Bull Researcher")
# Add remaining edges
workflow.add_conditional_edges(
"Bull Researcher",
self.conditional_logic.should_continue_debate,
{
"Bear Researcher": "Bear Researcher",
"Research Manager": "Research Manager",
},
)
workflow.add_conditional_edges(
"Bear Researcher",
self.conditional_logic.should_continue_debate,
{
"Bull Researcher": "Bull Researcher",
"Research Manager": "Research Manager",
},
)
# Both research-debate edges share the complete DEBATE_PATH_MAP (#1088).
for debate_node in ("Bull Researcher", "Bear Researcher"):
workflow.add_conditional_edges(
debate_node,
self.conditional_logic.should_continue_debate,
DEBATE_PATH_MAP,
)
workflow.add_edge("Research Manager", "Trader")
workflow.add_edge("Trader", "Aggressive Analyst")
workflow.add_conditional_edges(
"Aggressive Analyst",
self.conditional_logic.should_continue_risk_analysis,
{
"Conservative Analyst": "Conservative Analyst",
"Portfolio Manager": "Portfolio Manager",
},
)
workflow.add_conditional_edges(
"Conservative Analyst",
self.conditional_logic.should_continue_risk_analysis,
{
"Neutral Analyst": "Neutral Analyst",
"Portfolio Manager": "Portfolio Manager",
},
)
workflow.add_conditional_edges(
"Neutral Analyst",
self.conditional_logic.should_continue_risk_analysis,
{
"Aggressive Analyst": "Aggressive Analyst",
"Portfolio Manager": "Portfolio Manager",
},
)
# All three risk edges share the complete RISK_ANALYSIS_PATH_MAP (#1088).
for risk_node in ("Aggressive Analyst", "Conservative Analyst", "Neutral Analyst"):
workflow.add_conditional_edges(
risk_node,
self.conditional_logic.should_continue_risk_analysis,
RISK_ANALYSIS_PATH_MAP,
)
workflow.add_edge("Portfolio Manager", END)
+48 -4
View File
@@ -44,6 +44,24 @@ from .signal_processing import SignalProcessor
logger = logging.getLogger(__name__)
def _coerce_max_retries(value):
"""Validate an ``llm_max_retries`` value to a non-negative int.
Accepts an int or a numeric string (env vars arrive as strings). Rejects
booleans and negatives loudly so a misconfiguration fails at startup rather
than silently disabling retries.
"""
if isinstance(value, bool):
raise ValueError(f"llm_max_retries must be an integer, not a boolean: {value!r}")
try:
n = int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"llm_max_retries must be an integer, got {value!r}") from exc
if n < 0:
raise ValueError(f"llm_max_retries must be >= 0, got {n}")
return n
class TradingAgentsGraph:
"""Main class that orchestrates the trading agents framework."""
@@ -124,6 +142,9 @@ class TradingAgentsGraph:
self.ticker = None
self.log_states_dict = {} # date to full state dict
# Graph-shape-affecting run choices, kept for the checkpoint signature.
self.selected_analysts = tuple(selected_analysts)
# Set up the graph: keep the workflow for recompilation with a checkpointer.
self.workflow = self.graph_setup.setup_graph(selected_analysts)
self.graph = self.workflow.compile()
@@ -156,6 +177,12 @@ class TradingAgentsGraph:
if temperature is not None and temperature != "":
kwargs["temperature"] = float(temperature)
# SDK retry budget is cross-provider. Forward it only when explicitly set
# so each provider keeps its own default (usually 2) otherwise (#1091).
max_retries = self.config.get("llm_max_retries")
if max_retries is not None and max_retries != "":
kwargs["max_retries"] = _coerce_max_retries(max_retries)
return kwargs
def _create_tool_nodes(self) -> dict[str, ToolNode]:
@@ -318,6 +345,20 @@ class TradingAgentsGraph:
identity = resolve_instrument_identity(ticker)
return build_instrument_context(ticker, asset_type, identity)
def _run_signature(self, asset_type: str) -> str:
"""Graph-shape inputs that must invalidate a checkpoint if changed.
Keyed into the checkpoint thread ID so a resume under a different analyst
selection, debate/risk depth, or asset mode starts fresh instead of
silently continuing the previous graph (#1089).
"""
return "|".join([
"analysts=" + ",".join(self.selected_analysts),
f"debate={self.config['max_debate_rounds']}",
f"risk={self.config['max_risk_discuss_rounds']}",
f"asset={asset_type}",
])
def propagate(self, company_name, trade_date, asset_type: str = "stock"):
"""Run the trading agents graph for a company on a specific date.
@@ -342,7 +383,8 @@ class TradingAgentsGraph:
self.graph = self.workflow.compile(checkpointer=saver)
step = checkpoint_step(
self.config["data_cache_dir"], company_name, str(trade_date)
self.config["data_cache_dir"], company_name, str(trade_date),
self._run_signature(asset_type),
)
if step is not None:
logger.info(
@@ -389,9 +431,10 @@ class TradingAgentsGraph:
)
args = self.propagator.get_graph_args()
# Inject thread_id so same ticker+date resumes, different date starts fresh.
# Inject thread_id so same ticker+date+graph-shape resumes; a different
# date or graph shape starts fresh (#1089).
if self.config.get("checkpoint_enabled"):
tid = thread_id(company_name, str(trade_date))
tid = thread_id(company_name, str(trade_date), self._run_signature(asset_type))
args.setdefault("config", {}).setdefault("configurable", {})["thread_id"] = tid
if self.debug:
@@ -432,7 +475,8 @@ class TradingAgentsGraph:
# Clear checkpoint on successful completion to avoid stale state.
if self.config.get("checkpoint_enabled"):
clear_checkpoint(
self.config["data_cache_dir"], company_name, str(trade_date)
self.config["data_cache_dir"], company_name, str(trade_date),
self._run_signature(asset_type),
)
return final_state, self.process_signal(final_state["final_trade_decision"])
+11 -8
View File
@@ -11,16 +11,17 @@ _PASSTHROUGH_KWARGS = (
"callbacks", "http_client", "http_async_client", "effort",
)
# Anthropic's extended-thinking ``effort`` parameter is accepted by Opus 4.5+
# and Sonnet 4.6+ only. Sonnet 4.5 and any Haiku version 400 with
# ``"This model does not support the effort parameter"`` (#831). The per-family
# minimum version below is forward-compatible: future ``claude-{opus,sonnet}-X-Y``
# releases inherit support automatically, while Sonnet 4.5 and Haiku stay excluded.
# Anthropic's extended-thinking ``effort`` parameter is accepted by Opus 4.5+,
# Sonnet 4.6+, and the Claude 5 family (Sonnet 5, Fable 5). Sonnet 4.5 and any
# Haiku version 400 with ``"This model does not support the effort parameter"``
# (#831). Versions may be dotted (``opus-4-8``) or single-number (``sonnet-5``,
# ``fable-5``); the per-family minimum below is forward-compatible.
_EFFORT_EXACT = {
"claude-mythos-preview", # non-standard preview name; effort-capable
"claude-mythos-5", # Fable 5 twin (Project Glasswing); effort-capable
}
_EFFORT_MODEL = re.compile(r"^claude-(opus|sonnet)-(\d+)-(\d+)$")
_EFFORT_MIN_VERSION = {"opus": (4, 5), "sonnet": (4, 6)}
_EFFORT_MODEL = re.compile(r"^claude-(opus|sonnet|fable)-(\d+)(?:-(\d+))?$")
_EFFORT_MIN_VERSION = {"opus": (4, 5), "sonnet": (4, 6), "fable": (5, 0)}
def _supports_effort(model: str) -> bool:
@@ -31,7 +32,9 @@ def _supports_effort(model: str) -> bool:
match = _EFFORT_MODEL.match(model_lc)
if not match:
return False
family, major, minor = match.group(1), int(match.group(2)), int(match.group(3))
family = match.group(1)
major = int(match.group(2))
minor = int(match.group(3)) if match.group(3) else 0
return (major, minor) >= _EFFORT_MIN_VERSION[family]
+12 -5
View File
@@ -41,11 +41,12 @@ def _bedrock_class():
class BedrockClient(BaseLLMClient):
"""Client for Amazon Bedrock via the Converse API (langchain-aws).
Authentication uses the standard AWS credential chain (env vars,
``~/.aws/credentials``, or an IAM role); set ``AWS_REGION`` /
``AWS_DEFAULT_REGION`` and optionally ``AWS_PROFILE``. The model name is a
Bedrock model ID or cross-region inference profile ID, e.g.
``us.anthropic.claude-opus-4-8-v1:0``.
Authentication is either a Bedrock API key (bearer token) via
``AWS_BEARER_TOKEN_BEDROCK`` — no AWS access keys required — or the standard
AWS credential chain (env vars, ``~/.aws/credentials``, or an IAM role) with
optional ``AWS_PROFILE``. Set ``AWS_REGION`` / ``AWS_DEFAULT_REGION`` either
way (the token carries no region). The model name is a Bedrock model ID or
cross-region inference profile ID, e.g. ``us.anthropic.claude-opus-4-8-v1:0``.
"""
def get_llm(self) -> Any:
@@ -59,6 +60,12 @@ class BedrockClient(BaseLLMClient):
or _DEFAULT_REGION
)
llm_kwargs = {"model": self.model, "region_name": region}
# A Bedrock API key authenticates without AWS access keys. Passing it as
# api_key makes langchain-aws prefer bearer auth, so an ambient
# AWS_PROFILE / SigV4 credentials can't override it (#1103).
bearer_token = os.environ.get("AWS_BEARER_TOKEN_BEDROCK")
if bearer_token:
llm_kwargs["api_key"] = bearer_token
for key in ("temperature", "max_tokens", "max_retries", "callbacks"):
if key in self.kwargs:
llm_kwargs[key] = self.kwargs[key]
+4 -4
View File
@@ -94,14 +94,14 @@ MODEL_OPTIONS: ProviderModeOptions = {
},
"anthropic": {
"quick": [
("Claude Sonnet 4.6 - Best speed and intelligence balance", "claude-sonnet-4-6"),
("Claude Sonnet 5 - Best speed and intelligence balance", "claude-sonnet-5"),
("Claude Haiku 4.5 - Fastest with near-frontier intelligence", "claude-haiku-4-5"),
],
"deep": [
("Claude Opus 4.8 - Latest frontier, agentic coding and reasoning", "claude-opus-4-8"),
("Claude Fable 5 - Most capable, long-running agents", "claude-fable-5"),
("Claude Opus 4.8 - Frontier agentic coding and reasoning", "claude-opus-4-8"),
("Claude Sonnet 5 - Near-frontier intelligence at Sonnet cost", "claude-sonnet-5"),
("Claude Opus 4.7 - Previous frontier, long-running agents", "claude-opus-4-7"),
("Claude Opus 4.6 - Frontier intelligence, agents and coding", "claude-opus-4-6"),
("Claude Sonnet 4.6 - Best speed and intelligence balance", "claude-sonnet-4-6"),
],
},
"google": {
+15 -1
View File
@@ -33,7 +33,21 @@ class NormalizedChatOpenAI(ChatOpenAI):
"""
def invoke(self, input, config=None, **kwargs):
return normalize_content(super().invoke(input, config, **kwargs))
import json as _json
import time as _time
import logging as _logging
_logger = _logging.getLogger(__name__)
max_retries = 3
for attempt in range(max_retries):
try:
return normalize_content(super().invoke(input, config, **kwargs))
except (_json.JSONDecodeError, Exception) as e:
if 'JSONDecodeError' in type(e).__name__ or 'Expecting value' in str(e):
if attempt < max_retries - 1:
_logger.warning(f"LLM returned invalid JSON, retrying ({attempt+1}/{max_retries}): {e}")
_time.sleep(2 * (attempt + 1))
continue
raise
def with_structured_output(self, schema, *, method=None, **kwargs):
caps = get_capabilities(self.model_name)
+640
View File
@@ -0,0 +1,640 @@
"""TradingAgents Web 可视化页面
提供Web界面让用户输入股票代码,运行TradingAgents分析,查看结果。
"""
import json
import os
import sys
import threading
import uuid
from datetime import datetime
from flask import Flask, render_template_string, request, jsonify
# 确保项目根目录在路径中
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
app = Flask(__name__)
# 存储运行中的任务
tasks = {}
# HTML模板
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TradingAgents AI 交易分析</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #0a0a2e 0%, #1a1a4e 50%, #0d0d3d 100%);
color: #e0e0e0;
min-height: 100vh;
}
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
/* Header */
.header {
text-align: center;
padding: 40px 0 30px;
}
.header h1 {
font-size: 2.5em;
background: linear-gradient(120deg, #00d4ff, #7c3aed, #f472b6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 10px;
}
.header p {
color: #8888aa;
font-size: 1.1em;
}
/* Input Form */
.form-card {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px;
padding: 30px;
margin-bottom: 30px;
backdrop-filter: blur(10px);
}
.form-row {
display: flex;
gap: 15px;
align-items: flex-end;
flex-wrap: wrap;
}
.form-group {
flex: 1;
min-width: 200px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #aaaacc;
font-size: 0.9em;
}
.form-group input, .form-group select {
width: 100%;
padding: 12px 16px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 10px;
color: #fff;
font-size: 1em;
transition: all 0.3s;
}
.form-group input:focus, .form-group select:focus {
outline: none;
border-color: #7c3aed;
box-shadow: 0 0 20px rgba(124, 58, 237, 0.3);
}
.form-group input::placeholder { color: #666688; }
.btn {
padding: 12px 30px;
border: none;
border-radius: 10px;
font-size: 1em;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, #7c3aed, #3b82f6);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(124, 58, 237, 0.4);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* Preset Buttons */
.presets {
display: flex;
gap: 10px;
margin-top: 15px;
flex-wrap: wrap;
}
.preset-btn {
padding: 6px 14px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 20px;
color: #aaaacc;
font-size: 0.85em;
cursor: pointer;
transition: all 0.3s;
}
.preset-btn:hover {
background: rgba(124, 58, 237, 0.2);
border-color: #7c3aed;
color: #fff;
}
/* Status */
.status-card {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
display: none;
}
.status-card.active { display: block; }
.status-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(124, 58, 237, 0.3);
border-top-color: #7c3aed;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.log-box {
background: rgba(0,0,0,0.3);
border-radius: 8px;
padding: 15px;
max-height: 200px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 0.85em;
color: #8888aa;
line-height: 1.6;
}
.log-box .info { color: #3b82f6; }
.log-box .success { color: #10b981; }
.log-box .error { color: #ef4444; }
/* Results */
.results-card {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px;
padding: 30px;
margin-bottom: 20px;
display: none;
}
.results-card.active { display: block; }
.result-section {
margin-bottom: 25px;
padding-bottom: 25px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.result-section:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
.result-section h3 {
color: #7c3aed;
margin-bottom: 15px;
font-size: 1.2em;
}
.decision-box {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.15), rgba(59, 130, 246, 0.15));
border: 1px solid rgba(124, 58, 237, 0.3);
border-radius: 12px;
padding: 25px;
text-align: center;
margin-bottom: 20px;
}
.decision-label {
font-size: 0.9em;
color: #8888aa;
margin-bottom: 10px;
}
.decision-value {
font-size: 2em;
font-weight: bold;
}
.decision-buy { color: #10b981; }
.decision-sell { color: #ef4444; }
.decision-hold { color: #f59e0b; }
.agent-report {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px;
padding: 20px;
margin-bottom: 15px;
}
.agent-report h4 {
color: #3b82f6;
margin-bottom: 12px;
font-size: 1.05em;
}
.agent-report pre {
white-space: pre-wrap;
word-wrap: break-word;
font-size: 0.9em;
line-height: 1.6;
color: #ccccee;
}
/* Footer */
.footer {
text-align: center;
padding: 30px 0;
color: #555577;
font-size: 0.85em;
}
/* Responsive */
@media (max-width: 768px) {
.header h1 { font-size: 1.8em; }
.form-row { flex-direction: column; }
.form-group { min-width: 100%; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🤖 TradingAgents</h1>
<p>AI 多智能体交易分析系统 · Powered by MiMo-v2.5-pro</p>
</div>
<div class="form-card">
<form id="analysisForm">
<div class="form-row">
<div class="form-group">
<label>📊 股票代码</label>
<input type="text" id="ticker" placeholder="例: AAPL, 600519.SS, BTC-USD" required>
</div>
<div class="form-group" style="max-width: 200px;">
<label>📅 分析日期</label>
<input type="date" id="date" value="{{ default_date }}">
</div>
<div class="form-group" style="max-width: 150px;">
<label>&nbsp;</label>
<button type="submit" class="btn btn-primary" id="submitBtn">
🚀 开始分析
</button>
</div>
</div>
<div class="presets">
<span class="preset-btn" onclick="setTicker('AAPL')">🍎 AAPL</span>
<span class="preset-btn" onclick="setTicker('NVDA')">💚 NVDA</span>
<span class="preset-btn" onclick="setTicker('600519.SS')">🍷 茅台</span>
<span class="preset-btn" onclick="setTicker('000001.SZ')">🏦 平安</span>
<span class="preset-btn" onclick="setTicker('0700.HK')">🐧 腾讯</span>
<span class="preset-btn" onclick="setTicker('BTC-USD')">₿ BTC</span>
<span class="preset-btn" onclick="setTicker('SPY')">📈 SPY</span>
</div>
</form>
</div>
<div class="status-card" id="statusCard">
<div class="status-header">
<div class="spinner"></div>
<span id="statusText">正在初始化分析...</span>
</div>
<div class="log-box" id="logBox"></div>
</div>
<div class="results-card" id="resultsCard">
<div class="decision-box">
<div class="decision-label">📊 交易决策</div>
<div class="decision-value" id="decisionValue">--</div>
</div>
<div class="result-section">
<h3>📈 技术分析师</h3>
<div class="agent-report"><pre id="techReport">等待分析...</pre></div>
</div>
<div class="result-section">
<h3>💭 情绪分析师</h3>
<div class="agent-report"><pre id="sentimentReport">等待分析...</pre></div>
</div>
<div class="result-section">
<h3>📰 新闻分析师</h3>
<div class="agent-report"><pre id="newsReport">等待分析...</pre></div>
</div>
<div class="result-section">
<h3>🔬 研究员辩论</h3>
<div class="agent-report"><pre id="debateReport">等待分析...</pre></div>
</div>
<div class="result-section">
<h3>⚠️ 风险评估</h3>
<div class="agent-report"><pre id="riskReport">等待分析...</pre></div>
</div>
</div>
<div class="footer">
TradingAgents v1.0 · 数据来源: Yahoo Finance, StockTwits, Reddit, Polymarket, FRED<br>
⚠️ 仅供参考,不构成投资建议
</div>
</div>
<script>
function setTicker(t) {
document.getElementById('ticker').value = t;
}
document.getElementById('analysisForm').addEventListener('submit', async function(e) {
e.preventDefault();
const ticker = document.getElementById('ticker').value.trim();
const date = document.getElementById('date').value;
if (!ticker) return;
const btn = document.getElementById('submitBtn');
const statusCard = document.getElementById('statusCard');
const resultsCard = document.getElementById('resultsCard');
btn.disabled = true;
btn.textContent = '⏳ 分析中...';
statusCard.classList.add('active');
resultsCard.classList.remove('active');
const logBox = document.getElementById('logBox');
logBox.innerHTML = '<span class="info">⏳ 提交分析任务...</span><br>';
try {
const resp = await fetch('/api/analyze', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ticker, date})
});
const data = await resp.json();
if (data.error) {
logBox.innerHTML += `<span class="error">❌ ${data.error}</span><br>`;
btn.disabled = false;
btn.textContent = '🚀 开始分析';
return;
}
const taskId = data.task_id;
logBox.innerHTML += `<span class="info">📋 任务ID: ${taskId}</span><br>`;
// Poll for results
pollTask(taskId);
} catch(err) {
logBox.innerHTML += `<span class="error">❌ 请求失败: ${err.message}</span><br>`;
btn.disabled = false;
btn.textContent = '🚀 开始分析';
}
});
async function pollTask(taskId) {
const logBox = document.getElementById('logBox');
const btn = document.getElementById('submitBtn');
const statusCard = document.getElementById('statusCard');
const resultsCard = document.getElementById('resultsCard');
const maxPolls = 120;
for (let i = 0; i < maxPolls; i++) {
await new Promise(r => setTimeout(r, 5000));
try {
const resp = await fetch(`/api/status/${taskId}`);
const data = await resp.json();
// Update logs
if (data.logs && data.logs.length > 0) {
logBox.innerHTML = data.logs.map(l => {
const cls = l.includes('✅') ? 'success' : l.includes('❌') ? 'error' : 'info';
return `<span class="${cls}">${l}</span>`;
}).join('<br>');
logBox.scrollTop = logBox.scrollHeight;
}
if (data.status === 'completed') {
btn.disabled = false;
btn.textContent = '🚀 开始分析';
// Show results
resultsCard.classList.add('active');
if (data.result) {
// Decision
const decision = data.result.final_decision || 'HOLD';
const decEl = document.getElementById('decisionValue');
decEl.textContent = decision;
decEl.className = 'decision-value ' +
(decision.includes('BUY') ? 'decision-buy' :
decision.includes('SELL') ? 'decision-sell' : 'decision-hold');
// Reports
const reports = data.result.reports || {};
document.getElementById('techReport').textContent = reports.technical || '无数据';
document.getElementById('sentimentReport').textContent = reports.sentiment || '无数据';
document.getElementById('newsReport').textContent = reports.news || '无数据';
document.getElementById('debateReport').textContent = reports.debate || '无数据';
document.getElementById('riskReport').textContent = reports.risk || '无数据';
}
logBox.innerHTML += '<br><span class="success">✅ 分析完成!</span>';
return;
}
if (data.status === 'failed') {
btn.disabled = false;
btn.textContent = '🚀 开始分析';
logBox.innerHTML += `<br><span class="error">❌ 分析失败: ${data.error}</span>`;
return;
}
} catch(err) {
logBox.innerHTML += `<br><span class="error">❌ 状态查询失败: ${err.message}</span>`;
}
}
btn.disabled = false;
btn.textContent = '🚀 开始分析';
logBox.innerHTML += '<br><span class="error">❌ 分析超时</span>';
}
</script>
</body>
</html>
"""
@app.route('/')
def index():
default_date = datetime.now().strftime('%Y-%m-%d')
return render_template_string(HTML_TEMPLATE, default_date=default_date)
@app.route('/api/analyze', methods=['POST'])
def analyze():
data = request.json
ticker = data.get('ticker', '').strip()
date = data.get('date', datetime.now().strftime('%Y-%m-%d'))
if not ticker:
return jsonify({'error': '请输入股票代码'}), 400
task_id = str(uuid.uuid4())[:8]
tasks[task_id] = {
'status': 'running',
'ticker': ticker,
'date': date,
'logs': ['⏳ 初始化分析...'],
'result': None,
'error': None,
'started_at': datetime.now().isoformat()
}
# 在后台线程运行分析
thread = threading.Thread(target=run_analysis, args=(task_id, ticker, date))
thread.daemon = True
thread.start()
return jsonify({'task_id': task_id, 'status': 'running'})
@app.route('/api/status/<task_id>')
def task_status(task_id):
task = tasks.get(task_id)
if not task:
return jsonify({'error': '任务不存在'}), 404
return jsonify(task)
def run_analysis(task_id, ticker, date):
"""后台运行TradingAgents分析。"""
import sys
import io
try:
task = tasks[task_id]
def log(msg):
task['logs'].append(msg)
# 保持日志不超过50条
if len(task['logs']) > 50:
task['logs'] = task['logs'][-50:]
# 设置环境变量
os.environ['OPENAI_COMPATIBLE_API_KEY'] = 'tp-ce6eaqg1gh1uev46i4oexh4sahloyfo2hfpxuc2n9m7yl9dd'
os.environ['FRED_API_KEY'] = '825283926dd016ea66e96cc919e047da'
os.environ['ALPHA_VANTAGE_API_KEY'] = 'W5PTA2J5OB16W02L'
log(f'📊 开始分析 {ticker} ({date})')
log(f'🤖 模型: MiMo-v2.5-pro')
log(f'🌐 数据源: Alpha Vantage + SSH中转代理')
from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.default_config import DEFAULT_CONFIG
config = DEFAULT_CONFIG.copy()
config['llm_provider'] = 'openai_compatible'
config['backend_url'] = 'https://token-plan-cn.xiaomimimo.com/v1'
config['deep_think_llm'] = 'mimo-v2.5-pro'
config['quick_think_llm'] = 'mimo-v2.5-pro'
config['max_debate_rounds'] = 1
config['max_risk_discuss_rounds'] = 1
# 配置数据源:Yahoo Finance优先,Alpha Vantage作为备选
config['data_vendors'] = {
'core_stock_apis': 'yfinance,alpha_vantage',
'technical_indicators': 'yfinance,alpha_vantage',
'fundamental_data': 'yfinance,alpha_vantage',
'news_data': 'yfinance,alpha_vantage',
'macro_data': 'fred',
'prediction_markets': 'polymarket',
}
log('🔧 配置完成,启动分析引擎...')
log('⏳ 分析通常需要3-8分钟,请耐心等待...')
# 捕获stdout输出以获取实时进度
class LogCapture:
def __init__(self, task):
self.task = task
self.buffer = ''
def write(self, text):
self.buffer += text
if '\n' in self.buffer:
lines = self.buffer.split('\n')
self.buffer = lines[-1]
for line in lines[:-1]:
line = line.strip()
if line and len(line) > 5:
# 只保留有意义的输出
if any(k in line for k in ['Tool', 'Message', 'Agent', 'failed', 'error', '✅', '❌', '📊', '📈', '💭', '📰', '🔬', '⚠️']):
self.task['logs'].append(f'📋 {line[:100]}')
if len(self.task['logs']) > 50:
self.task['logs'] = self.task['logs'][-50:]
def flush(self):
pass
# 临时捕获输出
old_stdout = sys.stdout
sys.stdout = LogCapture(task)
try:
ta = TradingAgentsGraph(debug=True, config=config)
log('📈 技术分析师开始工作...')
state, decision = ta.propagate(ticker, date)
finally:
sys.stdout = old_stdout
log(f'✅ 分析完成: {decision}')
# 提取各Agent的报告
reports = {}
# 从state中提取各阶段报告
if 'market_report' in state:
reports['technical'] = state['market_report'][:2000]
if 'sentiment_report' in state:
reports['sentiment'] = state['sentiment_report'][:2000]
if 'news_report' in state:
reports['news'] = state['news_report'][:2000]
if 'investment_debate_state' in state:
debate = state['investment_debate_state']
if isinstance(debate, dict):
reports['debate'] = str(debate.get('judge_decision', ''))[:2000]
else:
reports['debate'] = str(debate)[:2000]
if 'risk_debate_state' in state:
risk = state['risk_debate_state']
if isinstance(risk, dict):
reports['risk'] = str(risk.get('judge_decision', ''))[:2000]
else:
reports['risk'] = str(risk)[:2000]
task['status'] = 'completed'
task['result'] = {
'final_decision': decision,
'reports': reports
}
task['logs'].append(f'✅ 最终决策: {decision}')
except Exception as e:
task = tasks.get(task_id)
if task:
task['status'] = 'failed'
task['error'] = str(e)
task['logs'].append(f'❌ 错误: {str(e)}')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8890, debug=False)