Fix: websocket emits "Run completed." on failed runs, masking errors in frontend (#102)
* Initial plan * Fix websocket sending "Run completed." on failed runs, masking errors from frontend Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com> Agent-Logs-Url: https://github.com/aguzererler/TradingAgents/sessions/608ea02c-ca85-4f05-9ce1-611802493d7b * Fix 5 failing unit tests (truthy MagicMock skips) + expose ticker_analyses in state + add Finviz live integration tests Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com> Agent-Logs-Url: https://github.com/aguzererler/TradingAgents/sessions/7872ceae-cade-4563-aedb-34660d419a36 * docs: update testing.md with MagicMock explainer, current test counts, full catalogue, and detailed run commands Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com> Agent-Logs-Url: https://github.com/aguzererler/TradingAgents/sessions/13639e60-cb9b-4cb0-891c-df5d448c158d --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com>
This commit is contained in:
parent
4c14080d73
commit
9c148b208a
|
|
@ -63,7 +63,7 @@ async def websocket_endpoint(
|
||||||
|
|
||||||
if run_info.get("status") == "failed":
|
if run_info.get("status") == "failed":
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{"type": "system", "message": f"Run failed: {run_info.get('error', 'unknown error')}"}
|
{"type": "system", "message": f"Error: Run failed: {run_info.get('error', 'unknown error')}"}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# status == "queued" — WebSocket is the executor (background task didn't start yet)
|
# status == "queued" — WebSocket is the executor (background task didn't start yet)
|
||||||
|
|
@ -102,10 +102,15 @@ async def websocket_endpoint(
|
||||||
else:
|
else:
|
||||||
msg = f"Run type '{run_type}' streaming not yet implemented."
|
msg = f"Run type '{run_type}' streaming not yet implemented."
|
||||||
logger.warning(msg)
|
logger.warning(msg)
|
||||||
|
run_info["status"] = "failed"
|
||||||
|
run_info["error"] = msg
|
||||||
await websocket.send_json({"type": "system", "message": f"Error: {msg}"})
|
await websocket.send_json({"type": "system", "message": f"Error: {msg}"})
|
||||||
|
|
||||||
await websocket.send_json({"type": "system", "message": "Run completed."})
|
if run_info.get("status") == "completed":
|
||||||
logger.info("Run completed run=%s type=%s", run_id, run_type)
|
await websocket.send_json({"type": "system", "message": "Run completed."})
|
||||||
|
logger.info("Run completed run=%s type=%s", run_id, run_type)
|
||||||
|
else:
|
||||||
|
logger.info("Run ended with status=%s run=%s type=%s", run_info.get("status"), run_id, run_type)
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
logger.info("WebSocket client disconnected run=%s", run_id)
|
logger.info("WebSocket client disconnected run=%s", run_id)
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,7 @@ class LangGraphEngine:
|
||||||
"analysis_date": date, # PortfolioManagerState uses analysis_date
|
"analysis_date": date, # PortfolioManagerState uses analysis_date
|
||||||
"prices": prices,
|
"prices": prices,
|
||||||
"scan_summary": scan_summary,
|
"scan_summary": scan_summary,
|
||||||
|
"ticker_analyses": ticker_analyses,
|
||||||
"messages": [],
|
"messages": [],
|
||||||
"portfolio_data": "",
|
"portfolio_data": "",
|
||||||
"risk_metrics": "",
|
"risk_metrics": "",
|
||||||
|
|
|
||||||
405
docs/testing.md
405
docs/testing.md
|
|
@ -1,7 +1,7 @@
|
||||||
# TradingAgents — Test Suite Reference
|
# TradingAgents — Test Suite Reference
|
||||||
|
|
||||||
> **Last verified:** 2026-03-19
|
> **Last verified:** 2026-03-24
|
||||||
> **Test counts (current):** 405 unit · 68 integration · 1 e2e
|
> **Test counts (current):** 793 passed · 14 skipped (unit+portfolio+cli default run) · 101 integration · 1 e2e
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -16,7 +16,8 @@
|
||||||
7. [Execution Flow Diagrams](#execution-flow-diagrams)
|
7. [Execution Flow Diagrams](#execution-flow-diagrams)
|
||||||
8. [How to Run Tests](#how-to-run-tests)
|
8. [How to Run Tests](#how-to-run-tests)
|
||||||
9. [Mock Patterns](#mock-patterns)
|
9. [Mock Patterns](#mock-patterns)
|
||||||
10. [Adding New Tests — Checklist](#adding-new-tests--checklist)
|
10. [What is MagicMock?](#what-is-magicmock)
|
||||||
|
11. [Adding New Tests — Checklist](#adding-new-tests--checklist)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -32,13 +33,19 @@ tests/
|
||||||
├── conftest.py ← root fixtures (shared across all tiers)
|
├── conftest.py ← root fixtures (shared across all tiers)
|
||||||
├── unit/ ← offline, <5 s total, default run
|
├── unit/ ← offline, <5 s total, default run
|
||||||
│ ├── conftest.py ← mock factories (yfinance, AV, LLM)
|
│ ├── conftest.py ← mock factories (yfinance, AV, LLM)
|
||||||
|
│ ├── agents/ ← agent-specific unit tests
|
||||||
│ └── test_*.py
|
│ └── test_*.py
|
||||||
├── integration/ ← live APIs, excluded from default run
|
├── integration/ ← live APIs, excluded from default run
|
||||||
│ ├── conftest.py ← VCR config + live key fixtures
|
│ ├── conftest.py ← VCR config + live key fixtures
|
||||||
│ └── test_*.py
|
│ └── test_*_live.py
|
||||||
├── e2e/ ← real LLM pipeline, manual only
|
├── e2e/ ← real LLM pipeline, manual only
|
||||||
│ ├── conftest.py
|
│ ├── conftest.py
|
||||||
│ └── test_*.py
|
│ └── test_*.py
|
||||||
|
├── portfolio/ ← portfolio module unit tests (no DB)
|
||||||
|
│ ├── conftest.py
|
||||||
|
│ └── test_*.py
|
||||||
|
├── cli/ ← CLI module unit tests
|
||||||
|
│ └── test_*.py
|
||||||
└── cassettes/ ← recorded HTTP responses (VCR)
|
└── cassettes/ ← recorded HTTP responses (VCR)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -48,7 +55,7 @@ tests/
|
||||||
|
|
||||||
| Tier | Directory | Default run? | Network? | Speed | Purpose |
|
| Tier | Directory | Default run? | Network? | Speed | Purpose |
|
||||||
|------|-----------|:---:|:---:|-------|---------|
|
|------|-----------|:---:|:---:|-------|---------|
|
||||||
| **Unit** | `tests/unit/` | ✅ yes | ❌ blocked by `pytest-socket` | < 5 s | Validate logic, parsing, routing with mocks |
|
| **Unit** | `tests/unit/`, `tests/portfolio/`, `tests/cli/` | ✅ yes | ❌ blocked by `pytest-socket` | < 5 s | Validate logic, parsing, routing with mocks |
|
||||||
| **Integration** | `tests/integration/` | ❌ ignored | ✅ real APIs | seconds–minutes | Validate vendor API contracts, live data shapes |
|
| **Integration** | `tests/integration/` | ❌ ignored | ✅ real APIs | seconds–minutes | Validate vendor API contracts, live data shapes |
|
||||||
| **E2E** | `tests/e2e/` | ❌ ignored | ✅ real LLM + APIs | minutes | Validate the full multi-agent pipeline |
|
| **E2E** | `tests/e2e/` | ❌ ignored | ✅ real LLM + APIs | minutes | Validate the full multi-agent pipeline |
|
||||||
|
|
||||||
|
|
@ -370,18 +377,48 @@ def pytest_collection_modifyitems(config, items):
|
||||||
| `test_e2e_api_integration.py` | 19 | `route_to_vendor` + full yfinance+AV pipeline | `yf.Ticker`, `requests.get` |
|
| `test_e2e_api_integration.py` | 19 | `route_to_vendor` + full yfinance+AV pipeline | `yf.Ticker`, `requests.get` |
|
||||||
| `test_env_override.py` | 15 | `TRADINGAGENTS_*` env vars override `DEFAULT_CONFIG` | `importlib.reload`, `patch.dict` |
|
| `test_env_override.py` | 15 | `TRADINGAGENTS_*` env vars override `DEFAULT_CONFIG` | `importlib.reload`, `patch.dict` |
|
||||||
| `test_finnhub_integration.py` | ~100 | Finnhub data layer — all endpoints, exception types | `requests.get` (mock response) |
|
| `test_finnhub_integration.py` | ~100 | Finnhub data layer — all endpoints, exception types | `requests.get` (mock response) |
|
||||||
|
| `test_finnhub_scanner_utils.py` | 10 | `_safe_fmt` and other Finnhub scanner utility functions | None (pure logic) |
|
||||||
|
| `test_incident_fixes.py` | 15 | `_load_or_fetch_ohlcv` cache, `YFinanceError` propagation | `yf.Ticker`, `tmp_path` |
|
||||||
| `test_industry_deep_dive.py` | 12 | `_extract_top_sectors()` + `run_tool_loop` nudge | `MagicMock` LLM, `ToolMessage` |
|
| `test_industry_deep_dive.py` | 12 | `_extract_top_sectors()` + `run_tool_loop` nudge | `MagicMock` LLM, `ToolMessage` |
|
||||||
| `test_json_utils.py` | 15 | `extract_json` — fences, think-tags, malformed input | None (pure logic) |
|
| `test_json_utils.py` | 15 | `extract_json` — fences, think-tags, malformed input | None (pure logic) |
|
||||||
|
| `test_langgraph_engine_extraction.py` | 14 | `_map_langgraph_event` — event type mapping, metadata extraction | `MagicMock` events |
|
||||||
|
| `test_langgraph_engine_run_modes.py` | 28 | `run_scan/pipeline/portfolio/auto` — phase coordination, skip logic | `MagicMock` store, `AsyncMock` graph |
|
||||||
| `test_macro_bridge.py` | ~12 | Macro JSON parsing, filtering, report rendering | `tmp_path` |
|
| `test_macro_bridge.py` | ~12 | Macro JSON parsing, filtering, report rendering | `tmp_path` |
|
||||||
| `test_macro_regime.py` | ~32 | VIX signals, credit spread, breadth, regime classifier | `pd.Series`, `patch` (yfinance) |
|
| `test_macro_regime.py` | ~32 | VIX signals, credit spread, breadth, regime classifier | `pd.Series`, `patch` (yfinance) |
|
||||||
| `test_notebook_sync.py` | 5 | `sync_to_notebooklm` subprocess flow | `subprocess.run` |
|
| `test_notebook_sync.py` | 5 | `sync_to_notebooklm` subprocess flow | `subprocess.run` |
|
||||||
| `test_peer_comparison.py` | ~18 | Sector peers, relative performance, comparison report | `yf.Ticker`, `yf.Sector` |
|
| `test_peer_comparison.py` | ~18 | Sector peers, relative performance, comparison report | `yf.Ticker`, `yf.Sector` |
|
||||||
|
| `test_portfolio_tools.py` | 23 | `portfolio_tools.py` — in-memory data, no DB | `MagicMock` repo |
|
||||||
| `test_scanner_fallback.py` | 2 | AV scanner raises on total failure | `_fetch_global_quote` side_effect |
|
| `test_scanner_fallback.py` | 2 | AV scanner raises on total failure | `_fetch_global_quote` side_effect |
|
||||||
| `test_scanner_graph.py` | 4 | `ScannerGraph` + `ScannerGraphSetup` compile correctly | `ScannerGraph._create_llm` |
|
| `test_scanner_graph.py` | 4 | `ScannerGraph` + `ScannerGraphSetup` compile correctly | `ScannerGraph._create_llm` |
|
||||||
| `test_scanner_mocked.py` | ~57 | yfinance + AV scanner functions, route_to_vendor routing | `yf.Screener`, `requests.get` |
|
| `test_scanner_mocked.py` | ~57 | yfinance + AV scanner functions, route_to_vendor routing | `yf.Screener`, `requests.get` |
|
||||||
|
| `test_security_notebook_sync.py` | 3 | Shell injection guard in `sync_to_notebooklm` | None |
|
||||||
|
| `test_stockstats_utils.py` | ~20 | `get_stockstats_indicator` + `stockstats` formatting | `yf.download` mock |
|
||||||
| `test_ttm_analysis.py` | ~21 | TTM metric computation, report formatting | `yf.Ticker` (quarterly data) |
|
| `test_ttm_analysis.py` | ~21 | TTM metric computation, report formatting | `yf.Ticker` (quarterly data) |
|
||||||
| `test_vendor_failfast.py` | 11 | Fail-fast routing (ADR 011), error chaining | `requests.get`, `MagicMock` |
|
| `test_vendor_failfast.py` | 11 | Fail-fast routing (ADR 011), error chaining | `requests.get`, `MagicMock` |
|
||||||
| `test_yfinance_integration.py` | ~48 | yfinance data layer — OHLCV, fundamentals, news | `yf.Ticker`, `yf.Search` |
|
| `test_yfinance_integration.py` | ~48 | yfinance data layer — OHLCV, fundamentals, news | `yf.Ticker`, `yf.Search` |
|
||||||
|
| `agents/test_analyst_agents.py` | ~15 | Analyst agent node wiring + prompt formatting | `MagicMock` LLM |
|
||||||
|
|
||||||
|
### Portfolio tests (`tests/portfolio/`)
|
||||||
|
|
||||||
|
These tests cover the portfolio management module with in-memory data only — no database connection is required.
|
||||||
|
|
||||||
|
| File | # Tests | What it covers | Key mocks used |
|
||||||
|
|------|--------:|----------------|---------------|
|
||||||
|
| `test_candidate_prioritizer.py` | 10 | Candidate scoring + ranking algorithm | None (pure logic) |
|
||||||
|
| `test_config.py` | 8 | Portfolio config defaults + env var overrides | `patch.dict` |
|
||||||
|
| `test_models.py` | 23 | `Portfolio`, `Holding`, `Trade`, `PortfolioSnapshot` dataclasses | None (pure logic) |
|
||||||
|
| `test_report_store.py` | 23 | `ReportStore` save/load cycle, JSON serialization | `tmp_path` |
|
||||||
|
| `test_repository.py` | 16 | `PortfolioRepository` — CRUD operations | `MagicMock` Supabase client |
|
||||||
|
| `test_risk_evaluator.py` | 28 | Risk constraint checks — position limits, sector limits, cash floor | None (pure logic) |
|
||||||
|
| `test_risk_metrics.py` | 48 | Volatility, Sharpe, max drawdown, beta metrics | `pd.Series` |
|
||||||
|
| `test_trade_executor.py` | 10 | `TradeExecutor` — sell/buy ordering, constraint pre-flight, snapshot | `MagicMock` repo |
|
||||||
|
|
||||||
|
### CLI tests (`tests/cli/`)
|
||||||
|
|
||||||
|
| File | # Tests | What it covers | Key mocks used |
|
||||||
|
|------|--------:|----------------|---------------|
|
||||||
|
| `test_main.py` | ~8 | `extract_content_string`, CLI argument parsing | `patch` (LLM), `tmp_path` |
|
||||||
|
| `test_stats_handler.py` | ~6 | `StatsCallbackHandler` — token counting, threading safety | `MagicMock` LLM callbacks |
|
||||||
|
|
||||||
### Integration tests (`tests/integration/`)
|
### Integration tests (`tests/integration/`)
|
||||||
|
|
||||||
|
|
@ -389,8 +426,10 @@ def pytest_collection_modifyitems(config, items):
|
||||||
|------|--------:|----------------|---------|
|
|------|--------:|----------------|---------|
|
||||||
| `test_alpha_vantage_live.py` | 3 | Live AV `_make_api_request` — key errors, timeout, success | Network |
|
| `test_alpha_vantage_live.py` | 3 | Live AV `_make_api_request` — key errors, timeout, success | Network |
|
||||||
| `test_finnhub_live.py` | ~41 | All Finnhub free-tier + paid-tier endpoints (live HTTP) | `FINNHUB_API_KEY` |
|
| `test_finnhub_live.py` | ~41 | All Finnhub free-tier + paid-tier endpoints (live HTTP) | `FINNHUB_API_KEY` |
|
||||||
|
| `test_finviz_live.py` | 27 | All three Finviz smart-money screener tools — live HTTP | Network; auto-skips if `finvizfinance` not installed |
|
||||||
| `test_nlm_live.py` | 1 | NotebookLM source CRUD via `nlm` CLI | `NOTEBOOKLM_ID` + `nlm` binary |
|
| `test_nlm_live.py` | 1 | NotebookLM source CRUD via `nlm` CLI | `NOTEBOOKLM_ID` + `nlm` binary |
|
||||||
| `test_scanner_live.py` | ~23 | yfinance scanner tools + AV routing (live yfinance + AV) | Network; `ALPHA_VANTAGE_API_KEY` for AV tests |
|
| `test_scanner_live.py` | ~23 | yfinance scanner tools + AV routing (live yfinance + AV) | Network; `ALPHA_VANTAGE_API_KEY` for AV tests |
|
||||||
|
| `test_stockstats_live.py` | ~6 | `get_stockstats_indicator` against live yfinance data | Network |
|
||||||
|
|
||||||
### E2E tests (`tests/e2e/`)
|
### E2E tests (`tests/e2e/`)
|
||||||
|
|
||||||
|
|
@ -444,7 +483,10 @@ flowchart TD
|
||||||
J -- no --> K["pytest.mark.skipif\n→ test_nlm_live.py SKIPPED"]
|
J -- no --> K["pytest.mark.skipif\n→ test_nlm_live.py SKIPPED"]
|
||||||
J -- yes --> L[NotebookLM live test runs]
|
J -- yes --> L[NotebookLM live test runs]
|
||||||
B --> M[Scanner live tests run\nagainst real yfinance API]
|
B --> M[Scanner live tests run\nagainst real yfinance API]
|
||||||
E & I & L & M --> N([Results reported])
|
B --> N{finvizfinance installed?}
|
||||||
|
N -- no --> O["pytestmark skipif\n→ test_finviz_live.py SKIPPED"]
|
||||||
|
N -- yes --> P[Finviz screener tests run\nno API key needed]
|
||||||
|
E & I & K & L & M & O & P --> Q([Results reported])
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -517,67 +559,176 @@ flowchart TD
|
||||||
|
|
||||||
## How to Run Tests
|
## How to Run Tests
|
||||||
|
|
||||||
### Default (unit only, CI-safe)
|
### Quick reference
|
||||||
|
|
||||||
```bash
|
| What to run | Command | Expected result |
|
||||||
pytest
|
|---|---|---|
|
||||||
# or equivalently:
|
| **All unit tests (CI-safe)** | `pytest` | 793 passed, 14 skipped, < 5 s |
|
||||||
pytest tests/unit/
|
| **Unit tests only** | `pytest tests/unit/` | ~600 passed |
|
||||||
```
|
| **Portfolio tests only** | `pytest tests/portfolio/` | ~166 passed |
|
||||||
|
| **CLI tests only** | `pytest tests/cli/` | ~14 passed |
|
||||||
Expected: **405 collected → ~395 passed, ~10 skipped**, < 5 s
|
| **All integration tests** | `pytest tests/integration/ -v` | varies (network required) |
|
||||||
|
| **Finviz integration** | `pytest tests/integration/test_finviz_live.py -v` | 27 tests, network required |
|
||||||
|
| **Finnhub integration** | `FINNHUB_API_KEY=<key> pytest tests/integration/test_finnhub_live.py -v` | requires key |
|
||||||
|
| **E2E pipeline** | `pytest tests/e2e/ -v` | requires LLM key + network |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Integration tests (requires network + optional API keys)
|
### Install dependencies first
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# All integration tests
|
pip install -e .
|
||||||
|
pip install pytest pytest-socket
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Default (unit + portfolio + cli, CI-safe)
|
||||||
|
|
||||||
|
The default `pytest` invocation is controlled by `addopts` in `pyproject.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
addopts = "--ignore=tests/integration --ignore=tests/e2e --disable-socket --allow-unix-socket -x -q"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Simplest — uses addopts automatically
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Equivalent explicit form
|
||||||
|
pytest tests/ --ignore=tests/integration --ignore=tests/e2e \
|
||||||
|
--disable-socket --allow-unix-socket -x -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
|
||||||
|
```
|
||||||
|
793 passed, 14 skipped in 4.9s
|
||||||
|
```
|
||||||
|
|
||||||
|
**What `-x` does:** stops at the first failing test.
|
||||||
|
**What `-q` does:** minimal output (dots + summary).
|
||||||
|
**What `--disable-socket` does:** blocks all real network calls — any accidental HTTP call fails with `SocketBlockedError`.
|
||||||
|
|
||||||
|
To see all failures instead of stopping at the first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/ --ignore=tests/integration --ignore=tests/e2e \
|
||||||
|
--disable-socket --allow-unix-socket -q
|
||||||
|
# (omit -x)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Run a single test file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unit file
|
||||||
|
pytest tests/unit/test_langgraph_engine_run_modes.py -v \
|
||||||
|
--disable-socket --allow-unix-socket
|
||||||
|
|
||||||
|
# Portfolio file
|
||||||
|
pytest tests/portfolio/test_trade_executor.py -v \
|
||||||
|
--disable-socket --allow-unix-socket
|
||||||
|
|
||||||
|
# CLI file
|
||||||
|
pytest tests/cli/test_stats_handler.py -v \
|
||||||
|
--disable-socket --allow-unix-socket
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Run a single test class or test function
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Single class
|
||||||
|
pytest tests/unit/test_langgraph_engine_run_modes.py::TestRunAutoTickerSource -v \
|
||||||
|
--disable-socket --allow-unix-socket
|
||||||
|
|
||||||
|
# Single test function
|
||||||
|
pytest tests/unit/test_langgraph_engine_run_modes.py::TestRunAutoTickerSource::test_run_auto_gets_tickers_from_scan_report -v \
|
||||||
|
--disable-socket --allow-unix-socket
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Integration tests (requires network)
|
||||||
|
|
||||||
|
Integration tests make **real HTTP calls** to external APIs. They are
|
||||||
|
excluded from the default run but can be run explicitly at any time.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All integration tests (everything in tests/integration/)
|
||||||
pytest tests/integration/ -v
|
pytest tests/integration/ -v
|
||||||
|
|
||||||
# Only Alpha Vantage live tests
|
# Finviz smart-money screener tests (no API key needed — free public screener)
|
||||||
|
pytest tests/integration/test_finviz_live.py -v
|
||||||
|
|
||||||
|
# Alpha Vantage live tests ('demo' key works for basic calls)
|
||||||
pytest tests/integration/test_alpha_vantage_live.py -v
|
pytest tests/integration/test_alpha_vantage_live.py -v
|
||||||
|
|
||||||
# Only Finnhub live tests (requires key)
|
# Finnhub live tests (free-tier endpoints only)
|
||||||
|
FINNHUB_API_KEY=your_key pytest tests/integration/test_finnhub_live.py -v \
|
||||||
|
-m "integration and not paid_tier"
|
||||||
|
|
||||||
|
# Finnhub — all tests including paid-tier (requires premium subscription)
|
||||||
FINNHUB_API_KEY=your_key pytest tests/integration/test_finnhub_live.py -v
|
FINNHUB_API_KEY=your_key pytest tests/integration/test_finnhub_live.py -v
|
||||||
|
|
||||||
# Only free-tier Finnhub tests
|
# Live scanner tests (hits real yfinance + Alpha Vantage APIs)
|
||||||
FINNHUB_API_KEY=your_key pytest tests/integration/test_finnhub_live.py -v -m "integration and not paid_tier"
|
|
||||||
|
|
||||||
# Scanner live tests
|
|
||||||
pytest tests/integration/test_scanner_live.py -v
|
pytest tests/integration/test_scanner_live.py -v
|
||||||
|
|
||||||
|
# Live stockstats tests
|
||||||
|
pytest tests/integration/test_stockstats_live.py -v
|
||||||
|
|
||||||
|
# NotebookLM tests (requires NOTEBOOKLM_ID env var and nlm CLI binary)
|
||||||
|
NOTEBOOKLM_ID=your_id pytest tests/integration/test_nlm_live.py -v
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Tip:** When running integration tests from a network-blocked environment
|
||||||
|
> (e.g., CI without outbound access), tests that require a network connection
|
||||||
|
> will auto-skip rather than fail — they detect the blocked socket and call
|
||||||
|
> `pytest.skip()` gracefully.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### E2E tests (requires LLM API key + network, manual only)
|
### E2E tests (requires LLM API key + network, manual only)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Requires a real LLM API key (e.g., OPENAI_API_KEY) and network access.
|
||||||
|
# Takes several minutes to complete.
|
||||||
pytest tests/e2e/ -v
|
pytest tests/e2e/ -v
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Targeting by marker
|
### Run only tests with a specific marker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run only integration-marked tests (wherever they are)
|
# All integration-marked tests across the entire test suite
|
||||||
pytest tests/ --override-ini="addopts=" -m integration
|
pytest tests/ --override-ini="addopts=" -m integration -v
|
||||||
|
|
||||||
# Run excluding slow tests
|
# All tests except slow ones
|
||||||
pytest tests/ --override-ini="addopts=" -m "not slow"
|
pytest tests/ --override-ini="addopts=" -m "not slow" \
|
||||||
|
--disable-socket --allow-unix-socket
|
||||||
|
|
||||||
# Run unit tests without -x (see all failures, not just first)
|
# Only paid_tier Finnhub tests (requires premium key)
|
||||||
pytest tests/unit/ --override-ini="addopts=--disable-socket --allow-unix-socket -q"
|
FINNHUB_API_KEY=your_key pytest tests/integration/ -m paid_tier -v
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **`--override-ini="addopts="`** clears the default `addopts` from
|
||||||
|
> `pyproject.toml` so you can pass your own flags instead.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Re-record VCR cassettes
|
### Re-record VCR cassettes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Record only new requests (existing cassettes kept)
|
||||||
pytest tests/integration/ --record-mode=new_episodes
|
pytest tests/integration/ --record-mode=new_episodes
|
||||||
# or to record from scratch:
|
|
||||||
|
# Re-record everything from scratch
|
||||||
pytest tests/integration/ --record-mode=all
|
pytest tests/integration/ --record-mode=all
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -722,6 +873,187 @@ automatically to every test in the file without explicit declaration.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## What is MagicMock?
|
||||||
|
|
||||||
|
`MagicMock` is the workhorse of Python's `unittest.mock` library — the
|
||||||
|
standard tool for replacing real objects with controllable stand-ins during
|
||||||
|
tests. Understanding it is essential for reading and writing tests in this
|
||||||
|
project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### The core idea: a pretend object
|
||||||
|
|
||||||
|
When you write:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
repo = MagicMock()
|
||||||
|
```
|
||||||
|
|
||||||
|
`repo` is now an object that:
|
||||||
|
- **accepts any attribute access** (`repo.some_attr` → another `MagicMock`)
|
||||||
|
- **accepts any method call** (`repo.get_portfolio()` → another `MagicMock`)
|
||||||
|
- **records everything** so you can ask later: "was this called? with what?"
|
||||||
|
|
||||||
|
No database, no HTTP, no file system — just a pretend object you fully control.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Setting return values
|
||||||
|
|
||||||
|
By default every method returns a new `MagicMock`. You can override this:
|
||||||
|
|
||||||
|
```python
|
||||||
|
repo = MagicMock()
|
||||||
|
repo.get_portfolio.return_value = {"portfolio_id": "p1", "cash": 50_000.0}
|
||||||
|
|
||||||
|
result = repo.get_portfolio("p1") # returns the dict you set, not a MagicMock
|
||||||
|
print(result["cash"]) # 50000.0
|
||||||
|
```
|
||||||
|
|
||||||
|
This is critical. If you forget `return_value=...`, the method returns a
|
||||||
|
`MagicMock`, which is **truthy**. Code that does `if repo.load_data(...):`
|
||||||
|
will take the "data exists" branch even when it shouldn't.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### The truthy problem (and why it caused the mock trade bug)
|
||||||
|
|
||||||
|
This project has guard clauses like:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if not force and store.load_execution_result(date, portfolio_id):
|
||||||
|
# skip — already done
|
||||||
|
return
|
||||||
|
```
|
||||||
|
|
||||||
|
If `store = MagicMock()` and `load_execution_result` is never given a
|
||||||
|
`return_value`, the call returns a new `MagicMock`, which is truthy.
|
||||||
|
The entire Phase 3 (portfolio execution / trades) gets **skipped** even
|
||||||
|
though no trades ever ran.
|
||||||
|
|
||||||
|
**The fix:** always set `return_value=None` for methods that are supposed to
|
||||||
|
return "nothing found":
|
||||||
|
|
||||||
|
```python
|
||||||
|
mock_store = MagicMock()
|
||||||
|
mock_store.load_scan.return_value = scan_data # some data
|
||||||
|
mock_store.load_execution_result.return_value = None # ← explicitly "not found"
|
||||||
|
mock_store.load_pm_decision.return_value = None # ← explicitly "not found"
|
||||||
|
mock_store.load_analysis.return_value = None # ← explicitly "not found"
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the pattern used in `TestRunAutoTickerSource._make_mock_store()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Checking calls (assertions)
|
||||||
|
|
||||||
|
```python
|
||||||
|
repo = MagicMock()
|
||||||
|
repo.add_holding("p1", "AAPL", 10, 150.0)
|
||||||
|
|
||||||
|
# Was it called?
|
||||||
|
repo.add_holding.assert_called_once()
|
||||||
|
|
||||||
|
# Was it called with these exact arguments?
|
||||||
|
repo.add_holding.assert_called_once_with("p1", "AAPL", 10, 150.0)
|
||||||
|
|
||||||
|
# How many times?
|
||||||
|
assert repo.add_holding.call_count == 1
|
||||||
|
|
||||||
|
# What args did the last call use?
|
||||||
|
args, kwargs = repo.add_holding.call_args
|
||||||
|
assert args[1] == "AAPL"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Raising exceptions from mocks
|
||||||
|
|
||||||
|
```python
|
||||||
|
repo.add_holding.side_effect = InsufficientCashError("Not enough cash")
|
||||||
|
|
||||||
|
# Now calling add_holding() will raise the error instead of returning a value
|
||||||
|
```
|
||||||
|
|
||||||
|
`side_effect` can also be a callable or a list of values/exceptions to cycle
|
||||||
|
through on successive calls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AsyncMock — for `async def` functions
|
||||||
|
|
||||||
|
LangGraph uses `async for` and `await` extensively. For those, use
|
||||||
|
`AsyncMock`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
mock_graph = MagicMock()
|
||||||
|
mock_graph.astream_events = AsyncMock(return_value=iter([]))
|
||||||
|
# or as an async generator:
|
||||||
|
async def fake_stream(*args, **kwargs):
|
||||||
|
yield {"event": "on_chain_end", "data": {"output": {}}}
|
||||||
|
mock_graph.astream_events = fake_stream
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### patch() — replacing real objects temporarily
|
||||||
|
|
||||||
|
`MagicMock` creates the stand-in; `patch()` *swaps* it in for the duration
|
||||||
|
of a `with` block (or test function):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
def test_run_portfolio_skips_when_already_done():
|
||||||
|
mock_store = MagicMock()
|
||||||
|
mock_store.load_execution_result.return_value = {"summary": "done"}
|
||||||
|
|
||||||
|
with patch("agent_os.backend.services.langgraph_engine.ReportStore",
|
||||||
|
return_value=mock_store):
|
||||||
|
# All code inside here that imports ReportStore gets mock_store instead
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
The `target` string must be the **import path where the name is used**, not
|
||||||
|
where it is defined. If `langgraph_engine.py` does
|
||||||
|
`from tradingagents.portfolio.report_store import ReportStore`, you patch
|
||||||
|
`agent_os.backend.services.langgraph_engine.ReportStore`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PropertyMock — for `@property` attributes
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unittest.mock import MagicMock, PropertyMock
|
||||||
|
|
||||||
|
mock_ticker = MagicMock()
|
||||||
|
type(mock_ticker).info = PropertyMock(return_value={"longName": "Apple Inc."})
|
||||||
|
# mock_ticker.info now returns the dict (not a MagicMock)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Quick reference table
|
||||||
|
|
||||||
|
| Tool | Use case |
|
||||||
|
|------|----------|
|
||||||
|
| `MagicMock()` | Stand-in for any object (repo, client, graph) |
|
||||||
|
| `mock.method.return_value = x` | Make a method return `x` |
|
||||||
|
| `mock.method.side_effect = exc` | Make a method raise `exc` |
|
||||||
|
| `mock.method.assert_called_once_with(...)` | Assert exact call args |
|
||||||
|
| `AsyncMock()` | Stand-in for `async def` functions / methods |
|
||||||
|
| `patch("module.Name")` | Swap a real class/function with a mock temporarily |
|
||||||
|
| `patch.dict(os.environ, {...})` | Inject env vars for the duration of a test |
|
||||||
|
| `PropertyMock` | Mock `@property` descriptors |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Adding New Tests — Checklist
|
## Adding New Tests — Checklist
|
||||||
|
|
||||||
When adding a test to this project, choose the right tier and follow the
|
When adding a test to this project, choose the right tier and follow the
|
||||||
|
|
@ -734,9 +1066,18 @@ corresponding checklist.
|
||||||
- [ ] yfinance: use `patch("...yf.Ticker", ...)` — never call yfinance directly.
|
- [ ] yfinance: use `patch("...yf.Ticker", ...)` — never call yfinance directly.
|
||||||
- [ ] AV / Finnhub: use `patch("...requests.get", return_value=_mock_response(...))`.
|
- [ ] AV / Finnhub: use `patch("...requests.get", return_value=_mock_response(...))`.
|
||||||
- [ ] Use `monkeypatch.setenv` or `patch.dict(os.environ, ...)` for env var tests.
|
- [ ] Use `monkeypatch.setenv` or `patch.dict(os.environ, ...)` for env var tests.
|
||||||
|
- [ ] When mocking a store/repo, always pin `load_*` methods to `None` to avoid
|
||||||
|
truthy MagicMock accidentally triggering "already done" skip branches.
|
||||||
- [ ] Do NOT use `@pytest.mark.integration` — that signals the test is being tracked
|
- [ ] Do NOT use `@pytest.mark.integration` — that signals the test is being tracked
|
||||||
for future migration, not that it's already mocked.
|
for future migration, not that it's already mocked.
|
||||||
- [ ] Run `pytest tests/unit/ -x` to confirm the test passes offline.
|
- [ ] Run `pytest tests/unit/ -x --disable-socket --allow-unix-socket` to confirm the test passes offline.
|
||||||
|
|
||||||
|
### Portfolio module test (no DB needed)
|
||||||
|
|
||||||
|
- [ ] File goes in `tests/portfolio/test_<component>.py`.
|
||||||
|
- [ ] Use `MagicMock()` for the Supabase client / `PortfolioRepository` if testing
|
||||||
|
higher-level logic. Do NOT use a real DB connection.
|
||||||
|
- [ ] Run `pytest tests/portfolio/ -x --disable-socket --allow-unix-socket`.
|
||||||
|
|
||||||
### Integration test (live API needed)
|
### Integration test (live API needed)
|
||||||
|
|
||||||
|
|
@ -744,6 +1085,8 @@ corresponding checklist.
|
||||||
- [ ] Class or function decorated with `@pytest.mark.integration`.
|
- [ ] Class or function decorated with `@pytest.mark.integration`.
|
||||||
- [ ] Use the `av_api_key` fixture (or a similar guard) to auto-skip when the API
|
- [ ] Use the `av_api_key` fixture (or a similar guard) to auto-skip when the API
|
||||||
is unavailable.
|
is unavailable.
|
||||||
|
- [ ] For APIs with no key (e.g., Finviz): use `pytest.mark.skipif(not lib_available, ...)`
|
||||||
|
so the file is skipped if the library is not installed.
|
||||||
- [ ] For Finnhub paid-tier endpoints: add both `@pytest.mark.paid_tier` and
|
- [ ] For Finnhub paid-tier endpoints: add both `@pytest.mark.paid_tier` and
|
||||||
`@pytest.mark.skip` so they are documented but never run accidentally.
|
`@pytest.mark.skip` so they are documented but never run accidentally.
|
||||||
- [ ] Do NOT add the file path to `addopts`'s `--ignore` list — it is already
|
- [ ] Do NOT add the file path to `addopts`'s `--ignore` list — it is already
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,297 @@
|
||||||
|
"""Live integration tests for the Finviz smart-money screener tools.
|
||||||
|
|
||||||
|
These tests make REAL HTTP requests to finviz.com via the ``finvizfinance``
|
||||||
|
library and therefore require network access. No API key is needed — Finviz
|
||||||
|
is a free public screener.
|
||||||
|
|
||||||
|
The entire module is skipped automatically when ``finvizfinance`` is not
|
||||||
|
installed in the current environment.
|
||||||
|
|
||||||
|
Run only the Finviz live tests:
|
||||||
|
pytest tests/integration/test_finviz_live.py -v -m integration
|
||||||
|
|
||||||
|
Run all integration tests:
|
||||||
|
pytest tests/integration/ -v -m integration
|
||||||
|
|
||||||
|
Skip in unit-only CI (default):
|
||||||
|
pytest tests/ --ignore=tests/integration -v # live tests never run
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Guard — skip every test in this file if finvizfinance is not installed.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
try:
|
||||||
|
import finvizfinance # noqa: F401
|
||||||
|
|
||||||
|
_finvizfinance_available = True
|
||||||
|
except ImportError:
|
||||||
|
_finvizfinance_available = False
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
_skip_if_no_finviz = pytest.mark.skipif(
|
||||||
|
not _finvizfinance_available,
|
||||||
|
reason="finvizfinance not installed — skipping live Finviz tests",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_VALID_RESULT_PREFIXES = (
|
||||||
|
"Top 5 stocks for ",
|
||||||
|
"No stocks matched",
|
||||||
|
"Smart money scan unavailable",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_valid_result(result: str, label: str) -> None:
|
||||||
|
"""Assert that *result* is a well-formed string from _run_finviz_screen."""
|
||||||
|
assert isinstance(result, str), f"{label}: expected str, got {type(result)}"
|
||||||
|
assert len(result) > 0, f"{label}: result is empty"
|
||||||
|
assert any(result.startswith(prefix) for prefix in _VALID_RESULT_PREFIXES), (
|
||||||
|
f"{label}: unexpected result format:\n{result}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_ticker_rows(result: str, label: str) -> None:
|
||||||
|
"""When results were found, every data row must have the expected shape."""
|
||||||
|
if not result.startswith("Top 5 stocks for "):
|
||||||
|
pytest.skip(f"{label}: no market data returned today — skipping row assertions")
|
||||||
|
|
||||||
|
lines = result.strip().split("\n")
|
||||||
|
# First line is the header "Top 5 stocks for …:"
|
||||||
|
data_lines = [l for l in lines[1:] if l.strip()]
|
||||||
|
assert len(data_lines) >= 1, f"{label}: header present but no data rows"
|
||||||
|
|
||||||
|
for line in data_lines:
|
||||||
|
# Expected shape: "- TICKER (Sector) @ $Price"
|
||||||
|
assert line.startswith("- "), f"{label}: row missing '- ' prefix: {line!r}"
|
||||||
|
assert "@" in line, f"{label}: row missing '@' separator: {line!r}"
|
||||||
|
assert "$" in line, f"{label}: row missing '$' price marker: {line!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _run_finviz_screen helper (tested indirectly via the public tools)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_if_no_finviz
|
||||||
|
class TestRunFinvizScreen:
|
||||||
|
"""
|
||||||
|
Tests for the shared ``_run_finviz_screen`` helper.
|
||||||
|
Exercised indirectly through the public LangChain tool wrappers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_returns_string(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_unusual_volume_stocks
|
||||||
|
|
||||||
|
result = get_unusual_volume_stocks.invoke({})
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
def test_result_is_non_empty(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_unusual_volume_stocks
|
||||||
|
|
||||||
|
result = get_unusual_volume_stocks.invoke({})
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
def test_result_has_valid_prefix(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_unusual_volume_stocks
|
||||||
|
|
||||||
|
result = get_unusual_volume_stocks.invoke({})
|
||||||
|
_assert_valid_result(result, "unusual_volume")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_insider_buying_stocks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_if_no_finviz
|
||||||
|
class TestGetInsiderBuyingStocks:
|
||||||
|
"""Live tests for the insider-buying screener tool."""
|
||||||
|
|
||||||
|
def test_returns_string(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_insider_buying_stocks
|
||||||
|
|
||||||
|
result = get_insider_buying_stocks.invoke({})
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
def test_result_is_non_empty(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_insider_buying_stocks
|
||||||
|
|
||||||
|
result = get_insider_buying_stocks.invoke({})
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
def test_result_has_valid_prefix(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_insider_buying_stocks
|
||||||
|
|
||||||
|
result = get_insider_buying_stocks.invoke({})
|
||||||
|
_assert_valid_result(result, "insider_buying")
|
||||||
|
|
||||||
|
def test_data_rows_have_expected_shape(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_insider_buying_stocks
|
||||||
|
|
||||||
|
result = get_insider_buying_stocks.invoke({})
|
||||||
|
_assert_ticker_rows(result, "insider_buying")
|
||||||
|
|
||||||
|
def test_no_error_message_on_success(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_insider_buying_stocks
|
||||||
|
|
||||||
|
result = get_insider_buying_stocks.invoke({})
|
||||||
|
# If finviz returned data or an empty result, there should be no error
|
||||||
|
if result.startswith("Top 5 stocks for ") or result.startswith("No stocks matched"):
|
||||||
|
assert "Finviz error" not in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_unusual_volume_stocks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_if_no_finviz
|
||||||
|
class TestGetUnusualVolumeStocks:
|
||||||
|
"""Live tests for the unusual-volume screener tool."""
|
||||||
|
|
||||||
|
def test_returns_string(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_unusual_volume_stocks
|
||||||
|
|
||||||
|
result = get_unusual_volume_stocks.invoke({})
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
def test_result_is_non_empty(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_unusual_volume_stocks
|
||||||
|
|
||||||
|
result = get_unusual_volume_stocks.invoke({})
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
def test_result_has_valid_prefix(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_unusual_volume_stocks
|
||||||
|
|
||||||
|
result = get_unusual_volume_stocks.invoke({})
|
||||||
|
_assert_valid_result(result, "unusual_volume")
|
||||||
|
|
||||||
|
def test_data_rows_have_expected_shape(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_unusual_volume_stocks
|
||||||
|
|
||||||
|
result = get_unusual_volume_stocks.invoke({})
|
||||||
|
_assert_ticker_rows(result, "unusual_volume")
|
||||||
|
|
||||||
|
def test_no_error_message_on_success(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_unusual_volume_stocks
|
||||||
|
|
||||||
|
result = get_unusual_volume_stocks.invoke({})
|
||||||
|
if result.startswith("Top 5 stocks for ") or result.startswith("No stocks matched"):
|
||||||
|
assert "Finviz error" not in result
|
||||||
|
|
||||||
|
def test_tickers_are_uppercase(self):
|
||||||
|
"""When data is returned, all ticker symbols must be uppercase."""
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_unusual_volume_stocks
|
||||||
|
|
||||||
|
result = get_unusual_volume_stocks.invoke({})
|
||||||
|
if not result.startswith("Top 5 stocks for "):
|
||||||
|
pytest.skip("No data returned today")
|
||||||
|
|
||||||
|
lines = result.strip().split("\n")[1:]
|
||||||
|
for line in lines:
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
# "- TICKER (…) @ $…"
|
||||||
|
ticker = line.lstrip("- ").split(" ")[0]
|
||||||
|
assert ticker == ticker.upper(), f"Ticker not uppercase: {ticker!r}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_breakout_accumulation_stocks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_if_no_finviz
|
||||||
|
class TestGetBreakoutAccumulationStocks:
|
||||||
|
"""Live tests for the breakout-accumulation screener tool."""
|
||||||
|
|
||||||
|
def test_returns_string(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_breakout_accumulation_stocks
|
||||||
|
|
||||||
|
result = get_breakout_accumulation_stocks.invoke({})
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
def test_result_is_non_empty(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_breakout_accumulation_stocks
|
||||||
|
|
||||||
|
result = get_breakout_accumulation_stocks.invoke({})
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
def test_result_has_valid_prefix(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_breakout_accumulation_stocks
|
||||||
|
|
||||||
|
result = get_breakout_accumulation_stocks.invoke({})
|
||||||
|
_assert_valid_result(result, "breakout_accumulation")
|
||||||
|
|
||||||
|
def test_data_rows_have_expected_shape(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_breakout_accumulation_stocks
|
||||||
|
|
||||||
|
result = get_breakout_accumulation_stocks.invoke({})
|
||||||
|
_assert_ticker_rows(result, "breakout_accumulation")
|
||||||
|
|
||||||
|
def test_no_error_message_on_success(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_breakout_accumulation_stocks
|
||||||
|
|
||||||
|
result = get_breakout_accumulation_stocks.invoke({})
|
||||||
|
if result.startswith("Top 5 stocks for ") or result.startswith("No stocks matched"):
|
||||||
|
assert "Finviz error" not in result
|
||||||
|
|
||||||
|
def test_at_most_five_rows_returned(self):
|
||||||
|
"""The screener caps output at 5 rows (hardcoded head(5))."""
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_breakout_accumulation_stocks
|
||||||
|
|
||||||
|
result = get_breakout_accumulation_stocks.invoke({})
|
||||||
|
if not result.startswith("Top 5 stocks for "):
|
||||||
|
pytest.skip("No data returned today")
|
||||||
|
|
||||||
|
lines = [l for l in result.strip().split("\n")[1:] if l.strip()]
|
||||||
|
assert len(lines) <= 5, f"Expected ≤5 rows, got {len(lines)}"
|
||||||
|
|
||||||
|
def test_price_column_is_numeric(self):
|
||||||
|
"""Price values after '$' must be parseable as floats."""
|
||||||
|
from tradingagents.agents.utils.scanner_tools import get_breakout_accumulation_stocks
|
||||||
|
|
||||||
|
result = get_breakout_accumulation_stocks.invoke({})
|
||||||
|
if not result.startswith("Top 5 stocks for "):
|
||||||
|
pytest.skip("No data returned today")
|
||||||
|
|
||||||
|
lines = [l for l in result.strip().split("\n")[1:] if l.strip()]
|
||||||
|
for line in lines:
|
||||||
|
price_part = line.split("@ $")[-1].strip()
|
||||||
|
float(price_part) # raises ValueError if not numeric
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# All three tools together — smoke test
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_if_no_finviz
|
||||||
|
class TestAllThreeToolsSmoke:
|
||||||
|
"""Quick smoke test running all three tools sequentially."""
|
||||||
|
|
||||||
|
def test_all_three_return_strings(self):
|
||||||
|
from tradingagents.agents.utils.scanner_tools import (
|
||||||
|
get_breakout_accumulation_stocks,
|
||||||
|
get_insider_buying_stocks,
|
||||||
|
get_unusual_volume_stocks,
|
||||||
|
)
|
||||||
|
|
||||||
|
tools = [
|
||||||
|
(get_insider_buying_stocks, "insider_buying"),
|
||||||
|
(get_unusual_volume_stocks, "unusual_volume"),
|
||||||
|
(get_breakout_accumulation_stocks, "breakout_accumulation"),
|
||||||
|
]
|
||||||
|
for tool_fn, label in tools:
|
||||||
|
result = tool_fn.invoke({})
|
||||||
|
assert isinstance(result, str), f"{label}: expected str"
|
||||||
|
assert len(result) > 0, f"{label}: empty result"
|
||||||
|
_assert_valid_result(result, label)
|
||||||
|
|
@ -636,6 +636,7 @@ class TestRunAutoTickerSource(unittest.TestCase):
|
||||||
|
|
||||||
mock_store = MagicMock()
|
mock_store = MagicMock()
|
||||||
mock_store.load_scan.return_value = scan_data
|
mock_store.load_scan.return_value = scan_data
|
||||||
|
mock_store.load_analysis.return_value = None # prevent truthy skip of pipeline phase
|
||||||
mock_rs_cls.return_value = mock_store
|
mock_rs_cls.return_value = mock_store
|
||||||
|
|
||||||
asyncio.run(_collect(engine.run_auto("auto1", {"date": "2026-01-01"})))
|
asyncio.run(_collect(engine.run_auto("auto1", {"date": "2026-01-01"})))
|
||||||
|
|
@ -716,6 +717,8 @@ class TestRunAutoTickerSource(unittest.TestCase):
|
||||||
|
|
||||||
mock_store = MagicMock()
|
mock_store = MagicMock()
|
||||||
mock_store.load_scan.return_value = {}
|
mock_store.load_scan.return_value = {}
|
||||||
|
mock_store.load_execution_result.return_value = None # prevent truthy skip of Phase 3
|
||||||
|
mock_store.load_pm_decision.return_value = None
|
||||||
mock_rs_cls.return_value = mock_store
|
mock_rs_cls.return_value = mock_store
|
||||||
|
|
||||||
asyncio.run(_collect(engine.run_auto("auto1", {"date": "2026-01-01"})))
|
asyncio.run(_collect(engine.run_auto("auto1", {"date": "2026-01-01"})))
|
||||||
|
|
@ -754,6 +757,8 @@ class TestRunAutoTickerSource(unittest.TestCase):
|
||||||
|
|
||||||
mock_store = MagicMock()
|
mock_store = MagicMock()
|
||||||
mock_store.load_scan.return_value = {}
|
mock_store.load_scan.return_value = {}
|
||||||
|
mock_store.load_execution_result.return_value = None # prevent truthy skip of Phase 3
|
||||||
|
mock_store.load_pm_decision.return_value = None
|
||||||
mock_rs_cls.return_value = mock_store
|
mock_rs_cls.return_value = mock_store
|
||||||
|
|
||||||
params = {"ticker": "GOOG", "portfolio_id": "my_portfolio", "date": "2026-01-01"}
|
params = {"ticker": "GOOG", "portfolio_id": "my_portfolio", "date": "2026-01-01"}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ class PortfolioManagerState(MessagesState):
|
||||||
analysis_date: str
|
analysis_date: str
|
||||||
prices: dict # ticker → price
|
prices: dict # ticker → price
|
||||||
scan_summary: dict # macro scan output from ScannerGraph
|
scan_summary: dict # macro scan output from ScannerGraph
|
||||||
|
ticker_analyses: dict # per-ticker analysis results keyed by ticker symbol
|
||||||
|
|
||||||
# Processing fields (string-serialised JSON — written by individual nodes)
|
# Processing fields (string-serialised JSON — written by individual nodes)
|
||||||
portfolio_data: Annotated[str, _last_value]
|
portfolio_data: Annotated[str, _last_value]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue