feat(tools): read_run_artifact — manifest-driven structured reads of run artifacts

Goal: give MCP clients token-cheap structured access to backtest run
artifacts. read_file returns raw CSV text and truncates mid-file; this
tool parses the artifact once and serves typed JSON pages a client can
page through or chart directly. Implements #1645.

Design: the run_card.json artifacts manifest (path/sha256/size_bytes per
file) is the trust anchor. Friendly aliases (equity, trades, metrics,
positions, target_positions, ohlcv:<CODE>, run_card) resolve by a fixed
mapping and work without a card; any other value is treated as a
run_dir-relative path accepted only when the manifest lists it, so a new
artifact kind becomes readable with zero code change. Manifest entries
are themselves validated — no absolute paths, no drive prefixes, no '..'
segments under either separator style — and every resolution is
re-checked with resolve() + is_relative_to(run_root), so a tampered card
can never point the reader outside the run directory. Three formats:
rows (lossless offset paging with honest truncated/next_offset),
downsample (equal-stride, first+last pinned), meta (columns/total_rows/
size_bytes, plus the manifest sha256 and manifest_size_bytes when
listed). Envelopes stay within a 120K-character budget by shrinking
whole records following _result_paging's proportional-shrink pattern —
never mid-record, never broken JSON.

Contract: purely additive read-only surface (75th MCP tool, 108th
registry tool); errors return {"ok": false, "error", "hint"} and success
envelopes carry no ok/status field.

Tests: 44 cases — manifest acceptance and refusal (unlisted relative
paths, '..' and absolute tampered entries, symlinked manifest entries,
non-served suffixes), aliases with absent/corrupt cards, meta sha256
exposure, 3821-row downsample endpoint pinning, lossless paging walk,
traversal refusals, coercion, projection, budget shrink/re-stride/
floor, clamping, exact envelope key sets. README/SKILL count pins
updated (74->75 MCP, 107->108 registry); test_readme_counts,
test_distribution_skill_manifest and the MCP smoke suite are green.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>
This commit is contained in:
shadowinlife committed 2026-10-01 01:17:41 +08:00
1 parent 18027a0c2b
commit faac0d2bac
11 files changed
+1253 -30

No files matched your search

+4 -4
View File
@@ -1522,7 +1522,7 @@ Posting `{}` schedules a template on its own suggested cadence with its declared
## 🔌 MCP Plugin
Vibe-Trading exposes 74 MCP tools for any MCP-compatible client. Runs as a stdio subprocess — no server setup needed. Core research tools work with zero API keys for HK/US/crypto; trading connector tools use the selected connector profile, and `run_swarm` needs an LLM key.
Vibe-Trading exposes 75 MCP tools for any MCP-compatible client. Runs as a stdio subprocess — no server setup needed. Core research tools work with zero API keys for HK/US/crypto; trading connector tools use the selected connector profile, and `run_swarm` needs an LLM key.
**Environment variables:** the client spawns the server itself, so a shell `export` never reaches it — set them in the client's `env` block. Generated backtest code is sandboxed to the allowed run roots, so writing results into a workspace of your own needs `VIBE_TRADING_ALLOWED_RUN_ROOTS`:
@@ -1591,7 +1591,7 @@ with `--host` / `--port`.
</details>
**MCP tools exposed (74):** `list_skills`, `load_skill`, `start_research_goal`, `get_research_goal`, `add_goal_evidence`, `update_research_goal_status`, `backtest`, `factor_analysis`, `alpha_zoo`, `alpha_bench`, `analyze_options`, `analyze_options_payoff`, `pattern_recognition`, `read_url`, `read_document`, `web_search`, `write_file`, `read_file`, `list_strategies`, `query_strategies`, `get_strategy_evidence`, `refresh_strategy_evidence`, `trading_connections`, `trading_select_connection`, `trading_check`, `trading_account`, `trading_positions`, `trading_orders`, `trading_quote`, `trading_history`, `list_swarm_presets`, `run_swarm`, `get_market_data`, `get_fund_flow`, `get_dragon_tiger`, `get_northbound_flow`, `get_margin_trading`, `get_block_trades`, `get_shareholder_count`, `get_lockup_expiry`, `get_sector_info`, `get_research_reports`, `get_stock_news`, `get_sec_filings`, `get_financial_statements`, `get_options_chain`, `get_stock_profile`, `screen_market`, `search_symbol`, `get_macro_series`, `iwencai_search`, `qveris_search`, `qveris_inspect`, `qveris_execute`, `get_institutional_holdings`, `etf_holdings`, `prediction_market`, `research_papers`, `get_swarm_status`, `get_run_result`, `list_runs`, `reap_stale_runs`, `retry_run`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `quantlib_call`, `cashflow_performance`, `orderbook_depth`, `sentiment`, `technical_indicators`, `get_fundamentals`.
**MCP tools exposed (75):** `list_skills`, `load_skill`, `start_research_goal`, `get_research_goal`, `add_goal_evidence`, `update_research_goal_status`, `backtest`, `factor_analysis`, `alpha_zoo`, `alpha_bench`, `analyze_options`, `analyze_options_payoff`, `pattern_recognition`, `read_url`, `read_document`, `web_search`, `write_file`, `read_file`, `read_run_artifact`, `list_strategies`, `query_strategies`, `get_strategy_evidence`, `refresh_strategy_evidence`, `trading_connections`, `trading_select_connection`, `trading_check`, `trading_account`, `trading_positions`, `trading_orders`, `trading_quote`, `trading_history`, `list_swarm_presets`, `run_swarm`, `get_market_data`, `get_fund_flow`, `get_dragon_tiger`, `get_northbound_flow`, `get_margin_trading`, `get_block_trades`, `get_shareholder_count`, `get_lockup_expiry`, `get_sector_info`, `get_research_reports`, `get_stock_news`, `get_sec_filings`, `get_financial_statements`, `get_options_chain`, `get_stock_profile`, `screen_market`, `search_symbol`, `get_macro_series`, `iwencai_search`, `qveris_search`, `qveris_inspect`, `qveris_execute`, `get_institutional_holdings`, `etf_holdings`, `prediction_market`, `research_papers`, `get_swarm_status`, `get_run_result`, `list_runs`, `reap_stale_runs`, `retry_run`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `quantlib_call`, `cashflow_performance`, `orderbook_depth`, `sentiment`, `technical_indicators`, `get_fundamentals`.
### SWARM external MCP tools
@@ -1976,7 +1976,7 @@ Vibe-Trading/
├── agent/ # Backend (Python)
│ ├── cli/ # CLI package — interactive TUI + subcommands
│ ├── api_server.py # FastAPI server — runs, sessions, upload, swarm, SSE
│ ├── mcp_server.py # MCP server — 74 tools for OpenClaw / Claude Desktop
│ ├── mcp_server.py # MCP server — 75 tools for OpenClaw / Claude Desktop
│ │
│ ├── src/
│ │ ├── agent/ # ReAct agent core
@@ -1991,7 +1991,7 @@ Vibe-Trading/
│ │ ├── memory/ # Cross-session persistent memory
│ │ │ └── persistent.py # file-based memory (~/.vibe-trading/memory/)
│ │ │
│ │ ├── tools/ # 107 auto-discovered agent tools
│ │ ├── tools/ # 108 auto-discovered agent tools
│ │ │ ├── backtest_tool.py # run backtests
│ │ │ ├── remember_tool.py # cross-session memory (save/recall/forget)
│ │ │ ├── skill_writer_tool.py # skill CRUD (save/patch/delete/file)
+4 -4
View File
@@ -1260,7 +1260,7 @@ curl -X POST http://localhost:8899/scheduled-runs/playbooks/premarket-brief \
## 🔌 MCP Plugin
يعرض Vibe-Trading 74 أداة MCP لأي عميل متوافق مع MCP. يعمل كعملية stdio فرعية، دون إعداد خادم. أدوات البحث الأساسية تعمل دون أي مفاتيح API لأسواق HK/US/crypto؛ وأدوات connector للتداول تستخدم profile الموصل المختار، ويحتاج `run_swarm` وحده إلى مفتاح LLM.
يعرض Vibe-Trading 75 أداة MCP لأي عميل متوافق مع MCP. يعمل كعملية stdio فرعية، دون إعداد خادم. أدوات البحث الأساسية تعمل دون أي مفاتيح API لأسواق HK/US/crypto؛ وأدوات connector للتداول تستخدم profile الموصل المختار، ويحتاج `run_swarm` وحده إلى مفتاح LLM.
**متغيرات البيئة:** العميل هو من يشغّل الخادم بنفسه، لذا لا يصل إليه `export` من الـ shell أبداً —— اضبطها في كتلة `env` الخاصة بالعميل. كود الاختبار الخلفي المولَّد محصور ضمن جذور التشغيل المسموح بها، لذا تحتاج إلى `VIBE_TRADING_ALLOWED_RUN_ROOTS` لكتابة النتائج في دليل عمل خاص بك:
@@ -1316,7 +1316,7 @@ vibe-trading-mcp --transport sse # legacy SSE (deprecated)
</details>
**أدوات MCP المعروضة (74):** `list_skills`, `load_skill`, `start_research_goal`, `get_research_goal`, `add_goal_evidence`, `update_research_goal_status`, `backtest`, `factor_analysis`, `alpha_zoo`, `alpha_bench`, `analyze_options`, `analyze_options_payoff`, `pattern_recognition`, `read_url`, `read_document`, `web_search`, `write_file`, `read_file`, `list_strategies`, `query_strategies`, `get_strategy_evidence`, `refresh_strategy_evidence`, `trading_connections`, `trading_select_connection`, `trading_check`, `trading_account`, `trading_positions`, `trading_orders`, `trading_quote`, `trading_history`, `list_swarm_presets`, `run_swarm`, `get_market_data`, `get_fund_flow`, `get_dragon_tiger`, `get_northbound_flow`, `get_margin_trading`, `get_block_trades`, `get_shareholder_count`, `get_lockup_expiry`, `get_sector_info`, `get_research_reports`, `get_stock_news`, `get_sec_filings`, `get_financial_statements`, `get_options_chain`, `get_stock_profile`, `screen_market`, `search_symbol`, `get_macro_series`, `iwencai_search`, `qveris_search`, `qveris_inspect`, `qveris_execute`, `get_institutional_holdings`, `etf_holdings`, `prediction_market`, `research_papers`, `get_swarm_status`, `get_run_result`, `list_runs`, `reap_stale_runs`, `retry_run`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `quantlib_call`, `cashflow_performance`, `orderbook_depth`, `sentiment`, `technical_indicators`, `get_fundamentals`.
**أدوات MCP المعروضة (75):** `list_skills`, `load_skill`, `start_research_goal`, `get_research_goal`, `add_goal_evidence`, `update_research_goal_status`, `backtest`, `factor_analysis`, `alpha_zoo`, `alpha_bench`, `analyze_options`, `analyze_options_payoff`, `pattern_recognition`, `read_url`, `read_document`, `web_search`, `write_file`, `read_file`, `read_run_artifact`, `list_strategies`, `query_strategies`, `get_strategy_evidence`, `refresh_strategy_evidence`, `trading_connections`, `trading_select_connection`, `trading_check`, `trading_account`, `trading_positions`, `trading_orders`, `trading_quote`, `trading_history`, `list_swarm_presets`, `run_swarm`, `get_market_data`, `get_fund_flow`, `get_dragon_tiger`, `get_northbound_flow`, `get_margin_trading`, `get_block_trades`, `get_shareholder_count`, `get_lockup_expiry`, `get_sector_info`, `get_research_reports`, `get_stock_news`, `get_sec_filings`, `get_financial_statements`, `get_options_chain`, `get_stock_profile`, `screen_market`, `search_symbol`, `get_macro_series`, `iwencai_search`, `qveris_search`, `qveris_inspect`, `qveris_execute`, `get_institutional_holdings`, `etf_holdings`, `prediction_market`, `research_papers`, `get_swarm_status`, `get_run_result`, `list_runs`, `reap_stale_runs`, `retry_run`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `quantlib_call`, `cashflow_performance`, `orderbook_depth`, `sentiment`, `technical_indicators`, `get_fundamentals`.
### أدوات MCP الخارجية في SWARM
@@ -1676,7 +1676,7 @@ Vibe-Trading/
├── agent/ # Backend (Python)
│ ├── cli/ # CLI package — interactive TUI + subcommands
│ ├── api_server.py # FastAPI server — runs, sessions, upload, swarm, SSE
│ ├── mcp_server.py # MCP server — 74 tools for OpenClaw / Claude Desktop
│ ├── mcp_server.py # MCP server — 75 tools for OpenClaw / Claude Desktop
│ │
│ ├── src/
│ │ ├── agent/ # ReAct agent core
@@ -1691,7 +1691,7 @@ Vibe-Trading/
│ │ ├── memory/ # Cross-session persistent memory
│ │ │ └── persistent.py # file-based memory (~/.vibe-trading/memory/)
│ │ │
│ │ ├── tools/ # 107 auto-discovered agent tools
│ │ ├── tools/ # 108 auto-discovered agent tools
│ │ │ ├── backtest_tool.py # run backtests
│ │ │ ├── remember_tool.py # cross-session memory (save/recall/forget)
│ │ │ ├── skill_writer_tool.py # skill CRUD (save/patch/delete/file)
+4 -4
View File
@@ -1320,7 +1320,7 @@ Enviar `{}` programa una plantilla con su propia cadencia sugerida y sus valores
## 🔌 MCP Plugin
Vibe-Trading expone 74 MCP tools para cualquier cliente compatible con MCP. Se ejecuta como un subproceso stdio — no requiere configuración de servidor. Las herramientas de investigación principales funcionan sin ninguna API key para HK/US/crypto; las herramientas del conector de trading usan el perfil de conector seleccionado, y `run_swarm` necesita una LLM key.
Vibe-Trading expone 75 MCP tools para cualquier cliente compatible con MCP. Se ejecuta como un subproceso stdio — no requiere configuración de servidor. Las herramientas de investigación principales funcionan sin ninguna API key para HK/US/crypto; las herramientas del conector de trading usan el perfil de conector seleccionado, y `run_swarm` necesita una LLM key.
**Variables de entorno:** el cliente lanza el servidor él mismo, así que un `export` de shell nunca le llega — configúralas en el bloque `env` del cliente. El código de backtest generado está confinado a los run roots permitidos, así que para escribir resultados en un workspace propio necesitas `VIBE_TRADING_ALLOWED_RUN_ROOTS`:
@@ -1391,7 +1391,7 @@ dirección de bind con `--host` / `--port`.
</details>
**MCP tools expuestas (74):** `list_skills`, `load_skill`, `start_research_goal`, `get_research_goal`, `add_goal_evidence`, `update_research_goal_status`, `backtest`, `factor_analysis`, `alpha_zoo`, `alpha_bench`, `analyze_options`, `analyze_options_payoff`, `pattern_recognition`, `read_url`, `read_document`, `web_search`, `write_file`, `read_file`, `list_strategies`, `query_strategies`, `get_strategy_evidence`, `refresh_strategy_evidence`, `trading_connections`, `trading_select_connection`, `trading_check`, `trading_account`, `trading_positions`, `trading_orders`, `trading_quote`, `trading_history`, `list_swarm_presets`, `run_swarm`, `get_market_data`, `get_fund_flow`, `get_dragon_tiger`, `get_northbound_flow`, `get_margin_trading`, `get_block_trades`, `get_shareholder_count`, `get_lockup_expiry`, `get_sector_info`, `get_research_reports`, `get_stock_news`, `get_sec_filings`, `get_financial_statements`, `get_options_chain`, `get_stock_profile`, `screen_market`, `search_symbol`, `get_macro_series`, `iwencai_search`, `qveris_search`, `qveris_inspect`, `qveris_execute`, `get_institutional_holdings`, `etf_holdings`, `prediction_market`, `research_papers`, `get_swarm_status`, `get_run_result`, `list_runs`, `reap_stale_runs`, `retry_run`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `quantlib_call`, `cashflow_performance`, `orderbook_depth`, `sentiment`, `technical_indicators`, `get_fundamentals`.
**MCP tools expuestas (75):** `list_skills`, `load_skill`, `start_research_goal`, `get_research_goal`, `add_goal_evidence`, `update_research_goal_status`, `backtest`, `factor_analysis`, `alpha_zoo`, `alpha_bench`, `analyze_options`, `analyze_options_payoff`, `pattern_recognition`, `read_url`, `read_document`, `web_search`, `write_file`, `read_file`, `read_run_artifact`, `list_strategies`, `query_strategies`, `get_strategy_evidence`, `refresh_strategy_evidence`, `trading_connections`, `trading_select_connection`, `trading_check`, `trading_account`, `trading_positions`, `trading_orders`, `trading_quote`, `trading_history`, `list_swarm_presets`, `run_swarm`, `get_market_data`, `get_fund_flow`, `get_dragon_tiger`, `get_northbound_flow`, `get_margin_trading`, `get_block_trades`, `get_shareholder_count`, `get_lockup_expiry`, `get_sector_info`, `get_research_reports`, `get_stock_news`, `get_sec_filings`, `get_financial_statements`, `get_options_chain`, `get_stock_profile`, `screen_market`, `search_symbol`, `get_macro_series`, `iwencai_search`, `qveris_search`, `qveris_inspect`, `qveris_execute`, `get_institutional_holdings`, `etf_holdings`, `prediction_market`, `research_papers`, `get_swarm_status`, `get_run_result`, `list_runs`, `reap_stale_runs`, `retry_run`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `quantlib_call`, `cashflow_performance`, `orderbook_depth`, `sentiment`, `technical_indicators`, `get_fundamentals`.
### SWARM external MCP tools
@@ -1764,7 +1764,7 @@ Vibe-Trading/
├── agent/ # Backend (Python)
│ ├── cli/ # Paquete CLI — TUI interactiva + subcomandos
│ ├── api_server.py # Servidor FastAPI — runs, sesiones, carga, swarm, SSE
│ ├── mcp_server.py # Servidor MCP — 74 herramientas para OpenClaw / Claude Desktop
│ ├── mcp_server.py # Servidor MCP — 75 herramientas para OpenClaw / Claude Desktop
│ │
│ ├── src/
│ │ ├── agent/ # Núcleo del agente ReAct
@@ -1779,7 +1779,7 @@ Vibe-Trading/
│ │ ├── memory/ # Memoria persistente entre sesiones
│ │ │ └── persistent.py # memoria basada en archivos (~/.vibe-trading/memory/)
│ │ │
│ │ ├── tools/ # 107 herramientas de agente autodescubiertas
│ │ ├── tools/ # 108 herramientas de agente autodescubiertas
│ │ │ ├── backtest_tool.py # ejecuta backtests
│ │ │ ├── remember_tool.py # memoria entre sesiones (save/recall/forget)
│ │ │ ├── skill_writer_tool.py # CRUD de skills (save/patch/delete/file)
+4 -4
View File
@@ -1566,7 +1566,7 @@ Mengirim `{}` akan menjadwalkan template memakai cadence yang disarankan beserta
<a id="-mcp-plugin"></a>
## 🔌 Plugin MCP
Vibe-Trading mengekspos 74 tool MCP untuk client yang kompatibel MCP. Berjalan sebagai subprocess stdio — tidak perlu setup server. Tool riset inti bekerja tanpa API key untuk HK/AS/crypto; tool connector trading menggunakan profil connector terpilih, dan `run_swarm` memerlukan LLM key.
Vibe-Trading mengekspos 75 tool MCP untuk client yang kompatibel MCP. Berjalan sebagai subprocess stdio — tidak perlu setup server. Tool riset inti bekerja tanpa API key untuk HK/AS/crypto; tool connector trading menggunakan profil connector terpilih, dan `run_swarm` memerlukan LLM key.
**Environment variable:** client menjalankan server sendiri, sehingga `export` dari shell tidak pernah sampai ke proses tersebut — atur di block `env` client. Kode backtest yang dihasilkan dibatasi ke root run yang diizinkan, jadi menulis hasil ke workspace Anda sendiri memerlukan `VIBE_TRADING_ALLOWED_RUN_ROOTS`:
@@ -1635,7 +1635,7 @@ dengan `--host` / `--port`.
</details>
**Tool MCP yang diekspos (74):** `list_skills`, `load_skill`, `start_research_goal`, `get_research_goal`, `add_goal_evidence`, `update_research_goal_status`, `backtest`, `factor_analysis`, `alpha_zoo`, `alpha_bench`, `analyze_options`, `analyze_options_payoff`, `pattern_recognition`, `read_url`, `read_document`, `web_search`, `write_file`, `read_file`, `list_strategies`, `query_strategies`, `get_strategy_evidence`, `refresh_strategy_evidence`, `trading_connections`, `trading_select_connection`, `trading_check`, `trading_account`, `trading_positions`, `trading_orders`, `trading_quote`, `trading_history`, `list_swarm_presets`, `run_swarm`, `get_market_data`, `get_fund_flow`, `get_dragon_tiger`, `get_northbound_flow`, `get_margin_trading`, `get_block_trades`, `get_shareholder_count`, `get_lockup_expiry`, `get_sector_info`, `get_research_reports`, `get_stock_news`, `get_sec_filings`, `get_financial_statements`, `get_options_chain`, `get_stock_profile`, `screen_market`, `search_symbol`, `get_macro_series`, `iwencai_search`, `qveris_search`, `qveris_inspect`, `qveris_execute`, `get_institutional_holdings`, `etf_holdings`, `prediction_market`, `research_papers`, `get_swarm_status`, `get_run_result`, `list_runs`, `reap_stale_runs`, `retry_run`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `quantlib_call`, `cashflow_performance`, `orderbook_depth`, `sentiment`, `technical_indicators`, `get_fundamentals`.
**Tool MCP yang diekspos (75):** `list_skills`, `load_skill`, `start_research_goal`, `get_research_goal`, `add_goal_evidence`, `update_research_goal_status`, `backtest`, `factor_analysis`, `alpha_zoo`, `alpha_bench`, `analyze_options`, `analyze_options_payoff`, `pattern_recognition`, `read_url`, `read_document`, `web_search`, `write_file`, `read_file`, `read_run_artifact`, `list_strategies`, `query_strategies`, `get_strategy_evidence`, `refresh_strategy_evidence`, `trading_connections`, `trading_select_connection`, `trading_check`, `trading_account`, `trading_positions`, `trading_orders`, `trading_quote`, `trading_history`, `list_swarm_presets`, `run_swarm`, `get_market_data`, `get_fund_flow`, `get_dragon_tiger`, `get_northbound_flow`, `get_margin_trading`, `get_block_trades`, `get_shareholder_count`, `get_lockup_expiry`, `get_sector_info`, `get_research_reports`, `get_stock_news`, `get_sec_filings`, `get_financial_statements`, `get_options_chain`, `get_stock_profile`, `screen_market`, `search_symbol`, `get_macro_series`, `iwencai_search`, `qveris_search`, `qveris_inspect`, `qveris_execute`, `get_institutional_holdings`, `etf_holdings`, `prediction_market`, `research_papers`, `get_swarm_status`, `get_run_result`, `list_runs`, `reap_stale_runs`, `retry_run`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `quantlib_call`, `cashflow_performance`, `orderbook_depth`, `sentiment`, `technical_indicators`, `get_fundamentals`.
### Tool MCP eksternal untuk SWARM
@@ -2009,7 +2009,7 @@ Vibe-Trading/
├── agent/ # Backend (Python)
│ ├── cli/ # CLI package — interactive TUI + subcommands
│ ├── api_server.py # FastAPI server — runs, sessions, upload, swarm, SSE
│ ├── mcp_server.py # MCP server — 74 tools for OpenClaw / Claude Desktop
│ ├── mcp_server.py # MCP server — 75 tools for OpenClaw / Claude Desktop
│ │
│ ├── src/
│ │ ├── agent/ # ReAct agent core
@@ -2024,7 +2024,7 @@ Vibe-Trading/
│ │ ├── memory/ # Cross-session persistent memory
│ │ │ └── persistent.py # file-based memory (~/.vibe-trading/memory/)
│ │ │
│ │ ├── tools/ # 107 auto-discovered agent tools
│ │ ├── tools/ # 108 auto-discovered agent tools
│ │ │ ├── backtest_tool.py # run backtests
│ │ │ ├── remember_tool.py # cross-session memory (save/recall/forget)
│ │ │ ├── skill_writer_tool.py # skill CRUD (save/patch/delete/file)
+4 -4
View File
@@ -1262,7 +1262,7 @@ curl -X POST http://localhost:8899/scheduled-runs/playbooks/premarket-brief \
## 🔌 MCP Plugin
Vibe-Trading は MCP-compatible client 向けに 74 MCP tools を公開します。stdio subprocess として動作し、server setup は不要です。Core research tools は HK/US/crypto で API key なしに動作し、trading connector tools は選択中の connector profile を使います。LLM key が必要なのは `run_swarm` のみです。
Vibe-Trading は MCP-compatible client 向けに 75 MCP tools を公開します。stdio subprocess として動作し、server setup は不要です。Core research tools は HK/US/crypto で API key なしに動作し、trading connector tools は選択中の connector profile を使います。LLM key が必要なのは `run_swarm` のみです。
**環境変数:** server は client 自身が spawn するため、shell の `export` は届きません —— client の `env` block に設定してください。生成された backtest code は allowed run roots 内に制限されるので、結果を自分の作業 directory に書き出すには `VIBE_TRADING_ALLOWED_RUN_ROOTS` が必要です:
@@ -1318,7 +1318,7 @@ vibe-trading-mcp --transport sse # legacy SSE (deprecated)
</details>
**公開される MCP tools(74):** `list_skills`, `load_skill`, `start_research_goal`, `get_research_goal`, `add_goal_evidence`, `update_research_goal_status`, `backtest`, `factor_analysis`, `alpha_zoo`, `alpha_bench`, `analyze_options`, `analyze_options_payoff`, `pattern_recognition`, `read_url`, `read_document`, `web_search`, `write_file`, `read_file`, `list_strategies`, `query_strategies`, `get_strategy_evidence`, `refresh_strategy_evidence`, `list_swarm_presets`, `run_swarm`, `get_market_data`, `get_fund_flow`, `get_dragon_tiger`, `get_northbound_flow`, `get_margin_trading`, `get_block_trades`, `get_shareholder_count`, `get_lockup_expiry`, `get_sector_info`, `get_research_reports`, `get_stock_news`, `get_sec_filings`, `get_financial_statements`, `get_options_chain`, `get_stock_profile`, `screen_market`, `search_symbol`, `get_macro_series`, `iwencai_search`, `qveris_search`, `qveris_inspect`, `qveris_execute`, `get_institutional_holdings`, `etf_holdings`, `prediction_market`, `research_papers`, `get_swarm_status`, `get_run_result`, `list_runs`, `reap_stale_runs`, `retry_run`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `trading_connections`, `trading_select_connection`, `trading_check`, `trading_account`, `trading_positions`, `trading_orders`, `trading_quote`, `trading_history`, `quantlib_call`, `cashflow_performance`, `orderbook_depth`, `sentiment`, `technical_indicators`, `get_fundamentals`.
**公開される MCP tools(75):** `list_skills`, `load_skill`, `start_research_goal`, `get_research_goal`, `add_goal_evidence`, `update_research_goal_status`, `backtest`, `factor_analysis`, `alpha_zoo`, `alpha_bench`, `analyze_options`, `analyze_options_payoff`, `pattern_recognition`, `read_url`, `read_document`, `web_search`, `write_file`, `read_file`, `read_run_artifact`, `list_strategies`, `query_strategies`, `get_strategy_evidence`, `refresh_strategy_evidence`, `list_swarm_presets`, `run_swarm`, `get_market_data`, `get_fund_flow`, `get_dragon_tiger`, `get_northbound_flow`, `get_margin_trading`, `get_block_trades`, `get_shareholder_count`, `get_lockup_expiry`, `get_sector_info`, `get_research_reports`, `get_stock_news`, `get_sec_filings`, `get_financial_statements`, `get_options_chain`, `get_stock_profile`, `screen_market`, `search_symbol`, `get_macro_series`, `iwencai_search`, `qveris_search`, `qveris_inspect`, `qveris_execute`, `get_institutional_holdings`, `etf_holdings`, `prediction_market`, `research_papers`, `get_swarm_status`, `get_run_result`, `list_runs`, `reap_stale_runs`, `retry_run`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `trading_connections`, `trading_select_connection`, `trading_check`, `trading_account`, `trading_positions`, `trading_orders`, `trading_quote`, `trading_history`, `quantlib_call`, `cashflow_performance`, `orderbook_depth`, `sentiment`, `technical_indicators`, `get_fundamentals`.
### SWARM の外部 MCP tools
@@ -1684,7 +1684,7 @@ Vibe-Trading/
├── agent/ # バックエンド (Python)
│ ├── cli/ # CLI パッケージ — インタラクティブ TUI + サブコマンド
│ ├── api_server.py # FastAPI サーバー — runs、sessions、upload、swarm、SSE
│ ├── mcp_server.py # MCP サーバー — OpenClaw / Claude Desktop 向け 74 tools
│ ├── mcp_server.py # MCP サーバー — OpenClaw / Claude Desktop 向け 75 tools
│ │
│ ├── src/
│ │ ├── agent/ # ReAct エージェントコア
@@ -1699,7 +1699,7 @@ Vibe-Trading/
│ │ ├── memory/ # クロスセッション永続メモリ
│ │ │ └── persistent.py # ファイルベースメモリ (~/.vibe-trading/memory/)
│ │ │
│ │ ├── tools/ # 107 個の自動検出エージェントツール
│ │ ├── tools/ # 108 個の自動検出エージェントツール
│ │ │ ├── backtest_tool.py # バックテスト実行
│ │ │ ├── remember_tool.py # クロスセッションメモリ (save/recall/forget)
│ │ │ ├── skill_writer_tool.py # skill CRUD (save/patch/delete/file)
+4 -4
View File
@@ -1263,7 +1263,7 @@ curl -X POST http://localhost:8899/scheduled-runs/playbooks/premarket-brief \
## 🔌 MCP Plugin
Vibe-Trading은 모든 MCP-compatible client를 위해 74개 MCP tools를 제공합니다. stdio subprocess로 실행되므로 server setup이 필요 없습니다. 핵심 research tools는 HK/US/crypto에서 API key 없이 작동하고, trading connector tools는 선택된 connector profile을 사용하며, `run_swarm`만 LLM key가 필요합니다.
Vibe-Trading은 모든 MCP-compatible client를 위해 75개 MCP tools를 제공합니다. stdio subprocess로 실행되므로 server setup이 필요 없습니다. 핵심 research tools는 HK/US/crypto에서 API key 없이 작동하고, trading connector tools는 선택된 connector profile을 사용하며, `run_swarm`만 LLM key가 필요합니다.
**환경 변수:** server는 client가 직접 spawn하므로 shell의 `export`는 전달되지 않습니다 —— client의 `env` block에 설정하세요. 생성된 backtest code는 allowed run roots 안으로 제한되므로, 결과를 자신의 작업 directory에 쓰려면 `VIBE_TRADING_ALLOWED_RUN_ROOTS`가 필요합니다:
@@ -1319,7 +1319,7 @@ vibe-trading-mcp --transport sse # legacy SSE (deprecated)
</details>
**노출되는 MCP tools(74):** `list_skills`, `load_skill`, `start_research_goal`, `get_research_goal`, `add_goal_evidence`, `update_research_goal_status`, `backtest`, `factor_analysis`, `alpha_zoo`, `alpha_bench`, `analyze_options`, `analyze_options_payoff`, `pattern_recognition`, `read_url`, `read_document`, `web_search`, `write_file`, `read_file`, `list_strategies`, `query_strategies`, `get_strategy_evidence`, `refresh_strategy_evidence`, `trading_connections`, `trading_select_connection`, `trading_check`, `trading_account`, `trading_positions`, `trading_orders`, `trading_quote`, `trading_history`, `list_swarm_presets`, `run_swarm`, `get_market_data`, `get_fund_flow`, `get_dragon_tiger`, `get_northbound_flow`, `get_margin_trading`, `get_block_trades`, `get_shareholder_count`, `get_lockup_expiry`, `get_sector_info`, `get_research_reports`, `get_stock_news`, `get_sec_filings`, `get_financial_statements`, `get_options_chain`, `get_stock_profile`, `screen_market`, `search_symbol`, `get_macro_series`, `iwencai_search`, `qveris_search`, `qveris_inspect`, `qveris_execute`, `get_institutional_holdings`, `etf_holdings`, `prediction_market`, `research_papers`, `get_swarm_status`, `get_run_result`, `list_runs`, `reap_stale_runs`, `retry_run`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `quantlib_call`, `cashflow_performance`, `orderbook_depth`, `sentiment`, `technical_indicators`, `get_fundamentals`.
**노출되는 MCP tools(75):** `list_skills`, `load_skill`, `start_research_goal`, `get_research_goal`, `add_goal_evidence`, `update_research_goal_status`, `backtest`, `factor_analysis`, `alpha_zoo`, `alpha_bench`, `analyze_options`, `analyze_options_payoff`, `pattern_recognition`, `read_url`, `read_document`, `web_search`, `write_file`, `read_file`, `read_run_artifact`, `list_strategies`, `query_strategies`, `get_strategy_evidence`, `refresh_strategy_evidence`, `trading_connections`, `trading_select_connection`, `trading_check`, `trading_account`, `trading_positions`, `trading_orders`, `trading_quote`, `trading_history`, `list_swarm_presets`, `run_swarm`, `get_market_data`, `get_fund_flow`, `get_dragon_tiger`, `get_northbound_flow`, `get_margin_trading`, `get_block_trades`, `get_shareholder_count`, `get_lockup_expiry`, `get_sector_info`, `get_research_reports`, `get_stock_news`, `get_sec_filings`, `get_financial_statements`, `get_options_chain`, `get_stock_profile`, `screen_market`, `search_symbol`, `get_macro_series`, `iwencai_search`, `qveris_search`, `qveris_inspect`, `qveris_execute`, `get_institutional_holdings`, `etf_holdings`, `prediction_market`, `research_papers`, `get_swarm_status`, `get_run_result`, `list_runs`, `reap_stale_runs`, `retry_run`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `quantlib_call`, `cashflow_performance`, `orderbook_depth`, `sentiment`, `technical_indicators`, `get_fundamentals`.
### SWARM 외부 MCP tools
@@ -1681,7 +1681,7 @@ Vibe-Trading/
├── agent/ # Backend (Python)
│ ├── cli/ # CLI package — interactive TUI + subcommands
│ ├── api_server.py # FastAPI server — runs, sessions, upload, swarm, SSE
│ ├── mcp_server.py # MCP server — 74 tools for OpenClaw / Claude Desktop
│ ├── mcp_server.py # MCP server — 75 tools for OpenClaw / Claude Desktop
│ │
│ ├── src/
│ │ ├── agent/ # ReAct agent core
@@ -1696,7 +1696,7 @@ Vibe-Trading/
│ │ ├── memory/ # Cross-session persistent memory
│ │ │ └── persistent.py # file-based memory (~/.vibe-trading/memory/)
│ │ │
│ │ ├── tools/ # 107 auto-discovered agent tools
│ │ ├── tools/ # 108 auto-discovered agent tools
│ │ │ ├── backtest_tool.py # run backtests
│ │ │ ├── remember_tool.py # cross-session memory (save/recall/forget)
│ │ │ ├── skill_writer_tool.py # skill CRUD (save/patch/delete/file)
+4 -4
View File
@@ -1267,7 +1267,7 @@ POST `{}` 即按模板自身的建议节奏和默认变量排程。渲染后的
## 🔌 MCP Plugin
Vibe-Trading 为任何 MCP-compatible client 暴露 74 个 MCP tools。它作为 stdio subprocess 运行,无需 server setup。核心 research tools 对港股/美股/加密零 API key 可用;trading connector tools 使用当前选择的 connector profile;只有 `run_swarm` 需要 LLM key。
Vibe-Trading 为任何 MCP-compatible client 暴露 75 个 MCP tools。它作为 stdio subprocess 运行,无需 server setup。核心 research tools 对港股/美股/加密零 API key 可用;trading connector tools 使用当前选择的 connector profile;只有 `run_swarm` 需要 LLM key。
**环境变量:** server 由 client 自己 spawn,因此在 shell 里 `export` 永远传不进去 —— 请写在 client 的 `env` 块里。生成的回测代码被限制在 allowed run roots 内,所以要把结果写进你自己的工作目录,需要 `VIBE_TRADING_ALLOWED_RUN_ROOTS`:
@@ -1323,7 +1323,7 @@ vibe-trading-mcp --transport sse # legacy SSE (deprecated)
</details>
**暴露的 MCP tools(74):** `list_skills`, `load_skill`, `start_research_goal`, `get_research_goal`, `add_goal_evidence`, `update_research_goal_status`, `backtest`, `factor_analysis`, `alpha_zoo`, `alpha_bench`, `analyze_options`, `analyze_options_payoff`, `pattern_recognition`, `read_url`, `read_document`, `web_search`, `write_file`, `read_file`, `list_strategies`, `query_strategies`, `get_strategy_evidence`, `refresh_strategy_evidence`, `list_swarm_presets`, `run_swarm`, `get_market_data`, `get_fund_flow`, `get_dragon_tiger`, `get_northbound_flow`, `get_margin_trading`, `get_block_trades`, `get_shareholder_count`, `get_lockup_expiry`, `get_sector_info`, `get_research_reports`, `get_stock_news`, `get_sec_filings`, `get_financial_statements`, `get_options_chain`, `get_stock_profile`, `screen_market`, `search_symbol`, `get_macro_series`, `iwencai_search`, `qveris_search`, `qveris_inspect`, `qveris_execute`, `get_institutional_holdings`, `etf_holdings`, `prediction_market`, `research_papers`, `get_swarm_status`, `get_run_result`, `list_runs`, `reap_stale_runs`, `retry_run`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `trading_connections`, `trading_select_connection`, `trading_check`, `trading_account`, `trading_positions`, `trading_orders`, `trading_quote`, `trading_history`, `quantlib_call`, `cashflow_performance`, `orderbook_depth`, `sentiment`, `technical_indicators`, `get_fundamentals`.
**暴露的 MCP tools(75):** `list_skills`, `load_skill`, `start_research_goal`, `get_research_goal`, `add_goal_evidence`, `update_research_goal_status`, `backtest`, `factor_analysis`, `alpha_zoo`, `alpha_bench`, `analyze_options`, `analyze_options_payoff`, `pattern_recognition`, `read_url`, `read_document`, `web_search`, `write_file`, `read_file`, `read_run_artifact`, `list_strategies`, `query_strategies`, `get_strategy_evidence`, `refresh_strategy_evidence`, `list_swarm_presets`, `run_swarm`, `get_market_data`, `get_fund_flow`, `get_dragon_tiger`, `get_northbound_flow`, `get_margin_trading`, `get_block_trades`, `get_shareholder_count`, `get_lockup_expiry`, `get_sector_info`, `get_research_reports`, `get_stock_news`, `get_sec_filings`, `get_financial_statements`, `get_options_chain`, `get_stock_profile`, `screen_market`, `search_symbol`, `get_macro_series`, `iwencai_search`, `qveris_search`, `qveris_inspect`, `qveris_execute`, `get_institutional_holdings`, `etf_holdings`, `prediction_market`, `research_papers`, `get_swarm_status`, `get_run_result`, `list_runs`, `reap_stale_runs`, `retry_run`, `analyze_trade_journal`, `extract_shadow_strategy`, `run_shadow_backtest`, `render_shadow_report`, `scan_shadow_signals`, `trading_connections`, `trading_select_connection`, `trading_check`, `trading_account`, `trading_positions`, `trading_orders`, `trading_quote`, `trading_history`, `quantlib_call`, `cashflow_performance`, `orderbook_depth`, `sentiment`, `technical_indicators`, `get_fundamentals`.
### SWARM 的外部 MCP tools
@@ -1679,7 +1679,7 @@ Vibe-Trading/
├── agent/ # 后端(Python)
│ ├── cli/ # CLI 包 —— 交互式 TUI + 子命令
│ ├── api_server.py # FastAPI server —— runs、sessions、upload、swarm、SSE
│ ├── mcp_server.py # MCP server —— 74 个工具,面向 OpenClaw / Claude Desktop
│ ├── mcp_server.py # MCP server —— 75 个工具,面向 OpenClaw / Claude Desktop
│ │
│ ├── src/
│ │ ├── agent/ # ReAct agent 内核
@@ -1694,7 +1694,7 @@ Vibe-Trading/
│ │ ├── memory/ # 跨 session 持久记忆
│ │ │ └── persistent.py # 基于文件的记忆(~/.vibe-trading/memory/)
│ │ │
│ │ ├── tools/ # 107 个自动发现的 agent 工具
│ │ ├── tools/ # 108 个自动发现的 agent 工具
│ │ │ ├── backtest_tool.py # 运行回测
│ │ │ ├── remember_tool.py # 跨 session 记忆(save/recall/forget)
│ │ │ ├── skill_writer_tool.py # skill CRUD(save/patch/delete/file)
+2 -1
View File
@@ -133,7 +133,7 @@ Comprehensive knowledge base covering:
Use `load_skill(name)` to access full methodology docs with code templates.
## Available MCP Tools (74)
## Available MCP Tools (75)
| Tool | Description | API Key |
|------|-------------|---------|
@@ -177,6 +177,7 @@ Use `load_skill(name)` to access full methodology docs with code templates.
| `read_document` | Extract text from PDF/DOCX/XLSX/PPTX/images | None |
| `write_file` | Write files (config, strategy code) | None |
| `read_file` | Read file contents | None |
| `read_run_artifact` | Structured run-artifact reads (rows / downsample / meta) | None |
| `list_strategies` | Browse discoverable strategies (Alpha Zoo + SDM store) | None |
| `query_strategies` | Evidence-gated query: regime / Sharpe / quality / cost filters | None |
| `get_strategy_evidence` | Per-regime evidence rows for one strategy | None |
+60 -1
View File
@@ -6,7 +6,7 @@ Zero API key required for HK/US/crypto research markets (yfinance, OKX,
AKShare are free). Trading connector tools are profile-scoped and require the
selected connector's own local app or OAuth setup.
Surfaces 74 tools: skills, research goals, strategy discovery,
Surfaces 75 tools: skills, research goals, strategy discovery,
backtest/factor/options/pattern
analysis, market data, fundamentals & capital-flow & news & discovery
(get_fund_flow / get_dragon_tiger / get_northbound_flow / get_margin_trading /
@@ -1190,6 +1190,65 @@ def read_file(path: str) -> str:
return registry.execute("read_file", {"path": path})
@mcp.tool
def read_run_artifact(
run_dir: str,
artifact: str,
format: str = "rows",
offset: int = 0,
max_rows: int = 1000,
columns: _lenient_str_list_opt = None,
) -> str:
"""Read a backtest run artifact as structured JSON instead of raw CSV text.
Artifacts: friendly aliases ``equity``, ``trades``, ``metrics``,
``positions``, ``target_positions`` (CSVs under ``<run_dir>/artifacts/``),
``ohlcv:<CODE>`` (e.g. ``ohlcv:600519.SH``) and ``run_card`` — plus any
run_dir-relative ``.csv``/``.json`` path listed in the run's
``run_card.json`` artifacts manifest (e.g. ``artifacts/validation.json``).
The manifest is the trust anchor: a relative path it does not list is
refused, and a manifest entry pointing outside the run directory is never
followed.
Formats:
- ``rows``: offset paging over whole records; follow ``next_offset`` until
``truncated`` is false to walk a file losslessly.
- ``downsample``: equal-stride sample of at most ``max_rows`` points with
the first and last row always pinned — one call feeds a chart.
- ``meta``: columns / total_rows / size_bytes, plus the manifest's
``sha256`` and ``manifest_size_bytes`` for a manifest-listed artifact.
Cells arrive typed (int / float / null / string). Every envelope is
serialized within a bounded byte budget (~120K characters): an oversized
page shrinks to whole records with honest ``truncated`` / ``next_offset``
metadata rather than cutting mid-JSON. Errors return
``{"ok": false, "error", "hint"}``.
Args:
run_dir: Run directory a backtest/tool call returned.
artifact: Alias name or manifest-listed relative path (see above);
anything else — including path traversal — is refused.
format: "rows" (default), "downsample" or "meta".
offset: First row index for "rows" mode (default 0).
max_rows: Page/sample size, clamped to [1, 5000] (default 1000).
columns: Optional column projection; unknown names are refused with
the valid list.
"""
registry = _get_registry()
return registry.execute(
"read_run_artifact",
{
"run_dir": run_dir,
"artifact": artifact,
"format": format,
"offset": offset,
"max_rows": max_rows,
"columns": columns,
},
)
# ---------------------------------------------------------------------------
# Strategy discovery tools
# ---------------------------------------------------------------------------
+598
View File
@@ -0,0 +1,598 @@
"""Run-artifact reader: chunked, downsampled, structured JSON over run files.
A backtest run writes CSV artifacts (``equity.csv``, ``trades.csv``, ...) plus
a ``run_card.json`` sidecar whose ``artifacts`` manifest lists every file the
run produced with its sha256 and size. The generic ``read_file`` tool returns
raw text, burning LLM tokens on comma-separated noise and truncating mid-file.
This tool parses the artifact once and serves structured JSON pages:
* ``rows`` — offset paging over whole records with an honest ``truncated`` /
``next_offset`` contract, so a walk reassembles the file losslessly.
* ``downsample`` — equal-stride sampling to at most ``max_rows`` points, first
and last row always pinned; ``offset`` is ignored (the sample spans all).
* ``meta`` — columns / total_rows / size_bytes, plus the manifest's ``sha256``
and ``manifest_size_bytes`` for a manifest-listed artifact.
Resolution is manifest-driven: friendly aliases (``equity``, ``ohlcv:<CODE>``,
``run_card``) resolve by a fixed mapping, and any other value is treated as a
run_dir-relative path accepted ONLY when the run_card manifest lists it — a
new artifact kind becomes readable with zero code change, while a tampered
card can never point the reader outside the run directory (every entry is
validated, every resolution re-checked inside ``run_root``). Serialized
envelopes stay under :data:`_BYTE_BUDGET` by shrinking whole records (never
mid-record, never broken JSON), following ``src/tools/_result_paging.py``.
"""
from __future__ import annotations
import csv
import json
import math
import re
from pathlib import Path
from typing import Any
from src.agent.tools import BaseTool
from src.tools.path_utils import safe_run_dir
# Serialized-envelope budget in characters. MCP clients truncate large tool
# results (50KB is a common default; raised harness limits reach 256KB); 120K
# keeps a full page intact under the raised limit while leaving framing room.
_BYTE_BUDGET = 120_000
# Friendly CSV aliases: name -> path relative to run_dir.
_CSV_ARTIFACTS: dict[str, str] = {
"equity": "artifacts/equity.csv",
"trades": "artifacts/trades.csv",
"metrics": "artifacts/metrics.csv",
"positions": "artifacts/positions.csv",
"target_positions": "artifacts/target_positions.csv",
}
_RUN_CARD_ALIAS = "run_card"
_RUN_CARD_PATH = "run_card.json"
_OHLCV_PREFIX = "ohlcv:"
_VALID_FORMATS = ("rows", "downsample", "meta")
_MAX_ROWS_CEILING = 5000
_SERVED_SUFFIXES = (".csv", ".json")
_WINDOWS_DRIVE = re.compile(r"^[A-Za-z]:")
_ALIAS_HINT = (
"artifact must be one of: "
+ ", ".join([*_CSV_ARTIFACTS, f"{_OHLCV_PREFIX}<CODE>", _RUN_CARD_ALIAS])
+ ", or a run_dir-relative .csv/.json path listed in the run_card.json "
"artifacts manifest (e.g. artifacts/validation.json)."
)
_MANIFEST_HINT = (
"Pass a friendly alias, or a run_dir-relative path the run's run_card.json "
"artifacts manifest lists; a run without a readable card only serves aliases."
)
class _ArtifactError(ValueError):
"""Artifact-resolution failure carrying its own user-facing hint."""
def __init__(self, error: str, hint: str) -> None:
super().__init__(error)
self.hint = hint
def _error(error: str, hint: str) -> str:
"""Serialize the error envelope: ``{"ok": false, "error", "hint"}``."""
return json.dumps({"ok": False, "error": error, "hint": hint}, ensure_ascii=False)
def _validate_ohlcv_code(code: str) -> None:
"""Reject an ``ohlcv:<CODE>`` suffix that could escape the artifacts dir.
Raises:
ValueError: If the code is empty or carries path separators, parent
references, a leading dot, or a null byte.
"""
if not code or not code.strip():
raise ValueError("ohlcv: requires a non-empty symbol code")
if "/" in code or "\\" in code or "\x00" in code:
raise ValueError(f"ohlcv code {code!r} must not contain path separators")
if code.startswith("."):
raise ValueError(f"ohlcv code {code!r} must not start with a dot")
if ".." in code:
raise ValueError(f"ohlcv code {code!r} must not contain '..'")
def _safe_relative_resolve(run_root: Path, relative: str) -> Path | None:
"""Resolve a manifest-relative path, or return ``None`` if it is unsafe.
Rejects empty paths, null bytes, absolute paths (POSIX or Windows
shaped), any ``..`` segment under either separator style, and anything
whose resolution leaves ``run_root`` (e.g. through a symlink) — so a
tampered manifest entry is never followed.
"""
if not relative or "\x00" in relative:
return None
if relative.startswith(("/", "\\")) or _WINDOWS_DRIVE.match(relative):
return None
if any(segment == ".." for segment in relative.replace("\\", "/").split("/")):
return None
if Path(relative).is_absolute():
return None
resolved = (run_root / relative).resolve()
return resolved if resolved.is_relative_to(run_root) else None
def _load_artifact_manifest(run_root: Path) -> dict[str, tuple[Path, dict[str, Any]]]:
"""Load and validate the ``run_card.json`` artifacts manifest.
The manifest is the trust anchor for non-alias artifact paths: an entry is
followed only after its own validation (see :func:`_safe_relative_resolve`),
so a missing, corrupt, or tampered card degrades to "no manifest" — aliases
keep working against the disk, and relative paths are refused.
Returns:
Mapping of manifest path string to ``(resolved_path, entry)``; empty
when the card carries no well-formed ``artifacts`` list.
"""
try:
card = json.loads((run_root / _RUN_CARD_PATH).read_text(encoding="utf-8"))
except (OSError, ValueError, UnicodeDecodeError):
return {}
entries = card.get("artifacts") if isinstance(card, dict) else None
if not isinstance(entries, list):
return {}
manifest: dict[str, tuple[Path, dict[str, Any]]] = {}
for entry in entries:
if not isinstance(entry, dict):
continue
relative = entry.get("path")
if not isinstance(relative, str):
continue
resolved = _safe_relative_resolve(run_root, relative)
if resolved is not None:
manifest[relative] = (resolved, entry)
return manifest
def _resolve_artifact(
run_root: Path,
artifact: str,
manifest: dict[str, tuple[Path, dict[str, Any]]],
) -> tuple[Path, dict[str, Any] | None]:
"""Map an alias or manifest-listed path to a file inside ``run_root``.
Friendly aliases resolve by a fixed mapping and need no card; any other
value is accepted only when the validated manifest lists it and it is a
``.csv``/``.json`` file. Every branch re-verifies containment.
Returns:
``(resolved_path, manifest_entry)``; the entry is ``None`` when the
artifact is not manifest-listed.
Raises:
_ArtifactError: If the name is neither a valid alias nor a
manifest-listed served-suffix path, or it escapes ``run_root``.
"""
if artifact in _CSV_ARTIFACTS:
relative = _CSV_ARTIFACTS[artifact]
elif artifact == _RUN_CARD_ALIAS:
relative = _RUN_CARD_PATH
elif artifact.startswith(_OHLCV_PREFIX):
code = artifact[len(_OHLCV_PREFIX) :]
try:
_validate_ohlcv_code(code)
except ValueError as exc:
raise _ArtifactError(str(exc), _ALIAS_HINT) from exc
relative = f"artifacts/ohlcv_{code}.csv"
else:
listed = manifest.get(artifact)
if listed is None:
raise _ArtifactError(
f"artifact {artifact!r} is not a known alias and is not "
"listed in the run_card.json artifacts manifest",
_MANIFEST_HINT,
)
path, entry = listed
if path.suffix.lower() not in _SERVED_SUFFIXES:
raise _ArtifactError(
f"manifest artifact {artifact!r} is not a .csv or .json file",
"read_run_artifact serves CSV and JSON artifacts; use "
"read_file for other files.",
)
return path, entry
resolved = (run_root / relative).resolve()
if not resolved.is_relative_to(run_root):
raise _ArtifactError(
f"artifact {artifact!r} resolves outside run_dir", _ALIAS_HINT
)
listed = manifest.get(relative)
return resolved, listed[1] if listed else None
def _coerce_value(raw: str) -> Any:
"""Coerce one CSV cell to int / float / None / str.
Integer-looking text becomes ``int``, finite float-looking text becomes
``float``, empty or non-finite values (``nan``/``inf`` in any spelling
``float()`` accepts) become ``None``, anything else stays a string.
Underscored numerics (``1_000``) stay strings because ``int()``/``float()``
would silently accept them.
"""
text = raw.strip()
if not text:
return None
if "_" not in text:
try:
return int(text)
except ValueError:
pass
try:
value = float(text)
except ValueError:
return raw
return value if math.isfinite(value) else None
return raw
def _read_csv(path: Path) -> tuple[list[str], list[list[Any]]]:
"""Parse a CSV artifact into columns plus coerced, header-aligned rows.
Each row has exactly ``len(columns)`` cells (short rows padded with
``None``, extra trailing cells dropped); an empty file yields ``([], [])``.
"""
with path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.reader(handle)
try:
header = next(reader)
except StopIteration:
return [], []
width = len(header)
rows: list[list[Any]] = []
for record in reader:
if not record:
continue
aligned = record[:width] + [""] * (width - len(record))
rows.append([_coerce_value(cell) for cell in aligned])
return header, rows
def _project(
columns: list[str], rows: list[list[Any]], requested: list[str]
) -> tuple[list[str], list[list[Any]]]:
"""Project rows onto the requested columns, preserving request order.
Raises:
ValueError: If a requested name is not in ``columns``; the message
lists the valid columns.
"""
unknown = [name for name in requested if name not in columns]
if unknown:
raise ValueError(f"unknown column(s) {unknown}; valid columns: {columns}")
indices = [columns.index(name) for name in requested]
return list(requested), [[row[i] for i in indices] for row in rows]
def _downsample_indices(total: int, max_points: int) -> tuple[list[int], int]:
"""Pick equal-stride sample indices with the first and last row pinned.
When ``total <= max_points`` every index is returned with stride 1.
"""
if total <= 0:
return [], 1
if max_points >= total:
return list(range(total)), 1
if max_points < 2:
# Both endpoints outrank the point cap: a one-point "sample" could
# never pin first AND last, so the floor is two.
return [0, total - 1], max(1, total - 1)
stride = -(-(total - 1) // (max_points - 1)) # ceil division
indices = list(range(0, total, stride))
if indices[-1] != total - 1:
indices.append(total - 1)
return indices, stride
def _serialize(envelope: dict[str, Any]) -> str:
"""Serialize an envelope the way every response in this tool does."""
return json.dumps(envelope, ensure_ascii=False)
def _fit_rows_payload(
base: dict[str, Any],
page: list[list[Any]],
offset: int,
total_rows: int,
budget: int,
) -> str:
"""Serialize a rows-mode page, shrinking whole rows until it fits budget.
Mirrors ``_result_paging.fit_records``: shrink proportionally to the
overflow (then one further) so wide records still converge, keeping
``returned_rows`` / ``truncated`` / ``next_offset`` honest — a shrunk page
always reports ``truncated: true`` with the resume offset, so the tail is
never dropped silently. The payload stays valid JSON even when a single
row alone exceeds the budget.
"""
count = len(page)
while True:
rows = page[:count]
returned = len(rows)
truncated = offset + returned < total_rows
envelope = {
**base,
"rows": rows,
"returned_rows": returned,
"truncated": truncated,
"next_offset": offset + returned if truncated else None,
}
payload = _serialize(envelope)
if len(payload) <= budget or count <= 1:
return payload
count = max(1, min(count - 1, int(count * budget / len(payload))))
def _fit_downsample_payload(
base: dict[str, Any], rows: list[list[Any]], max_points: int, budget: int
) -> str:
"""Serialize a downsample envelope, re-striding to fewer points if needed.
``base`` is the envelope skeleton without ``rows`` / ``downsample``
fields; ``rows`` are the parsed (and projected) source rows. The first
and last source row stay pinned at every sample size.
"""
total = len(rows)
target = max_points
while True:
indices, stride = _downsample_indices(total, target)
sample = [rows[i] for i in indices]
envelope = {
**base,
"rows": sample,
"returned_rows": len(sample),
"truncated": False,
"next_offset": None,
"downsample": {
"stride": stride,
"pinned_last": True,
"algorithm": "every-nth",
"total_rows": total,
},
}
payload = _serialize(envelope)
if len(payload) <= budget or target <= 2:
return payload
target = max(2, min(target - 1, int(target * budget / len(payload))))
def _read_json_artifact(path: Path, artifact: str, run_dir: str) -> str:
"""Parse and envelope a small JSON artifact (run_card or manifest-listed).
Returns the ``{"artifact", "run_dir", "json", "size_bytes"}`` envelope, or
the error envelope when the file is corrupt — never partial JSON.
"""
try:
parsed = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
return _error(
f"{artifact} is not valid JSON: {exc}",
f"Delete or regenerate {path.name}; this tool never returns partial JSON.",
)
return _serialize(
{
"artifact": artifact,
"run_dir": run_dir,
"json": parsed,
"size_bytes": path.stat().st_size,
}
)
def _meta_envelope(
base: dict[str, Any],
total_rows: int,
path: Path,
entry: dict[str, Any] | None,
) -> str:
"""Serialize a ``meta`` envelope, enriched from the manifest when listed.
A manifest-listed artifact additionally carries the card's claims when it
was written — ``sha256`` and ``manifest_size_bytes`` — beside the real
``size_bytes`` stat, so a caller can compare the run's verified-evidence
record against the file on disk.
"""
envelope: dict[str, Any] = {
**base,
"total_rows": total_rows,
"size_bytes": path.stat().st_size,
}
if entry is not None:
digest = entry.get("sha256")
if isinstance(digest, str):
envelope["sha256"] = digest
declared = entry.get("size_bytes")
if isinstance(declared, int) and not isinstance(declared, bool):
envelope["manifest_size_bytes"] = declared
return _serialize(envelope)
def read_run_artifact(
run_dir: str,
artifact: str,
format: str = "rows",
offset: int = 0,
max_rows: int = 1000,
columns: list[str] | None = None,
) -> str:
"""Read one run artifact as structured, budget-bounded JSON.
Args:
run_dir: Run directory; must sit inside an allowed run root.
artifact: Friendly alias — ``equity``/``trades``/``metrics``/
``positions``/``target_positions`` (CSVs under ``artifacts/``),
``ohlcv:<CODE>``, ``run_card`` — or any run_dir-relative
``.csv``/``.json`` path listed in the run's ``run_card.json``
artifacts manifest (e.g. ``artifacts/validation.json``).
format: ``rows`` (offset paging), ``downsample`` (equal-stride sample,
first+last pinned; ``offset`` ignored) or ``meta`` (shape only).
JSON artifacts return their parsed object regardless of format.
offset: First row index for ``rows`` mode; negative values refused.
max_rows: Page/sample size, clamped to ``[1, 5000]``.
columns: Optional projection applied before budget fitting; an unknown
name is refused with the valid column list.
Returns:
JSON string. Success envelopes carry no ``ok``/``status`` field;
failures are ``{"ok": false, "error": ..., "hint": ...}``. ``meta``
on a manifest-listed artifact additionally carries the manifest's
``sha256`` and ``manifest_size_bytes`` beside the real ``size_bytes``.
"""
if format not in _VALID_FORMATS:
return _error(
f"unknown format {format!r}",
f"format must be one of: {', '.join(_VALID_FORMATS)}.",
)
try:
run_root = safe_run_dir(run_dir)
except ValueError as exc:
return _error(str(exc), "Pass the run_dir a backtest/tool call returned.")
manifest = _load_artifact_manifest(run_root)
try:
path, entry = _resolve_artifact(run_root, artifact, manifest)
except _ArtifactError as exc:
return _error(str(exc), exc.hint)
if not path.is_file():
return _error(
f"artifact {artifact!r} not found at {path}",
"The run may not have produced it yet — check the backtest result's "
"'artifacts' map, or use format='meta' on an artifact that exists.",
)
if path.suffix.lower() == ".json":
return _read_json_artifact(path, artifact, run_dir)
try:
offset = int(offset)
max_rows = int(max_rows)
except (TypeError, ValueError):
return _error(
"offset and max_rows must be integers",
"Omit them for the defaults (offset=0, max_rows=1000).",
)
if offset < 0:
return _error(
f"offset must be >= 0, got {offset}",
"Start at offset=0 and follow next_offset.",
)
max_rows = max(1, min(max_rows, _MAX_ROWS_CEILING))
try:
header, rows = _read_csv(path)
except (OSError, UnicodeDecodeError, csv.Error) as exc:
return _error(
f"failed to read {artifact}: {exc}",
"The file may be corrupt or not a UTF-8 CSV artifact.",
)
if columns is not None:
try:
header, rows = _project(header, rows, list(columns))
except ValueError as exc:
return _error(
str(exc),
"Pass only column names from the artifact header, or omit 'columns'.",
)
total_rows = len(rows)
base: dict[str, Any] = {"artifact": artifact, "run_dir": run_dir, "columns": header}
budget = _BYTE_BUDGET
if format == "meta":
return _meta_envelope(base, total_rows, path, entry)
if format == "downsample":
return _fit_downsample_payload(
{**base, "total_rows": total_rows, "offset": 0}, rows, max_rows, budget
)
# rows mode
page = rows[offset : offset + max_rows] if offset < total_rows else []
envelope_base = {
**base,
"total_rows": total_rows,
"offset": offset,
"downsample": None,
}
return _fit_rows_payload(envelope_base, page, offset, total_rows, budget)
class RunArtifactTool(BaseTool):
"""Chunked/downsampled reader for backtest run artifacts."""
name = "read_run_artifact"
description = (
"Read a backtest run artifact as structured JSON: paged rows "
"(format='rows', follow next_offset), an equal-stride chart sample "
"with first+last pinned (format='downsample', offset ignored), or "
"shape only (format='meta', plus the manifest sha256 when listed). "
"Artifacts: equity, trades, metrics, positions, target_positions, "
"ohlcv:<CODE>, run_card, or any run_dir-relative .csv/.json path "
"listed in the run_card.json artifacts manifest. Values arrive typed "
"(int/float/null/string), never as raw CSV text, and every page stays "
"within a bounded byte budget."
)
parameters = {
"type": "object",
"properties": {
"run_dir": {"type": "string", "description": "Path to the run directory"},
"artifact": {
"type": "string",
"description": (
"Artifact alias: equity | trades | metrics | positions | "
"target_positions | ohlcv:<CODE> (e.g. ohlcv:600519.SH) | "
"run_card — or a run_dir-relative path listed in the "
"run_card.json artifacts manifest (e.g. "
"artifacts/validation.json)"
),
},
"format": {
"type": "string",
"enum": list(_VALID_FORMATS),
"description": (
"rows: offset paging over whole records; downsample: "
"equal-stride sample with first+last row pinned (offset "
"ignored); meta: columns/total_rows/size_bytes only"
),
},
"offset": {
"type": "integer",
"description": "First row index (rows mode only, default 0)",
},
"max_rows": {
"type": "integer",
"description": "Page/sample size, clamped to [1, 5000] (default 1000)",
},
"columns": {
"type": "array",
"items": {"type": "string"},
"description": (
"Optional column projection; unknown names are refused "
"with the valid list"
),
},
},
"required": ["run_dir", "artifact"],
}
repeatable = True
is_readonly = True
def execute(self, **kwargs: Any) -> str:
"""Execute the artifact read (see :func:`read_run_artifact`)."""
return read_run_artifact(
run_dir=kwargs["run_dir"],
artifact=kwargs["artifact"],
format=kwargs.get("format", "rows"),
offset=kwargs.get("offset", 0),
max_rows=kwargs.get("max_rows", 1000),
columns=kwargs.get("columns"),
)
+565
View File
@@ -0,0 +1,565 @@
"""Tests for the run-artifact reader tool.
Covers the contract of ``src.tools.run_artifact_tool``: manifest-driven
resolution (friendly aliases with and without a run card, manifest-listed
relative paths, tampered-manifest refusal), traversal rejection, CSV
coercion, offset paging, first+last-pinned downsampling, meta/JSON
envelopes including the manifest sha256, column projection, and the
byte-budget shrink loop.
"""
from __future__ import annotations
import csv
import hashlib
import json
from pathlib import Path
import pytest
from src.tools import run_artifact_tool
from src.tools.run_artifact_tool import RunArtifactTool, read_run_artifact
EQUITY_ROWS = 3821
EQUITY_COLUMNS = ["date", "equity", "cash", "drawdown"]
ROWS_ENVELOPE_KEYS = {
"artifact",
"run_dir",
"columns",
"rows",
"total_rows",
"offset",
"returned_rows",
"truncated",
"downsample",
"next_offset",
}
def _expected_row(i: int) -> list:
"""Return the coerced form of equity.csv data row ``i``."""
return [f"day{i:05d}", 100000 + i * 1.5, 1000 + i, None]
def _make_run(root: Path, rows: int = EQUITY_ROWS) -> Path:
"""Create a run directory carrying a programmatically generated equity.csv."""
run_dir = root / "run"
(run_dir / "artifacts").mkdir(parents=True, exist_ok=True)
with (run_dir / "artifacts" / "equity.csv").open(
"w", newline="", encoding="utf-8"
) as handle:
writer = csv.writer(handle)
writer.writerow(EQUITY_COLUMNS)
for i in range(rows):
writer.writerow([f"day{i:05d}", 100000 + i * 1.5, 1000 + i, ""])
return run_dir
@pytest.fixture()
def run_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Return a run directory inside a tmp_path-scoped allowed run root."""
monkeypatch.setenv("VIBE_TRADING_ALLOWED_RUN_ROOTS", str(tmp_path))
return _make_run(tmp_path)
def _write_csv(path: Path, header: list[str], rows: list[list[str]]) -> None:
"""Write a small CSV artifact."""
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(header)
writer.writerows(rows)
def _manifest_entry(run_dir: Path, relative: str) -> dict:
"""Build a run_card manifest entry for a file on disk (run_card 1.0 shape)."""
path = run_dir / relative
return {
"path": relative,
"size_bytes": path.stat().st_size,
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
}
def _write_run_card(run_dir: Path, entries: list[dict]) -> None:
"""Write a run_card.json carrying the given artifacts manifest."""
(run_dir / "run_card.json").write_text(
json.dumps({"schema_version": "1.0", "artifacts": entries}),
encoding="utf-8",
)
# --------------------------------------------------------------------------
# (a) downsample
# --------------------------------------------------------------------------
def test_downsample_caps_points_and_pins_first_last(run_dir: Path) -> None:
env = json.loads(
read_run_artifact(str(run_dir), "equity", format="downsample", max_rows=1000)
)
assert set(env) == ROWS_ENVELOPE_KEYS
assert len(env["rows"]) <= 1000
assert env["rows"][0] == _expected_row(0)
assert env["rows"][-1] == _expected_row(EQUITY_ROWS - 1)
ds = env["downsample"]
assert ds == {
"stride": 4,
"pinned_last": True,
"algorithm": "every-nth",
"total_rows": EQUITY_ROWS,
}
assert env["total_rows"] == EQUITY_ROWS
assert env["truncated"] is False
assert env["next_offset"] is None
def test_downsample_returns_everything_when_total_fits(run_dir: Path) -> None:
_write_csv(run_dir / "artifacts" / "trades.csv", ["a"], [["1"], ["2"], ["3"]])
env = json.loads(
read_run_artifact(str(run_dir), "trades", format="downsample", max_rows=100)
)
assert env["rows"] == [[1], [2], [3]]
assert env["downsample"]["stride"] == 1
assert env["downsample"]["pinned_last"] is True
# --------------------------------------------------------------------------
# (b) offset paging walk
# --------------------------------------------------------------------------
def test_offset_paging_walk_is_lossless(run_dir: Path) -> None:
gathered: list[list] = []
offset = 0
for _ in range(20): # 3821 / 500 -> 8 pages; hard stop guards a loop bug
env = json.loads(
read_run_artifact(str(run_dir), "equity", offset=offset, max_rows=500)
)
assert env["offset"] == offset
gathered.extend(env["rows"])
if not env["truncated"]:
assert env["next_offset"] is None
break
assert env["next_offset"] == offset + env["returned_rows"]
offset = env["next_offset"]
else:
pytest.fail("paging walk did not terminate")
assert len(gathered) == EQUITY_ROWS
assert gathered == [_expected_row(i) for i in range(EQUITY_ROWS)]
def test_offset_past_end_returns_empty_page(run_dir: Path) -> None:
env = json.loads(
read_run_artifact(str(run_dir), "equity", offset=EQUITY_ROWS + 100)
)
assert env["rows"] == []
assert env["returned_rows"] == 0
assert env["truncated"] is False
assert env["next_offset"] is None
def test_negative_offset_rejected(run_dir: Path) -> None:
env = json.loads(read_run_artifact(str(run_dir), "equity", offset=-1))
assert env["ok"] is False
def test_max_rows_clamped(run_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(run_artifact_tool, "_BYTE_BUDGET", 10_000_000)
_write_csv(
run_dir / "artifacts" / "trades.csv", ["n"], [[str(i)] for i in range(5200)]
)
tiny = json.loads(read_run_artifact(str(run_dir), "trades", max_rows=0))
assert tiny["returned_rows"] == 1
huge = json.loads(read_run_artifact(str(run_dir), "trades", max_rows=10**6))
assert huge["returned_rows"] == 5000
assert huge["truncated"] is True
assert huge["next_offset"] == 5000
# --------------------------------------------------------------------------
# (c) traversal / alias whitelist / manifest gate
# --------------------------------------------------------------------------
@pytest.mark.parametrize(
"artifact",
[
"../../etc/passwd",
"/etc/passwd",
"ohlcv:../../x",
"ohlcv:/abs",
"ohlcv:",
"ohlcv:.hidden",
"equity.csv",
"config",
"",
],
)
def test_non_whitelisted_artifacts_rejected(run_dir: Path, artifact: str) -> None:
env = json.loads(read_run_artifact(str(run_dir), artifact))
assert env["ok"] is False
assert set(env) == {"ok", "error", "hint"}
assert env["hint"]
def test_run_dir_outside_allowed_roots_rejected(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("VIBE_TRADING_ALLOWED_RUN_ROOTS", str(tmp_path))
env = json.loads(read_run_artifact(str(tmp_path.parent / "elsewhere"), "equity"))
assert env["ok"] is False
assert "run roots" in env["error"]
def test_symlink_escape_rejected(run_dir: Path, tmp_path: Path) -> None:
secret = tmp_path / "secret.csv"
secret.write_text("a\n1\n", encoding="utf-8")
(run_dir / "artifacts" / "trades.csv").symlink_to(secret)
env = json.loads(read_run_artifact(str(run_dir), "trades"))
assert env["ok"] is False
# --------------------------------------------------------------------------
# (c2) manifest-driven resolution
# --------------------------------------------------------------------------
def test_manifest_listed_json_artifact_resolves(run_dir: Path) -> None:
payload = {"walk_forward": {"oos_sharpe": 0.9}}
(run_dir / "artifacts" / "validation.json").write_text(
json.dumps(payload), encoding="utf-8"
)
_write_run_card(run_dir, [_manifest_entry(run_dir, "artifacts/validation.json")])
env = json.loads(read_run_artifact(str(run_dir), "artifacts/validation.json"))
assert set(env) == {"artifact", "run_dir", "json", "size_bytes"}
assert env["artifact"] == "artifacts/validation.json"
assert env["json"] == payload
def test_manifest_listed_csv_artifact_resolves(run_dir: Path) -> None:
_write_csv(
run_dir / "artifacts" / "rebalance_notes.csv",
["date", "note"],
[["2024-01-02", "held"]],
)
entry = _manifest_entry(run_dir, "artifacts/rebalance_notes.csv")
_write_run_card(run_dir, [entry])
env = json.loads(read_run_artifact(str(run_dir), "artifacts/rebalance_notes.csv"))
assert env["columns"] == ["date", "note"]
assert env["rows"] == [["2024-01-02", "held"]]
meta = json.loads(
read_run_artifact(str(run_dir), "artifacts/rebalance_notes.csv", format="meta")
)
assert meta["sha256"] == entry["sha256"]
assert meta["manifest_size_bytes"] == entry["size_bytes"]
def test_relative_path_not_in_manifest_refused(run_dir: Path) -> None:
_write_csv(run_dir / "artifacts" / "extra.csv", ["a"], [["1"]])
_write_run_card(run_dir, []) # a card exists but lists nothing
env = json.loads(read_run_artifact(str(run_dir), "artifacts/extra.csv"))
assert env["ok"] is False
assert set(env) == {"ok", "error", "hint"}
assert "manifest" in env["hint"]
@pytest.mark.parametrize("tampered", ["../evil.csv", "..\\evil.csv"])
def test_tampered_manifest_escape_entries_refused(
run_dir: Path, tmp_path: Path, tampered: str
) -> None:
(tmp_path / "evil.csv").write_text(
"a\n1\n", encoding="utf-8"
) # the escape target exists
_write_run_card(
run_dir, [{"path": tampered, "size_bytes": 4, "sha256": "deadbeef"}]
)
env = json.loads(read_run_artifact(str(run_dir), tampered))
assert env["ok"] is False
assert set(env) == {"ok", "error", "hint"}
def test_tampered_manifest_absolute_path_refused(run_dir: Path, tmp_path: Path) -> None:
secret = tmp_path / "secret.csv"
secret.write_text("a\n1\n", encoding="utf-8")
_write_run_card(
run_dir, [{"path": str(secret), "size_bytes": 4, "sha256": "deadbeef"}]
)
env = json.loads(read_run_artifact(str(run_dir), str(secret)))
assert env["ok"] is False
def test_manifest_symlink_entry_escaping_run_dir_refused(
run_dir: Path, tmp_path: Path
) -> None:
secret = tmp_path / "secret.csv"
secret.write_text("a\n1\n", encoding="utf-8")
(run_dir / "artifacts" / "link.csv").symlink_to(secret)
_write_run_card(run_dir, [_manifest_entry(run_dir, "artifacts/link.csv")])
env = json.loads(read_run_artifact(str(run_dir), "artifacts/link.csv"))
assert env["ok"] is False
def test_manifest_listed_non_served_suffix_refused(run_dir: Path) -> None:
(run_dir / "code").mkdir(parents=True, exist_ok=True)
(run_dir / "code" / "signal_engine.py").write_text("print(1)\n", encoding="utf-8")
_write_run_card(run_dir, [_manifest_entry(run_dir, "code/signal_engine.py")])
env = json.loads(read_run_artifact(str(run_dir), "code/signal_engine.py"))
assert env["ok"] is False
assert "read_file" in env["hint"]
def test_aliases_work_without_run_card(run_dir: Path) -> None:
assert not (run_dir / "run_card.json").exists()
env = json.loads(read_run_artifact(str(run_dir), "equity", format="meta"))
assert env["total_rows"] == EQUITY_ROWS
assert "sha256" not in env and "manifest_size_bytes" not in env
def test_aliases_work_with_corrupt_run_card(run_dir: Path) -> None:
(run_dir / "run_card.json").write_text("{oops not json", encoding="utf-8")
env = json.loads(read_run_artifact(str(run_dir), "equity", format="meta"))
assert env["total_rows"] == EQUITY_ROWS
refused = json.loads(read_run_artifact(str(run_dir), "artifacts/equity.csv"))
assert refused["ok"] is False
assert "manifest" in refused["hint"]
def test_meta_exposes_manifest_sha256(run_dir: Path) -> None:
entry = _manifest_entry(run_dir, "artifacts/equity.csv")
_write_run_card(run_dir, [entry])
env = json.loads(read_run_artifact(str(run_dir), "equity", format="meta"))
assert env["sha256"] == entry["sha256"]
assert env["manifest_size_bytes"] == entry["size_bytes"]
assert env["size_bytes"] == (run_dir / "artifacts" / "equity.csv").stat().st_size
# --------------------------------------------------------------------------
# (d) structured output, no raw CSV
# --------------------------------------------------------------------------
def test_rows_are_structured_arrays_not_raw_csv(run_dir: Path) -> None:
payload = read_run_artifact(str(run_dir), "equity", max_rows=50)
assert "day00000,100000.0" not in payload # no raw CSV line survives
env = json.loads(payload)
assert env["columns"] == EQUITY_COLUMNS
for row in env["rows"]:
assert isinstance(row, list)
assert all(cell is None or isinstance(cell, (int, float, str)) for cell in row)
assert all("\n" not in cell for cell in row if isinstance(cell, str))
# --------------------------------------------------------------------------
# (e) coercion
# --------------------------------------------------------------------------
def test_numeric_coercion(run_dir: Path) -> None:
_write_csv(
run_dir / "artifacts" / "metrics.csv",
["i", "f", "blank", "nan_col", "inf_col", "text"],
[
["42", "3.14", "", "nan", "inf", "hello"],
["-7", "1e5", " ", "NaN", "-inf", "1_000"],
["0", "2.0", "x", "Infinity", "-Infinity", "null"],
],
)
env = json.loads(read_run_artifact(str(run_dir), "metrics"))
assert env["rows"][0] == [42, 3.14, None, None, None, "hello"]
assert env["rows"][1] == [-7, 100000.0, None, None, None, "1_000"]
assert env["rows"][2] == [0, 2.0, "x", None, None, "null"]
# --------------------------------------------------------------------------
# (f) meta
# --------------------------------------------------------------------------
def test_meta_format(run_dir: Path) -> None:
env = json.loads(read_run_artifact(str(run_dir), "equity", format="meta"))
assert set(env) == {"artifact", "run_dir", "columns", "total_rows", "size_bytes"}
assert env["artifact"] == "equity"
assert env["columns"] == EQUITY_COLUMNS
assert env["total_rows"] == EQUITY_ROWS
assert env["size_bytes"] == (run_dir / "artifacts" / "equity.csv").stat().st_size
def test_unknown_format_rejected(run_dir: Path) -> None:
env = json.loads(read_run_artifact(str(run_dir), "equity", format="yaml"))
assert env["ok"] is False
# --------------------------------------------------------------------------
# (g) projection
# --------------------------------------------------------------------------
def test_columns_projection(run_dir: Path) -> None:
env = json.loads(
read_run_artifact(
str(run_dir), "equity", columns=["equity", "date"], max_rows=10
)
)
assert env["columns"] == ["equity", "date"]
assert env["rows"][0] == [100000.0, "day00000"]
assert all(len(row) == 2 for row in env["rows"])
def test_unknown_column_lists_valid_columns(run_dir: Path) -> None:
env = json.loads(
read_run_artifact(str(run_dir), "equity", columns=["equity", "nope"])
)
assert env["ok"] is False
assert "nope" in env["error"]
for column in EQUITY_COLUMNS:
assert column in env["error"]
# --------------------------------------------------------------------------
# (h) byte budget
# --------------------------------------------------------------------------
def test_byte_budget_shrinks_rows_page_honestly(
run_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(run_artifact_tool, "_BYTE_BUDGET", 3000)
payload = read_run_artifact(str(run_dir), "equity", max_rows=1000)
assert len(payload) <= 3000
env = json.loads(payload) # never broken JSON
assert 0 < env["returned_rows"] < 1000
assert env["returned_rows"] == len(env["rows"])
assert env["truncated"] is True
assert env["next_offset"] == env["offset"] + env["returned_rows"]
assert env["rows"][0] == _expected_row(0)
# The advertised resume point serves a valid next page.
follow = json.loads(
read_run_artifact(
str(run_dir), "equity", offset=env["next_offset"], max_rows=1000
)
)
assert follow["rows"][0] == _expected_row(env["next_offset"])
def test_byte_budget_restrides_downsample(
run_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(run_artifact_tool, "_BYTE_BUDGET", 3000)
payload = read_run_artifact(
str(run_dir), "equity", format="downsample", max_rows=1000
)
assert len(payload) <= 3000
env = json.loads(payload)
assert env["returned_rows"] < 1000
assert env["downsample"]["stride"] > 4 # re-striden, not silently truncated
assert env["downsample"]["total_rows"] == EQUITY_ROWS
assert env["rows"][0] == _expected_row(0)
assert env["rows"][-1] == _expected_row(EQUITY_ROWS - 1)
def test_byte_budget_never_breaks_json_at_floor(
run_dir: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(run_artifact_tool, "_BYTE_BUDGET", 10)
env = json.loads(read_run_artifact(str(run_dir), "equity", max_rows=100))
assert env["returned_rows"] == 1
assert env["truncated"] is True
assert env["next_offset"] == 1
# --------------------------------------------------------------------------
# (i) ohlcv
# --------------------------------------------------------------------------
def test_ohlcv_code_maps_to_artifacts_file(run_dir: Path) -> None:
_write_csv(
run_dir / "artifacts" / "ohlcv_600519.SH.csv",
["date", "open", "close"],
[["2024-01-02", "100.5", "101.25"]],
)
env = json.loads(read_run_artifact(str(run_dir), "ohlcv:600519.SH"))
assert env["artifact"] == "ohlcv:600519.SH"
assert env["columns"] == ["date", "open", "close"]
assert env["rows"] == [["2024-01-02", 100.5, 101.25]]
# --------------------------------------------------------------------------
# (j) run_card / corrupt JSON
# --------------------------------------------------------------------------
def test_run_card_returns_parsed_json_regardless_of_format(run_dir: Path) -> None:
card = {"run_id": "abc", "metrics": {"sharpe": 1.25}, "tags": ["x"]}
(run_dir / "run_card.json").write_text(json.dumps(card), encoding="utf-8")
for fmt in ("rows", "downsample", "meta"):
env = json.loads(read_run_artifact(str(run_dir), "run_card", format=fmt))
assert set(env) == {"artifact", "run_dir", "json", "size_bytes"}
assert env["json"] == card
assert env["size_bytes"] == (run_dir / "run_card.json").stat().st_size
def test_corrupt_run_card_rejected(run_dir: Path) -> None:
(run_dir / "run_card.json").write_text("{oops not json", encoding="utf-8")
env = json.loads(read_run_artifact(str(run_dir), "run_card"))
assert env["ok"] is False
assert set(env) == {"ok", "error", "hint"}
# --------------------------------------------------------------------------
# (k) missing file
# --------------------------------------------------------------------------
def test_missing_artifact_has_hint(run_dir: Path) -> None:
env = json.loads(read_run_artifact(str(run_dir), "trades"))
assert env["ok"] is False
assert env["hint"]
assert "not found" in env["error"]
def test_manifest_listed_but_missing_on_disk_has_hint(run_dir: Path) -> None:
_write_run_card(
run_dir,
[{"path": "artifacts/validation.json", "size_bytes": 2, "sha256": "deadbeef"}],
)
env = json.loads(read_run_artifact(str(run_dir), "artifacts/validation.json"))
assert env["ok"] is False
assert "not found" in env["error"]
# --------------------------------------------------------------------------
# envelope hygiene + tool class
# --------------------------------------------------------------------------
def test_success_envelope_carries_no_ok_field(run_dir: Path) -> None:
env = json.loads(read_run_artifact(str(run_dir), "equity", max_rows=5))
assert "ok" not in env and "status" not in env
assert set(env) == ROWS_ENVELOPE_KEYS
assert env["downsample"] is None
assert env["run_dir"] == str(run_dir)
def test_tool_class_contract(run_dir: Path) -> None:
tool = RunArtifactTool()
assert tool.name == "read_run_artifact"
assert tool.is_readonly is True
assert tool.repeatable is True
assert tool.description
assert "progress" not in tool.description
schema = tool.to_openai_schema()
props = schema["function"]["parameters"]["properties"]
assert props["format"]["enum"] == ["rows", "downsample", "meta"]
assert schema["function"]["parameters"]["required"] == ["run_dir", "artifact"]
meta = json.loads(
tool.execute(run_dir=str(run_dir), artifact="equity", format="meta")
)
assert meta["total_rows"] == EQUITY_ROWS
default = json.loads(
tool.execute(run_dir=str(run_dir), artifact="equity", max_rows=3)
)
assert set(default) == ROWS_ENVELOPE_KEYS
assert default["returned_rows"] == 3