Files
Auto-Trader/agent/mcp_server.py
T
Teerapat VatpitakandHaozhe Wu 39aaa62008 fix(swarm): surface errors, output contract, Windows-safe store, redact paths (#119)
* fix(swarm): surface task error at read boundaries

run_swarm / get_swarm_status / get_run_result and the in-process
run_swarm tool each hand-maintained a field allowlist that omitted
SwarmTask.error. A misconfigured provider returned status="failed"
with no diagnosable reason, though the error was captured on disk
the whole time.

Add src/swarm/serialization.py as the single source of truth for the
per-task projection (now includes error + iterations) plus a run-level
error summary; route all three boundaries through it so the allowlist
cannot silently drift again. Additive JSON change; the only shape
normalization is _format_result missing-summary "" -> null.

(cherry picked from commit 95f2717ac32bd02829f650bc971e3cd6dd7fffa6)

* fix(swarm): enforce worker output contract

A worker that emitted only its plan ("Phase 1 — Plan"), mock data,
unparsed tool markup, or (for a data agent) made no tool call and
wrote no report was returned status="completed". runtime.py also
folded timeout/token_limit into completed. The run reported success
with a stub as final_report (P01); degraded workers were invisible
(P03).

Add WorkerStatus.incomplete and a hybrid output contract in worker.py:
content-sanity for every agent, tool-evidence only for data agents so
tool-less synthesis/editor roles are not false-rejected. Fail-fast to
incomplete (no corrective retry — left for a follow-up). runtime.py
only maps a true completed to TaskStatus.completed; anything else
fails the run, so final_report can no longer be a non-deliverable.

(cherry picked from commit 774b26824509678c953cd0200ef7a56c03f23201)

* fix(swarm): thread shell tools + unify runs root

The MCP run_swarm wrapper called start_run() without
include_shell_tools, so stdio swarm workers silently lost bash and
could not run the scripts their own execution rules mandate (P03-B).
build_filtered_registry also dropped a requested-but-unavailable
tool with no log, hiding the contradiction.

Pass the server's shell-tool policy into start_run and warn on a
dropped tool. Add swarm_runs_root() as the single source of truth
for the run store location, shared by mcp_server and the path_utils
run-dir allow-list so the two can no longer drift (P03-A: an
installed-wheel layout had put every worker run_dir outside it).

(cherry picked from commit 2975fda09b5ad44d15580d9330890f72f43c00ce)

* fix(swarm): make store atomic write Windows-safe

SwarmStore._atomic_write did tmp.replace(target) with no retry. On
Windows os.replace raises PermissionError WinError 5/32 when the
target is concurrently open by load_run on the poll path, crashing
the worker thread and leaving the run stuck pending (P13). POSIX
os.replace is atomic and never raises these.

Add a WinError-scoped, bounded retry around the rename and a matching
transient read/parse retry in load_run (the other side of the same
race). Non-transient errors re-raise immediately; off-Windows the
loop runs once, so POSIX behavior is unchanged. Pre-existing, not
introduced by the P01/P03/P04 fixes — surfaced by E2E dogfooding.

(cherry picked from commit 46a779cf436353591e7840879f8c32f763dde826)

* style(swarm): drop unused imports (ruff F401)

Remove an unused `from pathlib import Path` in the PR03 test added by
2975fda, and the pre-existing unused `from src.swarm.mailbox import
Mailbox` in runtime.py (file already in this branch's diff). Net ruff
delta for PR1-PR4 is now -1. The remaining mcp_server.py:41 E402 is
pre-existing on upstream and structural — left out of scope.

(cherry picked from commit e96f56af1ead9b5b7058c90ec0d4df738b45e473)

* fix: redact internal absolute paths in errors

Unknown preset / workspace-escape / missing run-or-task dir errors
embedded the full install path (OS username, .venvs/site-packages
topology) — CWE-209/497, reachable via MCP/API/agent/CLI (P10).

Drop the absolute path while keeping the actionable bits: preset
name + Available list, the workspace-root boundary, and the logical
run/task id (basename only). Two test_path_safety assertions that
matched the old leaking message are updated to the redacted wording
(behavior unchanged — still raises and rejects). Verified-clean
sites (caller's own input / env-var / names list) left untouched.

(cherry picked from commit 69ea45295410cd25ea3079c25b7e3c4c73390b80)

* fix(security): redact internal paths in errors

Add agent/src/tools/redaction.py: anchored prefix redaction (no
regex, idempotent, None-safe) that hides only known-internal root
prefixes while keeping the relative tail for diagnosability.

Wire it through the swarm error read boundaries (serialization,
runtime, store) and the file tools' final broad except so a leaked
absolute path cannot reach a user-facing string (CWE-209/497, P10).
ValueError branches and the protected providers/llm.py are left
untouched.

(cherry picked from commit 21dc9cdfacf17cccd31cacfbe34481f62f1390af)

* fix(security): redact swarm event error payloads

---------

Co-authored-by: Haozhe Wu <haozhe_wu@connect.hku.hk>
2026-05-16 23:50:37 +08:00

825 lines
27 KiB
Python

#!/usr/bin/env python3
"""Vibe-Trading MCP Server — expose 22 finance research tools to any MCP client.
Works with OpenClaw, Claude Desktop, Cursor, and any MCP-compatible client.
Zero API key required for HK/US/crypto markets (yfinance, OKX, AKShare are free).
Usage:
python mcp_server.py # stdio transport (default)
python mcp_server.py --transport sse # SSE transport for web clients
OpenClaw config (~/.openclaw/config.yaml):
skills:
- name: vibe-trading
command: python /path/to/agent/mcp_server.py
Claude Desktop config:
{
"mcpServers": {
"vibe-trading": {
"command": "python",
"args": ["/path/to/agent/mcp_server.py"]
}
}
}
"""
from __future__ import annotations
import json
import logging
import math
import os
import re
import sys
from pathlib import Path
from typing import Any
# Ensure agent/ is on sys.path
AGENT_DIR = Path(__file__).resolve().parent
if str(AGENT_DIR) not in sys.path:
sys.path.insert(0, str(AGENT_DIR))
from fastmcp import FastMCP
mcp = FastMCP("Vibe-Trading")
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Lazy-loaded singletons
# ---------------------------------------------------------------------------
_skills_loader = None
_registry = None
_include_shell_tools = True
def _env_shell_tools_enabled() -> bool:
"""Return whether shell tools were explicitly enabled for network MCP."""
return os.getenv("VIBE_TRADING_ENABLE_SHELL_TOOLS", "").strip().lower() in {"1", "true", "yes", "on"}
def _get_skills_loader():
global _skills_loader
if _skills_loader is None:
from src.agent.skills import SkillsLoader
_skills_loader = SkillsLoader()
return _skills_loader
def _get_registry():
global _registry
if _registry is None:
from src.tools import build_registry
_registry = build_registry(include_shell_tools=_include_shell_tools)
return _registry
# ---------------------------------------------------------------------------
# Skill tools
# ---------------------------------------------------------------------------
@mcp.tool
def list_skills() -> str:
"""List all available finance skills with names and descriptions.
Returns a JSON array of {name, description} for all loaded skills.
Use load_skill(name) to get the full documentation for any skill.
"""
loader = _get_skills_loader()
skills = [{"name": s.name, "description": s.description} for s in loader.skills]
return json.dumps(skills, ensure_ascii=False, indent=2)
@mcp.tool
def load_skill(name: str) -> str:
"""Load full documentation for a named finance skill.
Each skill is a comprehensive knowledge document covering methodology,
code templates, parameters, and examples. Use list_skills() first to
discover available skills.
Args:
name: Skill name (e.g. 'strategy-generate', 'risk-analysis', 'technical-basic').
"""
loader = _get_skills_loader()
content = loader.get_content(name)
if content.startswith("Error:"):
return json.dumps({"status": "error", "error": content}, ensure_ascii=False)
return json.dumps({"status": "ok", "skill": name, "content": content}, ensure_ascii=False)
# ---------------------------------------------------------------------------
# Backtest tool
# ---------------------------------------------------------------------------
@mcp.tool
def backtest(run_dir: str) -> str:
"""Run a vectorized backtest using config.json and code/signal_engine.py.
The run_dir must contain:
- config.json: backtest configuration (source, codes, dates, etc.)
- code/signal_engine.py: strategy signal generation code
Supported data sources (set in config.json "source" field):
- "yfinance": HK/US equities (free, no API key needed)
- "okx": cryptocurrency (free, no API key needed)
- "tushare": China A-shares (requires TUSHARE_TOKEN env var)
- "akshare": A-shares, US, HK, futures, forex (free, no API key)
- "ccxt": crypto from 100+ exchanges (free, no API key)
- "auto": auto-detect based on symbol format (with fallback)
Returns metrics (Sharpe, return, drawdown, etc.) and artifact paths.
Args:
run_dir: Path to the run directory containing config.json and code/.
"""
from src.tools.backtest_tool import run_backtest
return run_backtest(run_dir)
# ---------------------------------------------------------------------------
# Factor analysis tool
# ---------------------------------------------------------------------------
@mcp.tool
def factor_analysis(
codes: list[str],
factor_name: str,
start_date: str,
end_date: str,
source: str = "auto",
top_n: int = 10,
bottom_n: int = 10,
) -> str:
"""Compute factor IC/IR analysis and layered backtest for a cross-section of stocks.
Analyzes factor predictive power using Spearman rank IC, IR (IC/std),
and top/bottom quintile return spreads.
Args:
codes: List of stock codes (e.g. ["000001.SZ", "600519.SH"]).
factor_name: Factor column name in daily_basic data (e.g. "pe_ttm", "pb", "turnover_rate").
start_date: Start date (YYYY-MM-DD).
end_date: End date (YYYY-MM-DD).
source: Data source ("tushare", "yfinance", "auto").
top_n: Number of top-ranked stocks per period.
bottom_n: Number of bottom-ranked stocks per period.
"""
registry = _get_registry()
return registry.execute(
"factor_analysis",
{
"codes": codes,
"factor_name": factor_name,
"start_date": start_date,
"end_date": end_date,
"source": source,
"top_n": top_n,
"bottom_n": bottom_n,
},
)
# ---------------------------------------------------------------------------
# Options pricing tool
# ---------------------------------------------------------------------------
@mcp.tool
def analyze_options(
spot: float,
strike: float,
expiry_days: int,
risk_free_rate: float = 0.03,
volatility: float = 0.25,
option_type: str = "call",
) -> str:
"""Calculate Black-Scholes option price and Greeks (Delta, Gamma, Theta, Vega).
Args:
spot: Current underlying price.
strike: Strike price.
expiry_days: Days until expiration.
risk_free_rate: Annual risk-free rate (default 0.03 = 3%).
volatility: Annual volatility (default 0.25 = 25%).
option_type: "call" or "put".
"""
registry = _get_registry()
return registry.execute(
"options_pricing",
{
"spot": spot,
"strike": strike,
"expiry_days": expiry_days,
"risk_free_rate": risk_free_rate,
"volatility": volatility,
"option_type": option_type,
},
)
# ---------------------------------------------------------------------------
# Pattern recognition tool
# ---------------------------------------------------------------------------
@mcp.tool
def pattern_recognition(run_dir: str) -> str:
"""Detect technical chart patterns (head-and-shoulders, double top/bottom,
triangles, wedges, channels) in OHLCV data.
Reads price data from run_dir/artifacts/ohlcv_*.csv files.
Can be called before coding (to inform strategy) or after backtest (to analyse).
Args:
run_dir: Path to run directory containing artifacts/ohlcv_*.csv.
"""
registry = _get_registry()
return registry.execute("pattern", {"run_dir": run_dir})
# ---------------------------------------------------------------------------
# Web & document reading tools
# ---------------------------------------------------------------------------
@mcp.tool
def read_url(url: str) -> str:
"""Fetch a web page and convert it to clean Markdown text.
Strips ads, navigation, and styling. Useful for reading API docs,
financial articles, research reports, and GitHub READMEs.
Args:
url: Target URL to read.
"""
from src.tools.web_reader_tool import read_url as _read_url
return _read_url(url)
@mcp.tool
def read_document(file_path: str) -> str:
"""Extract text from a PDF document with OCR fallback for scanned pages.
Supports text-based and image-based PDFs. Automatically uses OCR
for pages with insufficient extractable text.
Args:
file_path: Absolute path to the PDF file.
"""
registry = _get_registry()
return registry.execute("read_document", {"file_path": file_path})
# ---------------------------------------------------------------------------
# Web search tool
# ---------------------------------------------------------------------------
@mcp.tool
def web_search(query: str, max_results: int = 5) -> str:
"""Search the web via DuckDuckGo and return top results.
Returns titles, URLs, and snippets. Use read_url() to fetch full content
from any result URL. Free, no API key required.
Args:
query: Search query string.
max_results: Maximum results to return (default 5, max 10).
"""
registry = _get_registry()
return registry.execute(
"web_search",
{
"query": query,
"max_results": min(max_results, 10),
},
)
# ---------------------------------------------------------------------------
# File I/O tools (sandboxed to workspace)
# ---------------------------------------------------------------------------
@mcp.tool
def write_file(path: str, content: str) -> str:
"""Write content to a file. Used to create config.json and signal_engine.py
for backtesting workflows.
Args:
path: File path (relative to workspace or absolute).
content: File content to write.
"""
registry = _get_registry()
return registry.execute("write_file", {"path": path, "content": content})
@mcp.tool
def read_file(path: str) -> str:
"""Read the contents of a file.
Args:
path: File path to read.
"""
registry = _get_registry()
return registry.execute("read_file", {"path": path})
# ---------------------------------------------------------------------------
# Swarm team tool
# ---------------------------------------------------------------------------
@mcp.tool
def list_swarm_presets() -> str:
"""List available swarm multi-agent team presets.
Each preset defines a team of specialized agents (e.g. investment committee,
quant desk, risk committee) that collaborate on complex research tasks.
Returns preset names, descriptions, agent counts, and required variables.
"""
from src.swarm.presets import list_presets
presets = list_presets()
return json.dumps(presets, ensure_ascii=False, indent=2)
@mcp.tool
def run_swarm(preset_name: str, variables: dict[str, str]) -> str:
"""Run a swarm multi-agent team and return the final report.
Assembles a team of specialized agents that collaborate through a DAG workflow.
For example, the 'investment_committee' preset runs bull analyst, bear analyst,
risk officer, and portfolio manager in sequence.
Use list_swarm_presets() to see available presets and their required variables.
Args:
preset_name: Swarm preset name (e.g. 'investment_committee', 'quant_strategy_desk').
variables: Required variables for the preset (e.g. {"target": "AAPL.US", "market": "US"}).
"""
import time
from src.swarm.runtime import SwarmRuntime
from src.swarm.store import SwarmStore, swarm_runs_root
from src.swarm.models import RunStatus
swarm_dir = swarm_runs_root()
store = SwarmStore(base_dir=swarm_dir)
runtime = SwarmRuntime(store=store)
try:
run = runtime.start_run(
preset_name, variables, include_shell_tools=_include_shell_tools
)
except FileNotFoundError as exc:
return json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False)
except ValueError as exc:
return json.dumps({"status": "error", "error": f"DAG validation failed: {exc}"}, ensure_ascii=False)
# Poll until complete (max 30 minutes)
for _ in range(360):
time.sleep(5)
current = store.load_run(run.id)
if current is None:
return json.dumps({"status": "error", "error": "Run record lost"}, ensure_ascii=False)
if current.status in (RunStatus.completed, RunStatus.failed, RunStatus.cancelled):
from src.swarm.serialization import run_level_error, serialize_task
tasks = [serialize_task(t) for t in current.tasks]
return json.dumps(
{
"status": current.status.value,
"preset": preset_name,
"run_id": current.id,
"final_report": current.final_report,
"error": run_level_error(current),
"tasks": tasks,
"total_input_tokens": current.total_input_tokens,
"total_output_tokens": current.total_output_tokens,
},
ensure_ascii=False,
indent=2,
)
return json.dumps({"status": "error", "error": "Swarm timed out after 30 minutes"}, ensure_ascii=False)
# ---------------------------------------------------------------------------
# Market data tool
# ---------------------------------------------------------------------------
DEFAULT_MAX_ROWS = 250
_SOURCE_PATTERNS = [
(re.compile(r"^\d{6}\.(SZ|SH|BJ)$", re.I), "tushare"),
(re.compile(r"^[A-Z]+\.US$", re.I), "yfinance"),
(re.compile(r"^\d{3,5}\.HK$", re.I), "yfinance"),
(re.compile(r"^[A-Z]+-USDT$", re.I), "okx"),
(re.compile(r"^[A-Z]+/USDT$", re.I), "ccxt"),
]
def _detect_source(code: str) -> str:
for pattern, source in _SOURCE_PATTERNS:
if pattern.match(code):
return source
return "tushare"
def _get_loader(source: str):
"""Get loader class via registry with fallback support."""
from backtest.loaders.registry import get_loader_cls_with_fallback
return get_loader_cls_with_fallback(source)
def _cap_rows(records: list, max_rows: int) -> list | dict[str, object]:
"""Bound a per-symbol row list to keep the MCP payload within budget.
max_rows==0 disables the cap (full list, unchanged shape). A negative
max_rows is invalid and enforces the default cap (never unbounded).
Otherwise an oversized symbol is *evenly strided* — every step-th bar,
with the last bar pinned — so the returned series spans the full range
(no head+tail gap, no synthetic ``_gap`` sentinel). Symbols within the
cap are returned unchanged (plain list) — small queries are
byte-identical.
"""
n = len(records)
if max_rows < 0:
max_rows = DEFAULT_MAX_ROWS # negative invalid -> enforce cap, never unbounded
if max_rows == 0 or n <= max_rows:
return records
step = math.ceil(n / max_rows)
sampled = records[::step]
if sampled[-1] is not records[-1]:
sampled = sampled + [records[-1]]
return {
"rows": n,
"returned": len(sampled),
"truncated": True,
"policy": f"every-{step}th-row (even stride; last bar pinned)",
"hint": "narrow the date range, coarsen interval, or set max_rows=0 for all rows",
"data": sampled,
}
@mcp.tool
def get_market_data(
codes: list[str],
start_date: str,
end_date: str,
source: str = "auto",
interval: str = "1D",
max_rows: int = DEFAULT_MAX_ROWS,
) -> str:
"""Fetch OHLCV market data for stocks, crypto, or mixed symbols.
Supported sources:
- "yfinance": HK/US equities (free, e.g. AAPL.US, 700.HK)
- "okx": cryptocurrency (free, e.g. BTC-USDT, ETH-USDT)
- "tushare": China A-shares (requires TUSHARE_TOKEN, e.g. 000001.SZ)
- "akshare": A-shares, US, HK, futures, forex (free, e.g. 000001.SZ, AAPL.US)
- "ccxt": crypto from 100+ exchanges (free, e.g. BTC/USDT)
- "auto": auto-detect based on symbol format (with fallback)
Args:
codes: List of symbols (e.g. ["AAPL.US", "BTC-USDT", "000001.SZ"]).
start_date: Start date (YYYY-MM-DD).
end_date: End date (YYYY-MM-DD).
source: Data source ("auto", "yfinance", "okx", "tushare", "akshare", "ccxt").
interval: Bar size (1m/5m/15m/30m/1H/4H/1D, default "1D").
max_rows: Per-symbol row cap (default 250) so the response stays
within the MCP token budget. A symbol exceeding it returns an
even-stride downsample (every step-th bar, last bar pinned)
plus truncation metadata. Set max_rows=0 for all rows
(unbounded, legacy behavior).
"""
results = {}
if source == "auto":
groups: dict[str, list[str]] = {}
for code in codes:
src = _detect_source(code)
groups.setdefault(src, []).append(code)
else:
groups = {source: list(codes)}
for src, src_codes in groups.items():
loader_cls = _get_loader(src)
loader = loader_cls()
try:
data_map = loader.fetch(src_codes, start_date, end_date, interval=interval)
except Exception:
# A loader blow-up for one group must not lose already-resolved
# symbols or surface as an opaque MCP error; those codes fall
# through to _unresolved below (P05).
logger.exception("market-data loader %r failed for %s; codes fall through to _unresolved", src, src_codes)
data_map = {}
for symbol, df in data_map.items():
records = df.reset_index().to_dict(orient="records")
for r in records:
for k, v in r.items():
if hasattr(v, "isoformat"):
r[k] = v.isoformat()
elif hasattr(v, "item"):
r[k] = v.item()
results[symbol] = _cap_rows(records, max_rows)
# P05: a typo / wrong-suffix / delisted / no-data symbol used to vanish
# silently (the dict only held winners), indistinguishable from "no data".
# Surface every requested code that produced nothing under a reserved key.
# Additive: omitted entirely when all codes resolved, so the happy-path
# payload is byte-identical to before.
unresolved = [c for c in codes if c not in results]
if unresolved:
results["_unresolved"] = unresolved
return json.dumps(results, ensure_ascii=False, indent=2)
# ---------------------------------------------------------------------------
# Swarm status & history tools
# ---------------------------------------------------------------------------
def _get_swarm_store():
from src.swarm.store import SwarmStore, swarm_runs_root
swarm_dir = swarm_runs_root()
swarm_dir.mkdir(parents=True, exist_ok=True)
return SwarmStore(base_dir=swarm_dir)
def _run_to_dict(run) -> dict:
from src.swarm.serialization import run_level_error, serialize_task
return {
"run_id": run.id,
"status": run.status.value,
"preset": run.preset_name,
"created_at": run.created_at,
"error": run_level_error(run),
"tasks": [serialize_task(t) for t in run.tasks],
"final_report": run.final_report,
"total_input_tokens": run.total_input_tokens,
"total_output_tokens": run.total_output_tokens,
}
@mcp.tool
def get_swarm_status(run_id: str) -> str:
"""Get the current status of a swarm run.
Returns status, task progress, and token usage for the specified run.
Use this to poll a long-running swarm without blocking.
Args:
run_id: The run ID returned by run_swarm.
"""
store = _get_swarm_store()
run = store.load_run(run_id)
if run is None:
return json.dumps({"status": "error", "error": f"Run {run_id} not found"}, ensure_ascii=False)
return json.dumps(_run_to_dict(run), ensure_ascii=False, indent=2)
@mcp.tool
def get_run_result(run_id: str) -> str:
"""Get the final report and task summaries of a completed swarm run.
Returns the final_report text and per-task summaries. If the run is
still in progress, returns current status instead.
Args:
run_id: The run ID returned by run_swarm.
"""
store = _get_swarm_store()
run = store.load_run(run_id)
if run is None:
return json.dumps({"status": "error", "error": f"Run {run_id} not found"}, ensure_ascii=False)
return json.dumps(_run_to_dict(run), ensure_ascii=False, indent=2)
@mcp.tool
def list_runs(limit: int = 20) -> str:
"""List recent swarm runs sorted by creation time (newest first).
Returns run IDs, presets, statuses, and creation timestamps.
Use get_run_result(run_id) to fetch full details for a specific run.
Args:
limit: Maximum number of runs to return (default 20).
"""
store = _get_swarm_store()
runs = store.list_runs(limit=limit)
items = []
for run in runs:
items.append(
{
"run_id": run.id,
"preset": run.preset_name,
"status": run.status.value,
"created_at": run.created_at,
"total_input_tokens": run.total_input_tokens,
"total_output_tokens": run.total_output_tokens,
}
)
return json.dumps(items, ensure_ascii=False, indent=2)
# ---------------------------------------------------------------------------
# Trade journal tool
# ---------------------------------------------------------------------------
@mcp.tool
def analyze_trade_journal(
file_path: str,
analysis_type: str = "full",
filter_expr: str = "",
) -> str:
"""Analyze a user's trade journal (CSV/Excel broker export) and return
a trading profile plus behavior diagnostics.
Parses 同花顺 / 东方财富 / 富途 / generic formats (encoding auto-detected).
Output (JSON):
- profile: holding days, frequency, win rate, PnL ratio, top symbols,
market distribution, hourly distribution
- behaviors: disposition effect, overtrading, chasing momentum,
anchoring (each with severity + numeric evidence)
Args:
file_path: Absolute path to the uploaded CSV/Excel file.
analysis_type: "full" | "profile" | "behavior" | "strategy".
filter_expr: Optional filter (e.g. "2026-01 to 2026-03",
"symbol=600519.SH", "market=china_a").
"""
registry = _get_registry()
return registry.execute(
"analyze_trade_journal",
{
"file_path": file_path,
"analysis_type": analysis_type,
"filter_expr": filter_expr,
},
)
# ---------------------------------------------------------------------------
# Shadow Account tools (4)
# ---------------------------------------------------------------------------
@mcp.tool
def extract_shadow_strategy(
journal_path: str,
min_support: int = 3,
max_rules: int = 5,
) -> str:
"""Extract a Shadow Account profile (3-5 human-readable if-then rules)
from the user's profitable roundtrips in a trade journal.
Run `analyze_trade_journal` first if the journal hasn't been parsed.
Returns shadow_id + rules preview. Profile persists to
~/.vibe-trading/shadow_accounts/.
Args:
journal_path: Path to the CSV/Excel broker export.
min_support: Minimum profitable roundtrips required to back one rule.
max_rules: Maximum rules to return (typically 3-5).
"""
registry = _get_registry()
return registry.execute(
"extract_shadow_strategy",
{
"journal_path": journal_path,
"min_support": min_support,
"max_rules": max_rules,
},
)
@mcp.tool
def run_shadow_backtest(
shadow_id: str,
window_start: str = "",
window_end: str = "",
markets: list[str] | None = None,
journal_path: str = "",
) -> str:
"""Run a multi-market backtest (A股/港股/美股/crypto) on a Shadow Account
profile and compute delta-PnL attribution vs the user's realized trades.
Requires `extract_shadow_strategy` to have run first.
Args:
shadow_id: ID returned by extract_shadow_strategy.
window_start: ISO date, default today-1y.
window_end: ISO date, default today.
markets: Subset of ["china_a", "hk", "us", "crypto"], default all four.
journal_path: Original journal path (enables attribution), optional.
"""
registry = _get_registry()
params: dict[str, Any] = {"shadow_id": shadow_id}
if window_start:
params["window_start"] = window_start
if window_end:
params["window_end"] = window_end
if markets:
params["markets"] = markets
if journal_path:
params["journal_path"] = journal_path
return registry.execute("run_shadow_backtest", params)
@mcp.tool
def render_shadow_report(
shadow_id: str,
include_today_signals: bool = True,
window_start: str = "",
window_end: str = "",
journal_path: str = "",
) -> str:
"""Render the Shadow Account HTML/PDF report (8 sections + charts) for
a shadow_id. If no cached backtest, one is run automatically.
Args:
shadow_id: Shadow Account ID.
include_today_signals: Include today's market scan section.
window_start: Optional backtest window override.
window_end: Optional backtest window override.
journal_path: Original journal path (for attribution), optional.
"""
registry = _get_registry()
params: dict[str, Any] = {
"shadow_id": shadow_id,
"include_today_signals": include_today_signals,
}
if window_start:
params["window_start"] = window_start
if window_end:
params["window_end"] = window_end
if journal_path:
params["journal_path"] = journal_path
return registry.execute("render_shadow_report", params)
@mcp.tool
def scan_shadow_signals(
shadow_id: str,
date: str = "",
per_market: int = 3,
) -> str:
"""List today's symbols that match the Shadow Account's entry cadence
(research use only — not a trade recommendation).
Args:
shadow_id: Shadow Account ID.
date: ISO YYYY-MM-DD target date, default today.
per_market: Max signals per market.
"""
registry = _get_registry()
params: dict[str, Any] = {"shadow_id": shadow_id, "per_market": per_market}
if date:
params["date"] = date
return registry.execute("scan_shadow_signals", params)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main():
"""Entry point for `vibe-trading-mcp` CLI command."""
global _include_shell_tools, _registry
import argparse
parser = argparse.ArgumentParser(description="Vibe-Trading MCP Server")
parser.add_argument("--transport", choices=["stdio", "sse"], default="stdio", help="MCP transport (default: stdio)")
parser.add_argument("--port", type=int, default=8900, help="SSE port (only used with --transport sse)")
args = parser.parse_args()
_include_shell_tools = True if args.transport == "stdio" else _env_shell_tools_enabled()
_registry = None
_get_registry() # pre-warm: avoids deadlock when first tools/call lazy-inits inside FastMCP worker thread
if args.transport == "sse":
mcp.run(transport="sse", port=args.port)
else:
mcp.run()
if __name__ == "__main__":
main()