Compare commits
19
Commits
cbd17ac3e0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e0f2c5e74 | ||
|
|
47c523852f | ||
|
|
c05413d4fa | ||
|
|
9a6c476ff1 | ||
|
|
af1391d6ae | ||
|
|
709ea0fd78 | ||
|
|
5207a2bf18 | ||
|
|
6bba181307 | ||
|
|
f8a47cb473 | ||
|
|
51d057e2c9 | ||
|
|
01477f9afb | ||
|
|
0f70af2f31 | ||
|
|
43bd32befa | ||
|
|
a102afa090 | ||
|
|
daf1da9c35 | ||
|
|
b47a828a4f | ||
|
|
622f99d28a | ||
|
|
3570f2e1e6 | ||
|
|
85946c2f60 |
No files matched your search
+8
-2
@@ -25,8 +25,10 @@ NVIDIA_API_KEY=
|
|||||||
#OPENAI_COMPATIBLE_API_KEY=
|
#OPENAI_COMPATIBLE_API_KEY=
|
||||||
|
|
||||||
# AWS Bedrock (provider "bedrock", install with: pip install ".[bedrock]").
|
# AWS Bedrock (provider "bedrock", install with: pip install ".[bedrock]").
|
||||||
# Auth uses the standard AWS credential chain; set the region (and optionally a
|
# Auth: either a Bedrock API key (bearer token, no AWS access keys) OR the AWS
|
||||||
# named profile). No single API key.
|
# 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_DEFAULT_REGION=us-west-2
|
||||||
#AWS_PROFILE=
|
#AWS_PROFILE=
|
||||||
|
|
||||||
@@ -55,6 +57,10 @@ NVIDIA_API_KEY=
|
|||||||
# honor it). Unset leaves each provider at its default. See the README
|
# honor it). Unset leaves each provider at its default. See the README
|
||||||
# "Reproducibility" note — no setting makes LLM output fully deterministic.
|
# "Reproducibility" note — no setting makes LLM output fully deterministic.
|
||||||
#TRADINGAGENTS_TEMPERATURE=0.0
|
#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
|
# Provider-specific reasoning/thinking depth (optional; unset = provider
|
||||||
# default). Setting one also skips the matching interactive prompt.
|
# default). Setting one also skips the matching interactive prompt.
|
||||||
#TRADINGAGENTS_OPENAI_REASONING_EFFORT=medium
|
#TRADINGAGENTS_OPENAI_REASONING_EFFORT=medium
|
||||||
|
|||||||
+101
@@ -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).
|
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.
|
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
|
## [0.2.5] — 2026-05-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -28,7 +28,9 @@
|
|||||||
# TradingAgents: Multi-Agents LLM Financial Trading Framework
|
# TradingAgents: Multi-Agents LLM Financial Trading Framework
|
||||||
|
|
||||||
## News
|
## 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-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.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.
|
- [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.
|
||||||
|
|||||||
@@ -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: 小钰*
|
||||||
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tradingagents"
|
name = "tradingagents"
|
||||||
version = "0.2.5"
|
version = "0.3.1"
|
||||||
description = "TradingAgents: Multi-Agents LLM Financial Trading Framework"
|
description = "TradingAgents: Multi-Agents LLM Financial Trading Framework"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
"""Alpha Vantage request hardening.
|
"""Alpha Vantage request hardening.
|
||||||
|
|
||||||
Regressions for #990 (no request timeout -> can hang) and #991 (invalid-key
|
Regressions for #990 (no request timeout -> can hang), #991 (invalid-key
|
||||||
responses mislabeled as rate limits and silently treated as transient).
|
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 pytest
|
||||||
|
|
||||||
import tradingagents.dataflows.alpha_vantage_common as av
|
import tradingagents.dataflows.alpha_vantage_common as av
|
||||||
|
import tradingagents.dataflows.alpha_vantage_fundamentals as avf
|
||||||
|
|
||||||
|
|
||||||
class _FakeResponse:
|
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
|
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."}'))
|
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"})
|
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"
|
||||||
@@ -57,6 +57,16 @@ class TestEffortGate:
|
|||||||
mod.AnthropicClient(model=model, effort="low", api_key="x").get_llm()
|
mod.AnthropicClient(model=model, effort="low", api_key="x").get_llm()
|
||||||
assert captured["kwargs"]["effort"] == "low"
|
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):
|
def test_mythos_preview_receives_effort(self, monkeypatch):
|
||||||
captured = _capture_kwargs(monkeypatch)
|
captured = _capture_kwargs(monkeypatch)
|
||||||
mod.AnthropicClient(
|
mod.AnthropicClient(
|
||||||
|
|||||||
@@ -35,12 +35,46 @@ def test_helpful_error_when_langchain_aws_absent(monkeypatch):
|
|||||||
create_llm_client("bedrock", "m").get_llm()
|
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
|
@pytest.mark.unit
|
||||||
def test_construction_when_extra_installed(monkeypatch):
|
def test_construction_when_extra_installed(monkeypatch):
|
||||||
pytest.importorskip("langchain_aws")
|
pytest.importorskip("langchain_aws")
|
||||||
import tradingagents.llm_clients.bedrock_client as bc
|
import tradingagents.llm_clients.bedrock_client as bc
|
||||||
monkeypatch.setattr(bc, "_BEDROCK_CLASS", None)
|
monkeypatch.setattr(bc, "_BEDROCK_CLASS", None)
|
||||||
monkeypatch.setenv("AWS_DEFAULT_REGION", "eu-west-1")
|
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 type(llm).__name__ == "NormalizedChatBedrockConverse"
|
||||||
assert llm.region_name == "eu-west-1"
|
assert llm.region_name == "eu-west-1"
|
||||||
@@ -140,5 +140,79 @@ class TestCheckpointResume(unittest.TestCase):
|
|||||||
self.assertTrue(has_checkpoint(self.tmpdir, self.ticker, self.date))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -190,3 +190,25 @@ class TestFormatterHandlesRssPosts:
|
|||||||
assert "1234↑" in out
|
assert "1234↑" in out
|
||||||
assert "56c" in out
|
assert "56c" in out
|
||||||
assert "via RSS" not 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"
|
||||||
@@ -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
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
"""StockTwits fetch degrades (never raises) on transport errors, including the
|
"""StockTwits fetch: transport-error resilience (#1024) and crypto symbol
|
||||||
http.client chunked-transfer exceptions that are not OSErrors (#1024)."""
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -26,7 +30,7 @@ def _raise(exc):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class StockTwitsResilienceTests:
|
class TestStockTwitsResilience:
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"exc",
|
"exc",
|
||||||
[
|
[
|
||||||
@@ -40,3 +44,34 @@ class StockTwitsResilienceTests:
|
|||||||
out = stocktwits.fetch_stocktwits_messages("NVDA")
|
out = stocktwits.fetch_stocktwits_messages("NVDA")
|
||||||
assert "unavailable" in out.lower()
|
assert "unavailable" in out.lower()
|
||||||
assert out.startswith("<stocktwits unavailable")
|
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"]
|
||||||
@@ -6,6 +6,7 @@ import pytest
|
|||||||
|
|
||||||
from tradingagents.dataflows.symbol_utils import (
|
from tradingagents.dataflows.symbol_utils import (
|
||||||
NoMarketDataError,
|
NoMarketDataError,
|
||||||
|
crypto_base,
|
||||||
is_yahoo_safe,
|
is_yahoo_safe,
|
||||||
normalize_symbol,
|
normalize_symbol,
|
||||||
)
|
)
|
||||||
@@ -77,5 +78,25 @@ class TestIsYahooSafe(unittest.TestCase):
|
|||||||
self.assertFalse(is_yahoo_safe(sym))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -16,9 +16,12 @@ class TestTemperatureForwarding:
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"provider,model",
|
"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"),
|
("openai", "gpt-4.1"),
|
||||||
("anthropic", "claude-sonnet-4-6"),
|
("anthropic", "claude-sonnet-5"),
|
||||||
("google", "gemini-2.5-flash"),
|
("google", "gemini-3.5-flash"),
|
||||||
("deepseek", "deepseek-chat"),
|
("deepseek", "deepseek-chat"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def create_news_analyst(llm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
system_message = (
|
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."""
|
+ """ 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()
|
+ get_language_instruction()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
from .alpha_vantage_common import _make_api_request
|
from .alpha_vantage_common import _make_api_request
|
||||||
|
|
||||||
|
|
||||||
def _filter_reports_by_date(result, curr_date: str):
|
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
|
``_make_api_request`` returns the fundamentals payload as a JSON string, so
|
||||||
the simulation's current date.
|
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
|
return result
|
||||||
for key in ("annualReports", "quarterlyReports"):
|
for key in ("annualReports", "quarterlyReports"):
|
||||||
if key in result:
|
if isinstance(payload.get(key), list):
|
||||||
result[key] = [
|
payload[key] = [
|
||||||
r for r in result[key]
|
r for r in payload[key]
|
||||||
if r.get("fiscalDateEnding", "") <= curr_date
|
if r.get("fiscalDateEnding", "") <= curr_date
|
||||||
]
|
]
|
||||||
return result
|
return json.dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
def get_fundamentals(ticker: str, curr_date: str = None) -> str:
|
def get_fundamentals(ticker: str, curr_date: str = None) -> str:
|
||||||
|
|||||||
@@ -35,6 +35,6 @@ def get_stock(
|
|||||||
"datatype": "csv",
|
"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)
|
return _filter_csv_by_date_range(response, start_date, end_date)
|
||||||
@@ -15,23 +15,28 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from .ssh_proxy_pool import ssh_proxy_fetch_json
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
GAMMA_BASE = "https://gamma-api.polymarket.com"
|
GAMMA_BASE = "https://gamma-api.polymarket.com"
|
||||||
|
|
||||||
# Network timeout (seconds), consistent with the other vendors.
|
# 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 number of markets to return, ranked by traded volume.
|
||||||
DEFAULT_LIMIT = 6
|
DEFAULT_LIMIT = 6
|
||||||
|
|
||||||
|
|
||||||
def _request(path: str, params: dict) -> dict:
|
def _request(path: str, params: dict) -> dict:
|
||||||
response = requests.get(
|
url = f"{GAMMA_BASE}/{path}"
|
||||||
f"{GAMMA_BASE}/{path}", params=params, timeout=REQUEST_TIMEOUT
|
# Skip local request, go directly through SSH proxy
|
||||||
)
|
query = "&".join(f"{k}={v}" for k, v in params.items())
|
||||||
response.raise_for_status()
|
full_url = f"{url}?{query}"
|
||||||
return response.json()
|
return ssh_proxy_fetch_json(full_url, timeout=REQUEST_TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_list(value) -> list:
|
def _parse_json_list(value) -> list:
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ from urllib.error import HTTPError
|
|||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from .symbol_utils import crypto_base
|
||||||
|
from .stockstats_utils import _fetch_via_ssh
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_API = "https://www.reddit.com/r/{sub}/search.json?{qs}"
|
_API = "https://www.reddit.com/r/{sub}/search.json?{qs}"
|
||||||
@@ -121,7 +124,12 @@ def _fetch_subreddit_rss(
|
|||||||
except (OSError, http.client.HTTPException, ET.ParseError) as exc:
|
except (OSError, http.client.HTTPException, ET.ParseError) as exc:
|
||||||
# OSError covers URLError/TimeoutError/connection resets; HTTPException
|
# OSError covers URLError/TimeoutError/connection resets; HTTPException
|
||||||
# covers chunked-transfer errors (IncompleteRead/BadStatusLine, #1024).
|
# covers chunked-transfer errors (IncompleteRead/BadStatusLine, #1024).
|
||||||
logger.warning("Reddit RSS fetch failed for r/%s · %s: %s", sub, ticker, exc)
|
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 []
|
return []
|
||||||
|
|
||||||
posts = []
|
posts = []
|
||||||
@@ -200,6 +208,9 @@ def fetch_reddit_posts(
|
|||||||
stay under Reddit's public per-IP rate limit; combined with the RSS-first
|
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.
|
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 = []
|
blocks = []
|
||||||
total_posts = 0
|
total_posts = 0
|
||||||
for i, sub in enumerate(subreddits):
|
for i, sub in enumerate(subreddits):
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -19,6 +21,72 @@ logger = logging.getLogger(__name__)
|
|||||||
# enough to catch the year-old frames yfinance occasionally returns (#1021).
|
# enough to catch the year-old frames yfinance occasionally returns (#1021).
|
||||||
MAX_OHLCV_STALE_DAYS = 10
|
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):
|
def yf_retry(func, max_retries=3, base_delay=2.0):
|
||||||
"""Execute a yfinance call with exponential backoff on rate limits.
|
"""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
|
retry them internally. This wrapper adds retry logic specifically
|
||||||
for rate limits. Other exceptions propagate immediately.
|
for rate limits. Other exceptions propagate immediately.
|
||||||
"""
|
"""
|
||||||
for attempt in range(max_retries + 1):
|
# Skip local Yahoo Finance entirely - always use SSH proxy
|
||||||
try:
|
raise YFRateLimitError("Local Yahoo Finance disabled, use SSH proxy")
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_date_column(data: pd.DataFrame) -> pd.DataFrame:
|
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:
|
def load_ohlcv(symbol: str, curr_date: str) -> pd.DataFrame:
|
||||||
"""Fetch OHLCV data with caching, filtered to prevent look-ahead bias.
|
"""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()
|
config = get_config()
|
||||||
curr_date_dt = pd.to_datetime(curr_date)
|
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.
|
# Cache uses a fixed window (5y to today) so one file per symbol.
|
||||||
today_date = pd.Timestamp.today()
|
today_date = pd.Timestamp.today()
|
||||||
start_date = today_date - pd.DateOffset(years=5)
|
start_date = today_date - pd.DateOffset(years=5)
|
||||||
start_str = start_date.strftime("%Y-%m-%d")
|
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")
|
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)
|
os.makedirs(config["data_cache_dir"], exist_ok=True)
|
||||||
data_file = os.path.join(
|
data_file = os.path.join(
|
||||||
config["data_cache_dir"],
|
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,
|
# A cached file may be empty if a prior fetch failed. Treat empty cache as miss.
|
||||||
# transient rate limit). Treat an empty/columnless cache as a miss and
|
|
||||||
# re-fetch rather than serving the poisoned file forever.
|
|
||||||
data = None
|
data = None
|
||||||
if os.path.exists(data_file):
|
if os.path.exists(data_file):
|
||||||
cached = pd.read_csv(data_file, on_bad_lines="skip", encoding="utf-8")
|
cached = pd.read_csv(data_file, on_bad_lines="skip", encoding="utf-8")
|
||||||
if not cached.empty and "Close" in cached.columns:
|
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
|
data = cached
|
||||||
|
|
||||||
if data is None:
|
if data is None:
|
||||||
downloaded = yf_retry(lambda: yf.download(
|
if use_alpha_vantage:
|
||||||
canonical,
|
downloaded = _load_ohlcv_alpha_vantage(symbol, curr_date)
|
||||||
start=start_str,
|
else:
|
||||||
end=end_str,
|
# Skip local Yahoo Finance, go directly through SSH proxy
|
||||||
multi_level_index=False,
|
logger.info(f"Yahoo Finance: fetching {canonical} via SSH proxy...")
|
||||||
progress=False,
|
try:
|
||||||
auto_adjust=True,
|
downloaded = _yf_download_via_proxy(canonical, start_str, end_str)
|
||||||
))
|
except Exception as proxy_err:
|
||||||
downloaded = _ensure_date_column(downloaded.reset_index())
|
raise NoMarketDataError(
|
||||||
# Only cache real data — never persist an empty frame.
|
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:
|
if downloaded.empty or "Close" not in downloaded.columns:
|
||||||
raise NoMarketDataError(
|
raise NoMarketDataError(
|
||||||
symbol, canonical, "Yahoo Finance returned no rows"
|
symbol, canonical, "Yahoo Finance returned no rows"
|
||||||
)
|
)
|
||||||
|
downloaded = _clean_dataframe(downloaded)
|
||||||
|
|
||||||
downloaded.to_csv(data_file, index=False, encoding="utf-8")
|
downloaded.to_csv(data_file, index=False, encoding="utf-8")
|
||||||
data = downloaded
|
data = downloaded
|
||||||
|
|
||||||
|
if not use_alpha_vantage:
|
||||||
data = _clean_dataframe(data)
|
data = _clean_dataframe(data)
|
||||||
|
|
||||||
# Filter to curr_date to prevent look-ahead bias in backtesting
|
# Filter to curr_date to prevent look-ahead bias in backtesting
|
||||||
data = data[data["Date"] <= curr_date_dt]
|
data = data[data["Date"] <= curr_date_dt]
|
||||||
|
|
||||||
# Reject a stale frame (latest row far older than curr_date) rather than
|
# Reject a stale frame
|
||||||
# feeding year-old prices into indicators (#1021).
|
|
||||||
_assert_ohlcv_not_stale(data, curr_date, symbol, canonical)
|
_assert_ohlcv_not_stale(data, curr_date, symbol, canonical)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -19,12 +19,26 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from .symbol_utils import crypto_base
|
||||||
|
from .stockstats_utils import _fetch_via_ssh
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_API = "https://api.stocktwits.com/api/2/streams/symbol/{ticker}.json"
|
_API = "https://api.stocktwits.com/api/2/streams/symbol/{ticker}.json"
|
||||||
_UA = "tradingagents/0.2 (+https://github.com/TauricResearch/TradingAgents)"
|
_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:
|
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
|
"""Fetch recent StockTwits messages for ``ticker`` and return them as a
|
||||||
formatted plaintext block ready for prompt injection.
|
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
|
symbol has no messages, or the response shape is unexpected — the
|
||||||
caller never has to special-case None or exceptions.
|
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"})
|
req = Request(url, headers={"User-Agent": _UA, "Accept": "application/json"})
|
||||||
try:
|
try:
|
||||||
with urlopen(req, timeout=timeout) as resp:
|
with urlopen(req, timeout=timeout) as resp:
|
||||||
@@ -41,7 +55,12 @@ def fetch_stocktwits_messages(ticker: str, limit: int = 30, timeout: float = 10.
|
|||||||
except (OSError, http.client.HTTPException, json.JSONDecodeError) as exc:
|
except (OSError, http.client.HTTPException, json.JSONDecodeError) as exc:
|
||||||
# OSError covers URLError/TimeoutError/connection resets; HTTPException
|
# OSError covers URLError/TimeoutError/connection resets; HTTPException
|
||||||
# covers chunked-transfer errors (IncompleteRead/BadStatusLine, #1024).
|
# covers chunked-transfer errors (IncompleteRead/BadStatusLine, #1024).
|
||||||
logger.warning("StockTwits fetch failed for %s: %s", ticker, exc)
|
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__}>"
|
return f"<stocktwits unavailable: {type(exc).__name__}>"
|
||||||
|
|
||||||
messages = data.get("messages", []) if isinstance(data, dict) else []
|
messages = data.get("messages", []) if isinstance(data, dict) else []
|
||||||
|
|||||||
@@ -80,22 +80,27 @@ _YAHOO_SAFE = re.compile(r"^[A-Za-z0-9._\-\^=]+$")
|
|||||||
_CRYPTO_QUOTES = ("USDT", "USDC", "USD")
|
_CRYPTO_QUOTES = ("USDT", "USDC", "USD")
|
||||||
|
|
||||||
|
|
||||||
def _normalize_crypto(s: str) -> str | None:
|
def crypto_base(raw: str) -> str | None:
|
||||||
"""Return ``<BASE>-USD`` if ``s`` is a known crypto quoted in USD/USDT/USDC.
|
"""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``,
|
||||||
Accepts dashed or undashed forms: ``BTCUSD``, ``BTCUSDT``, ``BTC-USDT``,
|
``BTC-USDT`` — or None for non-crypto symbols. Purely syntactic.
|
||||||
``BTC-USDC`` all resolve to ``BTC-USD``. Returns None otherwise.
|
|
||||||
"""
|
"""
|
||||||
compact = s.replace("-", "")
|
if not isinstance(raw, str):
|
||||||
|
return None
|
||||||
|
compact = raw.strip().upper().rstrip("+").replace("-", "")
|
||||||
for quote in _CRYPTO_QUOTES:
|
for quote in _CRYPTO_QUOTES:
|
||||||
if compact.endswith(quote):
|
if compact.endswith(quote):
|
||||||
base = compact[: -len(quote)]
|
base = compact[: -len(quote)]
|
||||||
if base in _CRYPTO_BASES:
|
return base if base in _CRYPTO_BASES else None
|
||||||
return f"{base}-USD"
|
|
||||||
break
|
|
||||||
return 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:
|
def normalize_symbol(raw: str) -> str:
|
||||||
"""Map a user/broker symbol to its canonical Yahoo Finance symbol.
|
"""Map a user/broker symbol to its canonical Yahoo Finance symbol.
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import yfinance as yf
|
import yfinance as yf
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
from .stockstats_utils import (
|
from .stockstats_utils import (
|
||||||
StockstatsUtils,
|
StockstatsUtils,
|
||||||
_assert_ohlcv_not_stale,
|
_assert_ohlcv_not_stale,
|
||||||
|
_yf_download_via_proxy,
|
||||||
filter_financials_by_date,
|
filter_financials_by_date,
|
||||||
load_ohlcv,
|
load_ohlcv,
|
||||||
yf_retry,
|
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
|
# 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).
|
# past end_date so the requested range is actually inclusive (#986/#987).
|
||||||
end_inclusive = (end_dt + relativedelta(days=1)).strftime("%Y-%m-%d")
|
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
|
# Empty result means the symbol is unknown/delisted. Raise a typed error
|
||||||
# instead of returning prose: the routing layer turns it into a single
|
# instead of returning prose: the routing layer turns it into a single
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -18,6 +18,7 @@ _ENV_OVERRIDES = {
|
|||||||
"TRADINGAGENTS_CHECKPOINT_ENABLED": "checkpoint_enabled",
|
"TRADINGAGENTS_CHECKPOINT_ENABLED": "checkpoint_enabled",
|
||||||
"TRADINGAGENTS_BENCHMARK_TICKER": "benchmark_ticker",
|
"TRADINGAGENTS_BENCHMARK_TICKER": "benchmark_ticker",
|
||||||
"TRADINGAGENTS_TEMPERATURE": "temperature",
|
"TRADINGAGENTS_TEMPERATURE": "temperature",
|
||||||
|
"TRADINGAGENTS_LLM_MAX_RETRIES": "llm_max_retries",
|
||||||
# Provider-specific reasoning/thinking knobs (None = each provider's own
|
# Provider-specific reasoning/thinking knobs (None = each provider's own
|
||||||
# default). Settable here for non-interactive runs; the CLI also offers an
|
# default). Settable here for non-interactive runs; the CLI also offers an
|
||||||
# interactive choice, which is skipped when the matching var is set.
|
# 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
|
# variation on models that honor it; reasoning models largely ignore it
|
||||||
# and no setting makes LLM output bit-identical across runs (see README).
|
# and no setting makes LLM output bit-identical across runs (see README).
|
||||||
"temperature": None,
|
"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
|
# Checkpoint/resume: when True, LangGraph saves state after each node
|
||||||
# so a crashed run can resume from the last successful step.
|
# so a crashed run can resume from the last successful step.
|
||||||
"checkpoint_enabled": False,
|
"checkpoint_enabled": False,
|
||||||
|
|||||||
@@ -25,9 +25,17 @@ def _db_path(data_dir: str | Path, ticker: str) -> Path:
|
|||||||
return p / f"{safe}.db"
|
return p / f"{safe}.db"
|
||||||
|
|
||||||
|
|
||||||
def thread_id(ticker: str, date: str) -> str:
|
def thread_id(ticker: str, date: str, signature: str = "") -> str:
|
||||||
"""Deterministic thread ID for a ticker+date pair."""
|
"""Deterministic thread ID for a ticker+date pair.
|
||||||
return hashlib.sha256(f"{ticker.upper()}:{date}".encode()).hexdigest()[:16]
|
|
||||||
|
``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
|
@contextmanager
|
||||||
@@ -43,17 +51,17 @@ def get_checkpointer(data_dir: str | Path, ticker: str) -> Generator[SqliteSaver
|
|||||||
conn.close()
|
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."""
|
"""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."""
|
"""Return the step number of the latest checkpoint, or None if none exists."""
|
||||||
db = _db_path(data_dir, ticker)
|
db = _db_path(data_dir, ticker)
|
||||||
if not db.exists():
|
if not db.exists():
|
||||||
return None
|
return None
|
||||||
tid = thread_id(ticker, date)
|
tid = thread_id(ticker, date, signature)
|
||||||
with get_checkpointer(data_dir, ticker) as saver:
|
with get_checkpointer(data_dir, ticker) as saver:
|
||||||
config = {"configurable": {"thread_id": tid}}
|
config = {"configurable": {"thread_id": tid}}
|
||||||
cp = saver.get_tuple(config)
|
cp = saver.get_tuple(config)
|
||||||
@@ -73,12 +81,12 @@ def clear_all_checkpoints(data_dir: str | Path) -> int:
|
|||||||
return len(dbs)
|
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."""
|
"""Remove checkpoint for a specific ticker+date by deleting the thread's rows."""
|
||||||
db = _db_path(data_dir, ticker)
|
db = _db_path(data_dir, ticker)
|
||||||
if not db.exists():
|
if not db.exists():
|
||||||
return
|
return
|
||||||
tid = thread_id(ticker, date)
|
tid = thread_id(ticker, date, signature)
|
||||||
conn = sqlite3.connect(str(db))
|
conn = sqlite3.connect(str(db))
|
||||||
try:
|
try:
|
||||||
for table in ("writes", "checkpoints"):
|
for table in ("writes", "checkpoints"):
|
||||||
|
|||||||
@@ -25,6 +25,22 @@ from tradingagents.agents.utils.agent_states import AgentState
|
|||||||
from .analyst_execution import build_analyst_execution_plan
|
from .analyst_execution import build_analyst_execution_plan
|
||||||
from .conditional_logic import ConditionalLogic
|
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:
|
class GraphSetup:
|
||||||
"""Handles the setup and configuration of the agent graph."""
|
"""Handles the setup and configuration of the agent graph."""
|
||||||
@@ -118,48 +134,21 @@ class GraphSetup:
|
|||||||
else:
|
else:
|
||||||
workflow.add_edge(current_clear, "Bull Researcher")
|
workflow.add_edge(current_clear, "Bull Researcher")
|
||||||
|
|
||||||
# Add remaining edges
|
# Both research-debate edges share the complete DEBATE_PATH_MAP (#1088).
|
||||||
|
for debate_node in ("Bull Researcher", "Bear Researcher"):
|
||||||
workflow.add_conditional_edges(
|
workflow.add_conditional_edges(
|
||||||
"Bull Researcher",
|
debate_node,
|
||||||
self.conditional_logic.should_continue_debate,
|
self.conditional_logic.should_continue_debate,
|
||||||
{
|
DEBATE_PATH_MAP,
|
||||||
"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",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
workflow.add_edge("Research Manager", "Trader")
|
workflow.add_edge("Research Manager", "Trader")
|
||||||
workflow.add_edge("Trader", "Aggressive Analyst")
|
workflow.add_edge("Trader", "Aggressive Analyst")
|
||||||
|
# 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(
|
workflow.add_conditional_edges(
|
||||||
"Aggressive Analyst",
|
risk_node,
|
||||||
self.conditional_logic.should_continue_risk_analysis,
|
self.conditional_logic.should_continue_risk_analysis,
|
||||||
{
|
RISK_ANALYSIS_PATH_MAP,
|
||||||
"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",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
workflow.add_edge("Portfolio Manager", END)
|
workflow.add_edge("Portfolio Manager", END)
|
||||||
|
|||||||
@@ -44,6 +44,24 @@ from .signal_processing import SignalProcessor
|
|||||||
logger = logging.getLogger(__name__)
|
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:
|
class TradingAgentsGraph:
|
||||||
"""Main class that orchestrates the trading agents framework."""
|
"""Main class that orchestrates the trading agents framework."""
|
||||||
|
|
||||||
@@ -124,6 +142,9 @@ class TradingAgentsGraph:
|
|||||||
self.ticker = None
|
self.ticker = None
|
||||||
self.log_states_dict = {} # date to full state dict
|
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.
|
# Set up the graph: keep the workflow for recompilation with a checkpointer.
|
||||||
self.workflow = self.graph_setup.setup_graph(selected_analysts)
|
self.workflow = self.graph_setup.setup_graph(selected_analysts)
|
||||||
self.graph = self.workflow.compile()
|
self.graph = self.workflow.compile()
|
||||||
@@ -156,6 +177,12 @@ class TradingAgentsGraph:
|
|||||||
if temperature is not None and temperature != "":
|
if temperature is not None and temperature != "":
|
||||||
kwargs["temperature"] = float(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
|
return kwargs
|
||||||
|
|
||||||
def _create_tool_nodes(self) -> dict[str, ToolNode]:
|
def _create_tool_nodes(self) -> dict[str, ToolNode]:
|
||||||
@@ -318,6 +345,20 @@ class TradingAgentsGraph:
|
|||||||
identity = resolve_instrument_identity(ticker)
|
identity = resolve_instrument_identity(ticker)
|
||||||
return build_instrument_context(ticker, asset_type, identity)
|
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"):
|
def propagate(self, company_name, trade_date, asset_type: str = "stock"):
|
||||||
"""Run the trading agents graph for a company on a specific date.
|
"""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)
|
self.graph = self.workflow.compile(checkpointer=saver)
|
||||||
|
|
||||||
step = checkpoint_step(
|
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:
|
if step is not None:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -389,9 +431,10 @@ class TradingAgentsGraph:
|
|||||||
)
|
)
|
||||||
args = self.propagator.get_graph_args()
|
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"):
|
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
|
args.setdefault("config", {}).setdefault("configurable", {})["thread_id"] = tid
|
||||||
|
|
||||||
if self.debug:
|
if self.debug:
|
||||||
@@ -432,7 +475,8 @@ class TradingAgentsGraph:
|
|||||||
# Clear checkpoint on successful completion to avoid stale state.
|
# Clear checkpoint on successful completion to avoid stale state.
|
||||||
if self.config.get("checkpoint_enabled"):
|
if self.config.get("checkpoint_enabled"):
|
||||||
clear_checkpoint(
|
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"])
|
return final_state, self.process_signal(final_state["final_trade_decision"])
|
||||||
|
|||||||
@@ -11,16 +11,17 @@ _PASSTHROUGH_KWARGS = (
|
|||||||
"callbacks", "http_client", "http_async_client", "effort",
|
"callbacks", "http_client", "http_async_client", "effort",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Anthropic's extended-thinking ``effort`` parameter is accepted by Opus 4.5+
|
# 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
|
# Sonnet 4.6+, and the Claude 5 family (Sonnet 5, Fable 5). Sonnet 4.5 and any
|
||||||
# ``"This model does not support the effort parameter"`` (#831). The per-family
|
# Haiku version 400 with ``"This model does not support the effort parameter"``
|
||||||
# minimum version below is forward-compatible: future ``claude-{opus,sonnet}-X-Y``
|
# (#831). Versions may be dotted (``opus-4-8``) or single-number (``sonnet-5``,
|
||||||
# releases inherit support automatically, while Sonnet 4.5 and Haiku stay excluded.
|
# ``fable-5``); the per-family minimum below is forward-compatible.
|
||||||
_EFFORT_EXACT = {
|
_EFFORT_EXACT = {
|
||||||
"claude-mythos-preview", # non-standard preview name; effort-capable
|
"claude-mythos-preview", # non-standard preview name; effort-capable
|
||||||
|
"claude-mythos-5", # Fable 5 twin (Project Glasswing); effort-capable
|
||||||
}
|
}
|
||||||
_EFFORT_MODEL = re.compile(r"^claude-(opus|sonnet)-(\d+)-(\d+)$")
|
_EFFORT_MODEL = re.compile(r"^claude-(opus|sonnet|fable)-(\d+)(?:-(\d+))?$")
|
||||||
_EFFORT_MIN_VERSION = {"opus": (4, 5), "sonnet": (4, 6)}
|
_EFFORT_MIN_VERSION = {"opus": (4, 5), "sonnet": (4, 6), "fable": (5, 0)}
|
||||||
|
|
||||||
|
|
||||||
def _supports_effort(model: str) -> bool:
|
def _supports_effort(model: str) -> bool:
|
||||||
@@ -31,7 +32,9 @@ def _supports_effort(model: str) -> bool:
|
|||||||
match = _EFFORT_MODEL.match(model_lc)
|
match = _EFFORT_MODEL.match(model_lc)
|
||||||
if not match:
|
if not match:
|
||||||
return False
|
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]
|
return (major, minor) >= _EFFORT_MIN_VERSION[family]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -41,11 +41,12 @@ def _bedrock_class():
|
|||||||
class BedrockClient(BaseLLMClient):
|
class BedrockClient(BaseLLMClient):
|
||||||
"""Client for Amazon Bedrock via the Converse API (langchain-aws).
|
"""Client for Amazon Bedrock via the Converse API (langchain-aws).
|
||||||
|
|
||||||
Authentication uses the standard AWS credential chain (env vars,
|
Authentication is either a Bedrock API key (bearer token) via
|
||||||
``~/.aws/credentials``, or an IAM role); set ``AWS_REGION`` /
|
``AWS_BEARER_TOKEN_BEDROCK`` — no AWS access keys required — or the standard
|
||||||
``AWS_DEFAULT_REGION`` and optionally ``AWS_PROFILE``. The model name is a
|
AWS credential chain (env vars, ``~/.aws/credentials``, or an IAM role) with
|
||||||
Bedrock model ID or cross-region inference profile ID, e.g.
|
optional ``AWS_PROFILE``. Set ``AWS_REGION`` / ``AWS_DEFAULT_REGION`` either
|
||||||
``us.anthropic.claude-opus-4-8-v1:0``.
|
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:
|
def get_llm(self) -> Any:
|
||||||
@@ -59,6 +60,12 @@ class BedrockClient(BaseLLMClient):
|
|||||||
or _DEFAULT_REGION
|
or _DEFAULT_REGION
|
||||||
)
|
)
|
||||||
llm_kwargs = {"model": self.model, "region_name": 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"):
|
for key in ("temperature", "max_tokens", "max_retries", "callbacks"):
|
||||||
if key in self.kwargs:
|
if key in self.kwargs:
|
||||||
llm_kwargs[key] = self.kwargs[key]
|
llm_kwargs[key] = self.kwargs[key]
|
||||||
|
|||||||
@@ -94,14 +94,14 @@ MODEL_OPTIONS: ProviderModeOptions = {
|
|||||||
},
|
},
|
||||||
"anthropic": {
|
"anthropic": {
|
||||||
"quick": [
|
"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"),
|
("Claude Haiku 4.5 - Fastest with near-frontier intelligence", "claude-haiku-4-5"),
|
||||||
],
|
],
|
||||||
"deep": [
|
"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.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": {
|
"google": {
|
||||||
|
|||||||
@@ -33,7 +33,21 @@ class NormalizedChatOpenAI(ChatOpenAI):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def invoke(self, input, config=None, **kwargs):
|
def invoke(self, input, config=None, **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))
|
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):
|
def with_structured_output(self, schema, *, method=None, **kwargs):
|
||||||
caps = get_capabilities(self.model_name)
|
caps = get_capabilities(self.model_name)
|
||||||
|
|||||||
+640
@@ -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> </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)
|
||||||
Reference in new issue
Block a user