Merge pull request #9 from aguzererler/copilot/fix-allow-env-variables-override-config

fix: allow .env variables to override DEFAULT_CONFIG values
This commit is contained in:
ahmet guzererler 2026-03-17 16:14:14 +01:00 committed by GitHub
commit dc27874930
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 400 additions and 75 deletions

View File

@ -1,6 +1,23 @@
# LLM Providers (set the one you use) # LLM Provider API Keys (set the ones you use)
OPENAI_API_KEY= OPENAI_API_KEY=
GOOGLE_API_KEY= GOOGLE_API_KEY=
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=
XAI_API_KEY= XAI_API_KEY=
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
# Data Provider API Keys
ALPHA_VANTAGE_API_KEY=
# ── Configuration overrides ──────────────────────────────────────────
# Any setting in DEFAULT_CONFIG can be overridden with a
# TRADINGAGENTS_<KEY> environment variable. Unset or empty values
# are ignored (the hardcoded default is kept).
#
# Examples:
# TRADINGAGENTS_LLM_PROVIDER=openrouter
# TRADINGAGENTS_QUICK_THINK_LLM=deepseek/deepseek-chat-v3-0324
# TRADINGAGENTS_DEEP_THINK_LLM=deepseek/deepseek-r1-0528
# TRADINGAGENTS_BACKEND_URL=https://openrouter.ai/api/v1
# TRADINGAGENTS_RESULTS_DIR=./my_results
# TRADINGAGENTS_MAX_DEBATE_ROUNDS=2
# TRADINGAGENTS_VENDOR_SCANNER_DATA=alpha_vantage

View File

@ -75,6 +75,7 @@ OpenAI, Anthropic, Google, xAI, OpenRouter, Ollama
- LLM tiers configuration - LLM tiers configuration
- Vendor routing - Vendor routing
- Debate rounds settings - Debate rounds settings
- All values overridable via `TRADINGAGENTS_<KEY>` env vars (see `.env.example`)
## Patterns to Follow ## Patterns to Follow
@ -91,27 +92,38 @@ OpenAI, Anthropic, Google, xAI, OpenRouter, Ollama
- **Tool execution**: Trading graph uses `ToolNode` in graph. Scanner agents use `run_tool_loop()` inline. If `bind_tools()` is used, there MUST be a tool execution path. - **Tool execution**: Trading graph uses `ToolNode` in graph. Scanner agents use `run_tool_loop()` inline. If `bind_tools()` is used, there MUST be a tool execution path.
- **yfinance DataFrames**: `top_companies` has ticker as INDEX, not column. Always check `.index` and `.columns`. - **yfinance DataFrames**: `top_companies` has ticker as INDEX, not column. Always check `.index` and `.columns`.
- **yfinance Sector/Industry**: `Sector.overview` has NO performance data. Use ETF proxies for performance. - **yfinance Sector/Industry**: `Sector.overview` has NO performance data. Use ETF proxies for performance.
- **Vendor fallback**: Functions inside `route_to_vendor` must RAISE on failure, not embed errors in return values. Catch `AlphaVantageError` (base class), not just `RateLimitError`. - **Vendor fallback**: Functions inside `route_to_vendor` must RAISE on failure, not embed errors in return values. Catch `(AlphaVantageError, ConnectionError, TimeoutError)`, not just `RateLimitError`.
- **LangGraph parallel writes**: Any state field written by parallel nodes MUST have a reducer (`Annotated[str, reducer_fn]`). - **LangGraph parallel writes**: Any state field written by parallel nodes MUST have a reducer (`Annotated[str, reducer_fn]`).
- **Ollama remote host**: Never hardcode `localhost:11434`. Use configured `base_url`. - **Ollama remote host**: Never hardcode `localhost:11434`. Use configured `base_url`.
- **.env loading**: Check actual env var values when debugging auth. Worktree and main repo may have different `.env` files. - **.env loading**: `load_dotenv()` runs at module level in `default_config.py` — import-order-independent. Check actual env var values when debugging auth.
- **Rate limiter locks**: Never hold a lock during `sleep()` or IO. Release, sleep, re-acquire.
- **Config fallback keys**: `llm_provider` and `backend_url` must always exist at top level — `scanner_graph.py` and `trading_graph.py` use them as fallbacks.
## Project Tracking Files ## Project Tracking Files
- `DECISIONS.md` — Architecture decision records (vendor strategy, LLM setup, tool execution) - `DECISIONS.md` — Architecture decision records (vendor strategy, LLM setup, tool execution, env overrides)
- `PROGRESS.md` — Feature progress, what works, TODOs - `PROGRESS.md` — Feature progress, what works, TODOs
- `MISTAKES.md` — Past bugs and lessons learned (9 documented mistakes) - `MISTAKES.md` — Past bugs and lessons learned (10 documented mistakes)
## Current LLM Configuration (Hybrid) ## LLM Configuration
``` Per-tier provider overrides in `tradingagents/default_config.py`:
quick_think: qwen3.5:27b via Ollama (http://192.168.50.76:11434) - Each tier (`quick_think`, `mid_think`, `deep_think`) can have its own `_llm_provider` and `_backend_url`
mid_think: qwen3.5:27b via Ollama (http://192.168.50.76:11434) - Falls back to top-level `llm_provider` and `backend_url` when per-tier values are None
deep_think: deepseek/deepseek-r1-0528 via OpenRouter - All config values overridable via `TRADINGAGENTS_<KEY>` env vars
- Keys for LLM providers: `.env` file (e.g., `OPENROUTER_API_KEY`, `ALPHA_VANTAGE_API_KEY`)
### Env Var Override Convention
```env
# Pattern: TRADINGAGENTS_<UPPERCASE_KEY>=value
TRADINGAGENTS_LLM_PROVIDER=openrouter
TRADINGAGENTS_DEEP_THINK_LLM=deepseek/deepseek-r1-0528
TRADINGAGENTS_MAX_DEBATE_ROUNDS=3
TRADINGAGENTS_VENDOR_SCANNER_DATA=alpha_vantage
``` ```
Config: `tradingagents/default_config.py` (per-tier `_llm_provider` keys) Empty or unset vars preserve the hardcoded default. `None`-default fields (like `mid_think_llm`) stay `None` when unset, preserving fallback semantics.
Keys: `.env` file (`OPENROUTER_API_KEY`, `ALPHA_VANTAGE_API_KEY`)
## Running the Scanner ## Running the Scanner

View File

@ -105,10 +105,75 @@ Download 6 months of history via `yf.download()` and compute 1-day, 1-week, 1-mo
## Decision 007: .env Loading Strategy ## Decision 007: .env Loading Strategy
**Date**: 2026-03-17 **Date**: 2026-03-17
**Status**: Implemented ✅ **Status**: Superseded by Decision 008 ⚠️
**Context**: `load_dotenv()` loads from CWD. When running from a git worktree, the worktree `.env` may have placeholder values while the main repo `.env` has real keys. **Context**: `load_dotenv()` loads from CWD. When running from a git worktree, the worktree `.env` may have placeholder values while the main repo `.env` has real keys.
**Decision**: `cli/main.py` calls `load_dotenv()` (CWD) then `load_dotenv(Path(__file__).parent.parent / ".env")` as fallback. The worktree `.env` was also updated with real API keys. **Decision**: `cli/main.py` calls `load_dotenv()` (CWD) then `load_dotenv(Path(__file__).parent.parent / ".env")` as fallback. The worktree `.env` was also updated with real API keys.
**Note for future**: If `.env` issues recur, check which `.env` file is being picked up. The worktree and main repo each have their own `.env`. **Note for future**: If `.env` issues recur, check which `.env` file is being picked up. The worktree and main repo each have their own `.env`.
**Update**: Decision 008 moves `load_dotenv()` into `default_config.py` itself, making it import-order-independent. The CLI-level `load_dotenv()` in `main.py` is now defense-in-depth only.
---
## Decision 008: Environment Variable Config Overrides
**Date**: 2026-03-17
**Status**: Implemented ✅
**Context**: `DEFAULT_CONFIG` hardcoded all values (LLM providers, models, vendor routing, debate rounds). Users had to edit `default_config.py` to change any setting. The `load_dotenv()` call in `cli/main.py` ran *after* `DEFAULT_CONFIG` was already evaluated at import time, so env vars like `TRADINGAGENTS_LLM_PROVIDER` had no effect. This also created a latent bug (Mistake #9): `llm_provider` and `backend_url` were removed from the config but `scanner_graph.py` still referenced them as fallbacks.
**Decision**:
1. **Module-level `.env` loading**: `default_config.py` calls `load_dotenv()` at the top of the module, before `DEFAULT_CONFIG` is evaluated. Loads from CWD first, then falls back to project root (`Path(__file__).resolve().parent.parent / ".env"`).
2. **`_env()` / `_env_int()` helpers**: Read `TRADINGAGENTS_<KEY>` from environment. Return the hardcoded default when the env var is unset or empty (preserving `None` semantics for per-tier fallbacks).
3. **Restored top-level keys**: `llm_provider` (default: `"openai"`) and `backend_url` (default: `"https://api.openai.com/v1"`) restored as env-overridable keys. Resolves Mistake #9.
4. **All config keys overridable**: LLM models, providers, backend URLs, debate rounds, data vendor categories — all follow the `TRADINGAGENTS_<KEY>` pattern.
5. **Explicit dependency**: Added `python-dotenv>=1.0.0` to `pyproject.toml` (was used but undeclared).
**Naming convention**: `TRADINGAGENTS_` prefix + uppercase config key. Examples:
```
TRADINGAGENTS_LLM_PROVIDER=openrouter
TRADINGAGENTS_DEEP_THINK_LLM=deepseek/deepseek-r1-0528
TRADINGAGENTS_MAX_DEBATE_ROUNDS=3
TRADINGAGENTS_VENDOR_SCANNER_DATA=alpha_vantage
```
**Files changed**:
- `tradingagents/default_config.py` — core implementation
- `main.py` — moved `load_dotenv()` before imports (defense-in-depth)
- `pyproject.toml` — added `python-dotenv>=1.0.0`
- `.env.example` — documented all overrides
- `tests/test_env_override.py` — 15 tests
**Alternative considered**: YAML/TOML config file. Rejected — env vars are simpler, work with Docker/CI, and don't require a new config file format.
---
## Decision 009: Thread-Safe Rate Limiter for Alpha Vantage
**Date**: 2026-03-17
**Status**: Implemented ✅
**Context**: The Alpha Vantage rate limiter in `alpha_vantage_common.py` initially slept *inside* the lock when re-checking the rate window. This blocked all other threads from making API requests during the sleep period, effectively serializing all AV calls.
**Decision**: Two-phase rate limiting:
1. **First check**: Acquire lock, check timestamps, release lock, sleep if needed.
2. **Re-check loop**: Acquire lock, re-check timestamps. If still over limit, release lock *before* sleeping, then retry. Only append timestamp and break when under the limit.
This ensures the lock is never held during `sleep()` calls.
**File**: `tradingagents/dataflows/alpha_vantage_common.py`
---
## Decision 010: Broader Vendor Fallback Exception Handling
**Date**: 2026-03-17
**Status**: Implemented ✅
**Context**: `route_to_vendor()` only caught `AlphaVantageError` for fallback. But network issues (`ConnectionError`, `TimeoutError`) from the `requests` library wouldn't trigger fallback — they'd crash the pipeline instead.
**Decision**: Broadened the catch in `route_to_vendor()` to `(AlphaVantageError, ConnectionError, TimeoutError)`. Similarly, `_make_api_request()` now catches `requests.exceptions.RequestException` as a general fallback and wraps `raise_for_status()` in a try/except to convert HTTP errors to `ThirdPartyError`.
**Files**: `tradingagents/dataflows/interface.py`, `tradingagents/dataflows/alpha_vantage_common.py`

View File

@ -96,6 +96,27 @@ Documenting bugs and wrong assumptions to avoid repeating them.
**What happened**: Removed `llm_provider` from `default_config.py` (since we have per-tier providers). But `scanner_graph.py` line 78 does `self.config.get(f"{tier}_llm_provider") or self.config["llm_provider"]` — would crash if per-tier provider is ever None. **What happened**: Removed `llm_provider` from `default_config.py` (since we have per-tier providers). But `scanner_graph.py` line 78 does `self.config.get(f"{tier}_llm_provider") or self.config["llm_provider"]` — would crash if per-tier provider is ever None.
**Status**: Works currently because per-tier providers are always set. But it's a latent bug. **Status**: ✅ RESOLVED in PR #9. Top-level `llm_provider` (default: `"openai"`) and `backend_url` (default: `"https://api.openai.com/v1"`) restored as env-overridable config keys. Per-tier providers safely fall back to these when `None`.
**TODO**: Add a safe fallback or remove the dead code path. **Lesson**: Always preserve fallback keys that downstream code depends on. When refactoring config, grep for all references before removing keys.
---
## Mistake 10: Rate limiter held lock during sleep
**What happened**: The Alpha Vantage rate limiter's re-check path in `_rate_limited_request()` called `_time.sleep(extra_sleep)` while holding `_rate_lock`. This blocked all other threads from making API requests during the sleep period, effectively serializing all AV calls even though the pipeline runs parallel scanner agents.
**Root cause**: Initial implementation only had one lock section. When the re-check-after-sleep pattern was added to prevent race conditions, the sleep was left inside the `with _rate_lock:` block.
**Fix**: Restructured the re-check as a `while True` loop that releases the lock before sleeping:
```python
while True:
with _rate_lock:
if len(_call_timestamps) < _RATE_LIMIT:
_call_timestamps.append(_time.time())
break
extra_sleep = 60 - (now - _call_timestamps[0]) + 0.1
_time.sleep(extra_sleep) # ← outside lock
```
**Lesson**: Never hold a lock during a sleep/IO operation. Always release the lock, perform the blocking operation, then re-acquire.

View File

@ -15,10 +15,11 @@ The 3-phase scanner pipeline runs successfully from `python -m cli.main scan --d
| Phase 3: Macro Synthesis | ✅ | OpenRouter/DeepSeek R1, pure LLM synthesis (no tools) | | Phase 3: Macro Synthesis | ✅ | OpenRouter/DeepSeek R1, pure LLM synthesis (no tools) |
| Parallel fan-out (Phase 1) | ✅ | LangGraph with `_last_value` reducers | | Parallel fan-out (Phase 1) | ✅ | LangGraph with `_last_value` reducers |
| Tool execution loop | ✅ | `run_tool_loop()` in `tool_runner.py` | | Tool execution loop | ✅ | `run_tool_loop()` in `tool_runner.py` |
| Data vendor fallback | ✅ | AV → yfinance fallback on `AlphaVantageError` | | Data vendor fallback | ✅ | AV → yfinance fallback on `AlphaVantageError`, `ConnectionError`, `TimeoutError` |
| CLI `--date` flag | ✅ | `python -m cli.main scan --date YYYY-MM-DD` | | CLI `--date` flag | ✅ | `python -m cli.main scan --date YYYY-MM-DD` |
| .env loading | ✅ | Keys loaded from project root `.env` | | .env loading | ✅ | `load_dotenv()` at module level in `default_config.py` — import-order-independent |
| Tests (23 total) | ✅ | 14 original + 9 scanner fallback tests | | Env var config overrides | ✅ | All `DEFAULT_CONFIG` keys overridable via `TRADINGAGENTS_<KEY>` env vars |
| Tests (38 total) | ✅ | 14 original + 9 scanner fallback + 15 env override tests |
### Output Quality (Sample Run 2026-03-17) ### Output Quality (Sample Run 2026-03-17)
@ -41,14 +42,40 @@ The 3-phase scanner pipeline runs successfully from `python -m cli.main scan --d
- `tradingagents/graph/scanner_setup.py` — LangGraph workflow setup - `tradingagents/graph/scanner_setup.py` — LangGraph workflow setup
- `tradingagents/dataflows/yfinance_scanner.py` — yfinance data for scanner - `tradingagents/dataflows/yfinance_scanner.py` — yfinance data for scanner
- `tradingagents/dataflows/alpha_vantage_scanner.py` — Alpha Vantage data for scanner - `tradingagents/dataflows/alpha_vantage_scanner.py` — Alpha Vantage data for scanner
- `tradingagents/pipeline/macro_bridge.py` — scan → filter → per-ticker analysis bridge
- `tests/test_scanner_fallback.py` — 9 fallback tests - `tests/test_scanner_fallback.py` — 9 fallback tests
- `tests/test_env_override.py` — 15 env override tests
**Modified files:** **Modified files:**
- `tradingagents/default_config.py`per-tier LLM provider config (hybrid setup) - `tradingagents/default_config.py`env var overrides via `_env()`/`_env_int()` helpers, `load_dotenv()` at module level, restored top-level `llm_provider` and `backend_url` keys
- `tradingagents/llm_clients/openai_client.py` — Ollama remote host support - `tradingagents/llm_clients/openai_client.py` — Ollama remote host support
- `tradingagents/dataflows/interface.py` — broadened fallback catch to `AlphaVantageError` - `tradingagents/dataflows/interface.py` — broadened fallback catch to `(AlphaVantageError, ConnectionError, TimeoutError)`
- `cli/main.py``scan` command with `--date` flag, `.env` loading fix - `tradingagents/dataflows/alpha_vantage_common.py` — thread-safe rate limiter (sleep outside lock), broader `RequestException` catch, wrapped `raise_for_status`
- `.env` — real API keys - `tradingagents/graph/scanner_graph.py` — debug mode fix (stream for debug, invoke for result)
- `tradingagents/pipeline/macro_bridge.py``get_running_loop()` over deprecated `get_event_loop()`
- `cli/main.py``scan` command with `--date` flag, `try/except` in `run_pipeline`, `.env` loading fix
- `main.py``load_dotenv()` before tradingagents imports
- `pyproject.toml``python-dotenv>=1.0.0` dependency declared
- `.env.example` — documented all `TRADINGAGENTS_*` overrides and `ALPHA_VANTAGE_API_KEY`
---
## Milestone: Env Var Config Overrides ✅ COMPLETE (PR #9)
All `DEFAULT_CONFIG` values are now overridable via `TRADINGAGENTS_<KEY>` environment variables without code changes. This resolves the latent bug from Mistake #9 (missing top-level `llm_provider`).
### What Changed
| Component | Detail |
|-----------|--------|
| `default_config.py` | `load_dotenv()` at module level + `_env()`/`_env_int()` helpers |
| Top-level fallback keys | Restored `llm_provider` and `backend_url` (defaults: `"openai"`, `"https://api.openai.com/v1"`) |
| Per-tier overrides | All `None` by default — fall back to top-level when not set via env |
| Integer config keys | `max_debate_rounds`, `max_risk_discuss_rounds`, `max_recur_limit` use `_env_int()` |
| Data vendor keys | `data_vendors.*` overridable via `TRADINGAGENTS_VENDOR_<CATEGORY>` |
| `.env.example` | Complete reference of all overridable settings |
| `python-dotenv` | Added to `pyproject.toml` as explicit dependency |
| Tests | 15 new tests in `tests/test_env_override.py` |
--- ---
@ -78,4 +105,4 @@ The 3-phase scanner pipeline runs successfully from `python -m cli.main scan --d
- [ ] **Streaming output**: Scanner currently runs with `Live(Spinner(...))` — no intermediate output. Could stream phase completions to the console. - [ ] **Streaming output**: Scanner currently runs with `Live(Spinner(...))` — no intermediate output. Could stream phase completions to the console.
- [ ] **Remove top-level `llm_provider` references**: `scanner_graph.py` lines 69, 78 still fall back to `self.config["llm_provider"]` which doesn't exist in current config. Works because per-tier providers are always set, but will crash if they're ever `None`. - [x] ~~**Remove top-level `llm_provider` references**~~: Resolved in PR #9`llm_provider` and `backend_url` restored as top-level keys with `"openai"` / `"https://api.openai.com/v1"` defaults. Per-tier providers fall back to these when `None`.

View File

@ -1,5 +1,6 @@
from typing import Optional from typing import Optional
import datetime import datetime
import json
import typer import typer
from pathlib import Path from pathlib import Path
from functools import wraps from functools import wraps
@ -1201,8 +1202,6 @@ def run_scan(date: Optional[str] = None):
raise typer.Exit(1) raise typer.Exit(1)
# Save reports # Save reports
import json as _json
for key in ["geopolitical_report", "market_movers_report", "sector_performance_report", for key in ["geopolitical_report", "market_movers_report", "sector_performance_report",
"industry_deep_dive_report", "macro_scan_summary"]: "industry_deep_dive_report", "macro_scan_summary"]:
content = result.get(key, "") content = result.get(key, "")
@ -1217,7 +1216,7 @@ def run_scan(date: Optional[str] = None):
# Try to parse and show watchlist table # Try to parse and show watchlist table
try: try:
summary_data = _json.loads(summary) summary_data = json.loads(summary)
stocks = summary_data.get("stocks_to_investigate", []) stocks = summary_data.get("stocks_to_investigate", [])
if stocks: if stocks:
table = Table(title="Stocks to Investigate", box=box.ROUNDED) table = Table(title="Stocks to Investigate", box=box.ROUNDED)
@ -1235,16 +1234,16 @@ def run_scan(date: Optional[str] = None):
s.get("thesis_angle", ""), s.get("thesis_angle", ""),
) )
console.print(table) console.print(table)
except (_json.JSONDecodeError, KeyError): except (json.JSONDecodeError, KeyError):
pass # Summary wasn't valid JSON — already printed as markdown pass # Summary wasn't valid JSON — already printed as markdown
console.print(f"\n[green]Results saved to {save_dir}[/green]") console.print(f"\n[green]Results saved to {save_dir}[/green]")
def run_pipeline(): def run_pipeline():
"""Full pipeline: scan -> filter -> per-ticker deep dive.""" """Full pipeline: scan -> filter -> per-ticker deep dive."""
import asyncio import asyncio
import json as _json
from tradingagents.pipeline.macro_bridge import ( from tradingagents.pipeline.macro_bridge import (
parse_macro_output, parse_macro_output,
filter_candidates, filter_candidates,
@ -1293,10 +1292,14 @@ def run_pipeline():
output_dir = Path("results/macro_pipeline") output_dir = Path("results/macro_pipeline")
console.print(f"\n[cyan]Running TradingAgents for {len(candidates)} tickers...[/cyan]") console.print(f"\n[cyan]Running TradingAgents for {len(candidates)} tickers...[/cyan]")
try:
with Live(Spinner("dots", text="Analyzing..."), console=console, transient=True): with Live(Spinner("dots", text="Analyzing..."), console=console, transient=True):
results = asyncio.run( results = asyncio.run(
run_all_tickers(candidates, macro_context, config, analysis_date) run_all_tickers(candidates, macro_context, config, analysis_date)
) )
except Exception as e:
console.print(f"[red]Pipeline failed: {e}[/red]")
raise typer.Exit(1)
save_results(results, macro_context, output_dir) save_results(results, macro_context, output_dir)

10
main.py
View File

@ -1,11 +1,13 @@
from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.default_config import DEFAULT_CONFIG
from dotenv import load_dotenv from dotenv import load_dotenv
# Load environment variables from .env file # Load environment variables from .env file BEFORE importing any
# tradingagents modules so TRADINGAGENTS_* vars are visible to
# DEFAULT_CONFIG at import time.
load_dotenv() load_dotenv()
from tradingagents.graph.trading_graph import TradingAgentsGraph
from tradingagents.default_config import DEFAULT_CONFIG
# Create a custom config # Create a custom config
config = DEFAULT_CONFIG.copy() config = DEFAULT_CONFIG.copy()
config["deep_think_llm"] = "gpt-5-mini" # Use a different model config["deep_think_llm"] = "gpt-5-mini" # Use a different model

View File

@ -19,6 +19,7 @@ dependencies = [
"langgraph>=0.4.8", "langgraph>=0.4.8",
"pandas>=2.3.0", "pandas>=2.3.0",
"parsel>=1.10.0", "parsel>=1.10.0",
"python-dotenv>=1.0.0",
"pytz>=2025.2", "pytz>=2025.2",
"questionary>=2.1.0", "questionary>=2.1.0",
"rank-bm25>=0.2.2", "rank-bm25>=0.2.2",

108
tests/test_env_override.py Normal file
View File

@ -0,0 +1,108 @@
"""Tests that TRADINGAGENTS_* environment variables override DEFAULT_CONFIG."""
import importlib
import os
from unittest.mock import patch
import pytest
class TestEnvOverridesDefaults:
"""Verify that setting TRADINGAGENTS_<KEY> env vars changes DEFAULT_CONFIG."""
def _reload_config(self):
"""Force-reimport default_config so the module-level dict is rebuilt."""
import tradingagents.default_config as mod
importlib.reload(mod)
return mod.DEFAULT_CONFIG
def test_llm_provider_override(self):
with patch.dict(os.environ, {"TRADINGAGENTS_LLM_PROVIDER": "openrouter"}):
cfg = self._reload_config()
assert cfg["llm_provider"] == "openrouter"
def test_backend_url_override(self):
with patch.dict(os.environ, {"TRADINGAGENTS_BACKEND_URL": "http://localhost:1234"}):
cfg = self._reload_config()
assert cfg["backend_url"] == "http://localhost:1234"
def test_deep_think_llm_override(self):
with patch.dict(os.environ, {"TRADINGAGENTS_DEEP_THINK_LLM": "deepseek/deepseek-r1"}):
cfg = self._reload_config()
assert cfg["deep_think_llm"] == "deepseek/deepseek-r1"
def test_quick_think_llm_override(self):
with patch.dict(os.environ, {"TRADINGAGENTS_QUICK_THINK_LLM": "gpt-4o-mini"}):
cfg = self._reload_config()
assert cfg["quick_think_llm"] == "gpt-4o-mini"
def test_mid_think_llm_none_by_default(self):
"""mid_think_llm defaults to None (falls back to quick_think_llm)."""
with patch.dict(os.environ, {}, clear=False):
# Remove the env var if it happens to be set
os.environ.pop("TRADINGAGENTS_MID_THINK_LLM", None)
cfg = self._reload_config()
assert cfg["mid_think_llm"] is None
def test_mid_think_llm_override(self):
with patch.dict(os.environ, {"TRADINGAGENTS_MID_THINK_LLM": "gpt-4o"}):
cfg = self._reload_config()
assert cfg["mid_think_llm"] == "gpt-4o"
def test_empty_env_var_keeps_default(self):
"""An empty string is treated the same as unset (keeps the default)."""
with patch.dict(os.environ, {"TRADINGAGENTS_LLM_PROVIDER": ""}):
cfg = self._reload_config()
assert cfg["llm_provider"] == "openai"
def test_empty_env_var_keeps_none_default(self):
"""An empty string for a None-default field stays None."""
with patch.dict(os.environ, {"TRADINGAGENTS_DEEP_THINK_LLM_PROVIDER": ""}):
cfg = self._reload_config()
assert cfg["deep_think_llm_provider"] is None
def test_per_tier_provider_override(self):
with patch.dict(os.environ, {"TRADINGAGENTS_DEEP_THINK_LLM_PROVIDER": "anthropic"}):
cfg = self._reload_config()
assert cfg["deep_think_llm_provider"] == "anthropic"
def test_per_tier_backend_url_override(self):
with patch.dict(os.environ, {"TRADINGAGENTS_MID_THINK_BACKEND_URL": "http://my-ollama:11434"}):
cfg = self._reload_config()
assert cfg["mid_think_backend_url"] == "http://my-ollama:11434"
def test_max_debate_rounds_int(self):
with patch.dict(os.environ, {"TRADINGAGENTS_MAX_DEBATE_ROUNDS": "3"}):
cfg = self._reload_config()
assert cfg["max_debate_rounds"] == 3
def test_max_debate_rounds_bad_value(self):
"""Non-numeric string falls back to hardcoded default."""
with patch.dict(os.environ, {"TRADINGAGENTS_MAX_DEBATE_ROUNDS": "abc"}):
cfg = self._reload_config()
assert cfg["max_debate_rounds"] == 1
def test_results_dir_override(self):
with patch.dict(os.environ, {"TRADINGAGENTS_RESULTS_DIR": "/tmp/my_results"}):
cfg = self._reload_config()
assert cfg["results_dir"] == "/tmp/my_results"
def test_vendor_scanner_data_override(self):
with patch.dict(os.environ, {"TRADINGAGENTS_VENDOR_SCANNER_DATA": "alpha_vantage"}):
cfg = self._reload_config()
assert cfg["data_vendors"]["scanner_data"] == "alpha_vantage"
def test_defaults_unchanged_when_no_env_set(self):
"""Without any TRADINGAGENTS_* vars, defaults are the original hardcoded values."""
# Clear all TRADINGAGENTS_ vars
env_clean = {k: v for k, v in os.environ.items() if not k.startswith("TRADINGAGENTS_")}
with patch.dict(os.environ, env_clean, clear=True):
cfg = self._reload_config()
assert cfg["llm_provider"] == "openai"
assert cfg["deep_think_llm"] == "gpt-5.2"
assert cfg["mid_think_llm"] is None
assert cfg["quick_think_llm"] == "gpt-5-mini"
assert cfg["backend_url"] == "https://api.openai.com/v1"
assert cfg["max_debate_rounds"] == 1
assert cfg["data_vendors"]["scanner_data"] == "yfinance"

View File

@ -12,7 +12,9 @@ from typing import Any, List
from langchain_core.messages import AIMessage, ToolMessage from langchain_core.messages import AIMessage, ToolMessage
MAX_TOOL_ROUNDS = 5 # safety limit to avoid infinite loops # Most LLM tool-calling patterns resolve within 2-3 rounds;
# 5 provides headroom for complex scenarios while preventing runaway loops.
MAX_TOOL_ROUNDS = 5
def run_tool_loop( def run_tool_loop(

View File

@ -2,6 +2,8 @@ import os
import requests import requests
import pandas as pd import pandas as pd
import json import json
import threading
import time as _time
from datetime import datetime from datetime import datetime
from io import StringIO from io import StringIO
@ -73,8 +75,6 @@ class ThirdPartyParseError(AlphaVantageError):
# ─── Rate-limited request helper ───────────────────────────────────────────── # ─── Rate-limited request helper ─────────────────────────────────────────────
import threading
import time as _time
_rate_lock = threading.Lock() _rate_lock = threading.Lock()
_call_timestamps: list[float] = [] _call_timestamps: list[float] = []
@ -83,14 +83,34 @@ _RATE_LIMIT = 75 # calls per minute (Alpha Vantage premium)
def _rate_limited_request(function_name: str, params: dict, timeout: int = 30) -> dict | str: def _rate_limited_request(function_name: str, params: dict, timeout: int = 30) -> dict | str:
"""Make an API request with rate limiting (75 calls/min for premium key).""" """Make an API request with rate limiting (75 calls/min for premium key)."""
sleep_time = 0.0
with _rate_lock: with _rate_lock:
now = _time.time() now = _time.time()
# Remove timestamps older than 60 seconds # Remove timestamps older than 60 seconds
_call_timestamps[:] = [t for t in _call_timestamps if now - t < 60] _call_timestamps[:] = [t for t in _call_timestamps if now - t < 60]
if len(_call_timestamps) >= _RATE_LIMIT: if len(_call_timestamps) >= _RATE_LIMIT:
sleep_time = 60 - (now - _call_timestamps[0]) + 0.1 sleep_time = 60 - (now - _call_timestamps[0]) + 0.1
# Sleep outside the lock to avoid blocking other threads
if sleep_time > 0:
_time.sleep(sleep_time) _time.sleep(sleep_time)
# Re-check and register under lock to avoid races where multiple
# threads calculate similar sleep times and then all fire at once.
while True:
with _rate_lock:
now = _time.time()
_call_timestamps[:] = [t for t in _call_timestamps if now - t < 60]
if len(_call_timestamps) >= _RATE_LIMIT:
# Another thread filled the window while we slept — wait again
extra_sleep = 60 - (now - _call_timestamps[0]) + 0.1
else:
_call_timestamps.append(_time.time()) _call_timestamps.append(_time.time())
break
# Sleep outside the lock to avoid blocking other threads
_time.sleep(extra_sleep)
return _make_api_request(function_name, params, timeout=timeout) return _make_api_request(function_name, params, timeout=timeout)
@ -131,6 +151,8 @@ def _make_api_request(function_name: str, params: dict, timeout: int = 30) -> di
) )
except requests.exceptions.ConnectionError as exc: except requests.exceptions.ConnectionError as exc:
raise ThirdPartyError(f"Connection error: function={function_name}, error={exc}") raise ThirdPartyError(f"Connection error: function={function_name}, error={exc}")
except requests.exceptions.RequestException as exc:
raise ThirdPartyError(f"Request failed: function={function_name}, error={exc}")
# HTTP-level errors # HTTP-level errors
if response.status_code == 401: if response.status_code == 401:
@ -146,7 +168,13 @@ def _make_api_request(function_name: str, params: dict, timeout: int = 30) -> di
f"Server error: status={response.status_code}, function={function_name}, " f"Server error: status={response.status_code}, function={function_name}, "
f"body={response.text[:200]}" f"body={response.text[:200]}"
) )
try:
response.raise_for_status() response.raise_for_status()
except requests.exceptions.HTTPError as exc:
raise ThirdPartyError(
f"HTTP error: status={response.status_code}, function={function_name}, "
f"body={response.text[:200]}"
) from exc
response_text = response.text response_text = response.text

View File

@ -201,7 +201,7 @@ def route_to_vendor(method: str, *args, **kwargs):
try: try:
return impl_func(*args, **kwargs) return impl_func(*args, **kwargs)
except AlphaVantageError: except (AlphaVantageError, ConnectionError, TimeoutError):
continue # Any AV error triggers fallback to next vendor continue # Any AV error or connection/timeout triggers fallback to next vendor
raise RuntimeError(f"No available vendor for '{method}'") raise RuntimeError(f"No available vendor for '{method}'")

View File

@ -1,45 +1,83 @@
import os import os
from pathlib import Path
from dotenv import load_dotenv
# Load .env so that TRADINGAGENTS_* variables are available before
# DEFAULT_CONFIG is evaluated. CWD is checked first, then the project
# root (two levels up from this file). load_dotenv never overwrites
# variables that are already present in the environment.
load_dotenv()
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
def _env(key: str, default=None):
"""Read ``TRADINGAGENTS_<KEY>`` from the environment.
Returns *default* when the variable is unset **or** empty, so that
``TRADINGAGENTS_MID_THINK_LLM=`` in a ``.env`` file is treated the
same as not setting it at all (preserving the ``None`` semantics for
"fall back to the parent setting").
"""
val = os.getenv(f"TRADINGAGENTS_{key.upper()}")
if not val: # None or ""
return default
return val
def _env_int(key: str, default=None):
"""Like :func:`_env` but coerces the value to ``int``."""
val = _env(key)
if val is None:
return default
try:
return int(val)
except (ValueError, TypeError):
return default
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"project_dir": os.path.abspath(os.path.join(os.path.dirname(__file__), ".")), "project_dir": os.path.abspath(os.path.join(os.path.dirname(__file__), ".")),
"results_dir": os.getenv("TRADINGAGENTS_RESULTS_DIR", "./results"), "results_dir": _env("RESULTS_DIR", "./results"),
"data_cache_dir": os.path.join( "data_cache_dir": os.path.join(
os.path.abspath(os.path.join(os.path.dirname(__file__), ".")), os.path.abspath(os.path.join(os.path.dirname(__file__), ".")),
"dataflows/data_cache", "dataflows/data_cache",
), ),
# LLM settings # LLM settings — all overridable via TRADINGAGENTS_<KEY> env vars
"mid_think_llm": "qwen3.5:27b", # falls back to quick_think_llm when None "llm_provider": _env("LLM_PROVIDER", "openai"),
"quick_think_llm": "qwen3.5:27b", "deep_think_llm": _env("DEEP_THINK_LLM", "gpt-5.2"),
"mid_think_llm": _env("MID_THINK_LLM"), # falls back to quick_think_llm when None
"quick_think_llm": _env("QUICK_THINK_LLM", "gpt-5-mini"),
"backend_url": _env("BACKEND_URL", "https://api.openai.com/v1"),
# Per-role provider overrides (fall back to llm_provider / backend_url when None) # Per-role provider overrides (fall back to llm_provider / backend_url when None)
"deep_think_llm_provider": "openrouter", "deep_think_llm_provider": _env("DEEP_THINK_LLM_PROVIDER"), # e.g. "google", "anthropic", "openrouter"
"deep_think_llm": "deepseek/deepseek-r1-0528", "deep_think_backend_url": _env("DEEP_THINK_BACKEND_URL"), # override backend URL for deep-think model
"deep_think_backend_url": None, # uses OpenRouter's default URL "mid_think_llm_provider": _env("MID_THINK_LLM_PROVIDER"), # e.g. "ollama"
"mid_think_llm_provider": "ollama", # falls back to ollama "mid_think_backend_url": _env("MID_THINK_BACKEND_URL"), # override backend URL for mid-think model
"mid_think_backend_url": "http://192.168.50.76:11434", # falls back to backend_url (ollama host) "quick_think_llm_provider": _env("QUICK_THINK_LLM_PROVIDER"), # e.g. "openai", "ollama"
"quick_think_llm_provider": "ollama", # falls back to ollama "quick_think_backend_url": _env("QUICK_THINK_BACKEND_URL"), # override backend URL for quick-think model
"quick_think_backend_url": "http://192.168.50.76:11434", # falls back to backend_url (ollama host)
# Provider-specific thinking configuration (applies to all roles unless overridden) # Provider-specific thinking configuration (applies to all roles unless overridden)
"google_thinking_level": None, # "high", "minimal", etc. "google_thinking_level": _env("GOOGLE_THINKING_LEVEL"), # "high", "minimal", etc.
"openai_reasoning_effort": None, # "medium", "high", "low" "openai_reasoning_effort": _env("OPENAI_REASONING_EFFORT"), # "medium", "high", "low"
# Per-role provider-specific thinking configuration # Per-role provider-specific thinking configuration
"deep_think_google_thinking_level": None, "deep_think_google_thinking_level": _env("DEEP_THINK_GOOGLE_THINKING_LEVEL"),
"deep_think_openai_reasoning_effort": None, "deep_think_openai_reasoning_effort": _env("DEEP_THINK_OPENAI_REASONING_EFFORT"),
"mid_think_google_thinking_level": None, "mid_think_google_thinking_level": _env("MID_THINK_GOOGLE_THINKING_LEVEL"),
"mid_think_openai_reasoning_effort": None, "mid_think_openai_reasoning_effort": _env("MID_THINK_OPENAI_REASONING_EFFORT"),
"quick_think_google_thinking_level": None, "quick_think_google_thinking_level": _env("QUICK_THINK_GOOGLE_THINKING_LEVEL"),
"quick_think_openai_reasoning_effort": None, "quick_think_openai_reasoning_effort": _env("QUICK_THINK_OPENAI_REASONING_EFFORT"),
# Debate and discussion settings # Debate and discussion settings
"max_debate_rounds": 1, "max_debate_rounds": _env_int("MAX_DEBATE_ROUNDS", 1),
"max_risk_discuss_rounds": 1, "max_risk_discuss_rounds": _env_int("MAX_RISK_DISCUSS_ROUNDS", 1),
"max_recur_limit": 100, "max_recur_limit": _env_int("MAX_RECUR_LIMIT", 100),
# Data vendor configuration # Data vendor configuration
# Category-level configuration (default for all tools in category) # Category-level configuration (default for all tools in category)
"data_vendors": { "data_vendors": {
"core_stock_apis": "yfinance", # Options: alpha_vantage, yfinance "core_stock_apis": _env("VENDOR_CORE_STOCK_APIS", "yfinance"),
"technical_indicators": "yfinance", # Options: alpha_vantage, yfinance "technical_indicators": _env("VENDOR_TECHNICAL_INDICATORS", "yfinance"),
"fundamental_data": "yfinance", # Options: alpha_vantage, yfinance "fundamental_data": _env("VENDOR_FUNDAMENTAL_DATA", "yfinance"),
"news_data": "yfinance", # Options: alpha_vantage, yfinance "news_data": _env("VENDOR_NEWS_DATA", "yfinance"),
"scanner_data": "alpha_vantage", # Options: alpha_vantage (primary), yfinance (fallback) "scanner_data": _env("VENDOR_SCANNER_DATA", "yfinance"),
}, },
# Tool-level configuration (takes precedence over category-level) # Tool-level configuration (takes precedence over category-level)
"tool_vendors": { "tool_vendors": {

View File

@ -139,9 +139,10 @@ class ScannerGraph:
} }
if self.debug: if self.debug:
trace = [] # stream() yields partial state updates; use invoke() for the
# full accumulated state and print chunks for debugging only.
for chunk in self.graph.stream(initial_state): for chunk in self.graph.stream(initial_state):
trace.append(chunk) print(f"[scanner debug] chunk keys: {list(chunk.keys())}")
return trace[-1] if trace else initial_state # Fall through to invoke() for the correct accumulated result
return self.graph.invoke(initial_state) return self.graph.invoke(initial_state)

View File

@ -238,10 +238,10 @@ async def run_all_tickers(
List of TickerResult in completion order. List of TickerResult in completion order.
""" """
semaphore = asyncio.Semaphore(max_concurrent) semaphore = asyncio.Semaphore(max_concurrent)
loop = asyncio.get_event_loop()
async def _run_one(candidate: StockCandidate) -> TickerResult: async def _run_one(candidate: StockCandidate) -> TickerResult:
async with semaphore: async with semaphore:
loop = asyncio.get_running_loop()
# TradingAgentsGraph is synchronous — run it in a thread pool # TradingAgentsGraph is synchronous — run it in a thread pool
return await loop.run_in_executor( return await loop.run_in_executor(
None, None,