股票分析代理工具

一个用于教学的多代理股票分析工具,通过四个代理(研究专家、技术分析师、行业分析师和报告撰写者)协同工作,分析股票并提供报告。

  • by zviba
  • Python
  • 本地部署
  • 更新于
在 GitHub 查看 zviba/mcp_stocks_demo_crewai_exercise

使用说明

CrewAI + MCP Stocks Analysis — Sector Agent Exercise (solved)

A teaching exercise for Agents & MCP, carrying the completed solution.

Four CrewAI agents analyse a stock by calling a real MCP server over stdio. Every call they make is recorded, and a deterministic check afterwards compares the finished report against what the tools actually returned — so you can prove whether the report is grounded rather than hoping it is.

The exercise was: add a fourth agent, a Sector Analyst, that compares the target stock against its sector peers. It is done, and the interesting part is how little Python it took. See The exercise below.

What changed in this version. Two things, and the second is the reason for the first.

  1. The architecture caught up with the demo. The previous version of this repo decorated functions with @mcp.tool() and then imported those same functions directly into agents.py, re-wrapping them with CrewAI's @tool. No server was ever started and no JSON-RPC ever crossed a process boundary — the "MCP" was decoration in the literal sense. It also depended on fastmcp, a different package from the one the code imported. Now the crew spawns python -m stocks_crew.mcp_server and talks to it as a client, exactly the way Claude Desktop or another team's agent would. Packaging moved from requirements.txt to uv, and the flat agents.py / mcp_server.py / datasource.py at the repo root became the src/stocks_crew/ package.
  2. The sector agent moved out of Python and into YAML. It used to be an Agent(...) literal in create_agents(), a Task(...) literal in create_tasks(), and a tasks[2].agent = agents["sector"] line you had to remember to add. It is now a block in config/agents.yaml and a block in config/tasks.yaml. crew.py did not change.

Its task brief changed too, and not cosmetically — the old one asked for numbers no tool in this project can return. That is the sharpest lesson in the exercise; it has its own section.


Quick start

Requires uv:

curl -LsSf https://astral.sh/uv/install.sh | sh

Then:

uv sync                     # creates .venv and installs everything from uv.lock
cp .env.sample .env         # add OPENAI_API_KEY or GEMINI_API_KEY (either one)
uv run streamlit run streamlit_crewai_app.py

The first uv sync pulls CrewAI, chromadb and the OpenAI and Google SDKs — expect a few hundred MB and a minute or two. Subsequent syncs are instant.

That is the whole thing. There is no API server to start first — the old instructions in this repo told you to run uvicorn api:app before the Streamlit app, and that is no longer true (it was never needed for the CrewAI path). The crew runs inside the Streamlit process and launches the MCP subprocess itself. The FastAPI bridge is a separate, optional entry point for HTTP clients:

uv run uvicorn stocks_crew.api:app --host 0.0.0.0 --port 8001 --reload

Prove the MCP server is real before you spend a token:

uv run python -m stocks_crew.crew --list-tools    # real handshake, prints the tool list
uv run stocks-crew-mcp                            # the server itself; silent stdout is correct
npx @modelcontextprotocol/inspector uv run stocks-crew-mcp

Architecture

                    ┌────────────────────────┐
   browser ────────▶│ streamlit_crewai_app   │  :8501
                    └───────────┬────────────┘
                                │ in-process call
                    ┌───────────▼────────────┐        ┌──────────────────┐
                    │   stocks_crew.crew     │◀──────▶│ stocks_crew.trace│
                    │   4 agents, sequential │ events │  the tool trace  │
                    └───────────┬────────────┘        └──────────────────┘
                                │ MCPServerAdapter (stdio, JSON-RPC)
                    ┌───────────▼────────────┐
                    │ stocks_crew.mcp_server │  subprocess, FastMCP
                    └───────────┬────────────┘
                                │
                    ┌───────────▼────────────┐
                    │ tools → analytics      │  pandas + yfinance
                    │        → datasource    │
                    └────────────────────────┘

    optional:  stocks_crew.api  (:8001)  ── data routes call tools/ directly,
                                            /analyze runs the crew over MCP

How the crew talks to the MCP server

crew.py spawns the server as a child process and speaks MCP over its stdin/stdout:

params = StdioServerParameters(
    command=sys.executable,
    args=["-m", "stocks_crew.mcp_server"],
    env={**os.environ},
)
with MCPServerAdapter(params) as mcp_tools:
    ...  # mcp_tools are now CrewAI tools the agents can call

Each run spawns a fresh MCP subprocess (~1s of startup). That is deliberate for a demo: you can watch the process appear and see the tool calls in the logs.

Consequence: nothing in the server may write to stdout, since stdout is the protocol channel. Use logging (which goes to stderr). This is why datasource.py logs its yfinance column debug instead of print()ing it.

The crew

Four agents, run sequentially, all on the same model — openai/gpt-4o-mini or gemini/gemini-2.5-flash, depending on which provider the run picked (see Choosing a model provider):

Agent MCP tools Job
Research Specialist search_symbols, latest_quote, price_series Confirm the symbol is real, get the current quote and recent history.
Technical Analyst indicators, detect_events Read SMA/EMA/RSI and the gap / volatility / 52-week flags.
Sector Analyst search_symbols, latest_quote, indicators Confirm 3–5 peers, fetch each one's quote and indicators, place the target among them.
Report Writer none Synthesize the three into a report. A writer with tools re-fetches data and quietly contradicts the analysts.

Roles, backstories and task briefs live in src/stocks_crew/config/*.yaml, so a prompt can be tweaked without touching Python. Task order in tasks.yaml is the run order.


The exercise

Goal: add a fourth agent, a Sector Analyst, that compares the target stock against its sector peers.

What it took

Two YAML blocks. crew.py builds whatever agents.yaml declares and runs whatever tasks.yaml declares, in file order, so a new agent is a new block:

# src/stocks_crew/config/agents.yaml
sector:
  role: Sector Comparison Specialist
  goal: >
    Place {symbol} against three to five peers traded in the same sector, using
    only quote and indicator values fetched for each peer.
  backstory: >
    ...
  tools:
    - search_symbols
    - latest_quote
    - indicators
# src/stocks_crew/config/tasks.yaml
sector:
  description: |
    ...
  expected_output: >
    ...
  agent: sector
  context:
    - research
    - technical

Placement matters in both files, for different reasons:

  • In agents.yaml, block order sets the column order in the Streamlit UI.
  • In tasks.yaml, order is execution order. sector must come after research and technical, because context may only name tasks that have already run — and before report, which consumes its output.

Then one edit that is easy to forget: the report task had to be told the sector output exists. That means adding - sector to its context and a sector section to its brief. Without it the sector agent runs, spends tokens, and its work never reaches the reader — the quietest possible way for this exercise to look like it worked. tests/test_crew.py::test_the_report_task_is_given_the_sector_comparison pins it.

What did not change

crew.py — no new Agent(...), no new Task(...), no tasks[2].agent = .... Compare with the integration checklist the old version of this exercise needed:

Old (Python) Now (YAML)
sector_agent = Agent(...) in create_agents() a block in agents.yaml
add "sector" to the returned agents dict — (the dict is the YAML)
sector_task = Task(...) in create_tasks() a block in tasks.yaml
add sector_task to the returned list — (file order is the list)
tasks[2].agent = agents["sector"] agent: sector in the task block
renumber tasks[3].agent = agents["report"] — (nothing is indexed)
col1, col2, col3, col4 = st.columns(4) — (st.columns(len(defaults)))

Four of those seven steps existed only to keep two Python lists in sync by index, and "wrong task index" was on the old hint file's list of common mistakes. That class of bug is now unrepresentable.

The Python that did change is cosmetic, and only where a hardcoded number had crept in:

  • streamlit_crewai_app.py — a display label for sector, and of 3 → of {len(defaults)} on the agent cards. An agent with no label still gets a column, labelled with its key.
  • src/stocks_crew/api.py — sector_tools added to the deprecated per-agent fields, so requests written against the old API still land on the right agent.
  • crew.py — the "running research → technical → report…" progress line now derives that list from the config instead of spelling it out.

The P/E trap

The obvious sector brief — the one the previous version of this exercise shipped — asks for:

  1. Price performance (1M, 3M, 6M, 1Y)
  2. Key financial metrics (P/E ratio, market cap, revenue growth)
  3. Technical indicators (RSI, moving averages, volatility)
  4. Sector positioning and competitive advantages

Read that against the tool table. This desk serves quotes, OHLCV history, SMA/EMA/RSI and event flags. It returns no P/E ratio, no market cap, no revenue growth. Item 2 asks the agent for numbers it has no way to obtain — so it supplies them from the model's memory of some earlier year, stated with exactly the same confidence as the figures it actually fetched.

On the old architecture nothing noticed. On this one, guardrails.py compares every figure in the report against the full output of every tool call, and those invented metrics come back as ungrounded_figures in red.

The fix is not a softer guardrail. It is a brief that asks for what the tools can supply, and says so out loud:

Do NOT report P/E ratios, market capitalisation, revenue growth, analyst ratings or index membership. No tool on this desk returns them, so any figure you write for them would be invented. State once that valuation and fundamental metrics are outside the available tools, and move on.

The general rule: an agent's job description is bounded by its tools. Writing a task that outruns them is the most common way to manufacture a hallucination, and it is a design mistake, not a model failure. Run the old brief once with the guardrail panel open — it is worth the tokens.

There is a second, subtler gap here, and it is a good thing to argue about in class. The peer tickers are the model's own suggestion — no tool returns a peer list. The brief requires each one to be confirmed with search_symbols, which proves the ticker exists but not that it is genuinely a peer. So the peers' numbers are grounded and their relevance is not, and the grounding guardrail cannot tell the difference. Ask the room how they would check the second thing.


The tool trace — the point of the demo

The task prompts say things like "Call latest_quote. Do not estimate prices." That is a prompt, not a guarantee. The trace is how you find out whether the model complied.

trace.py subscribes to crewai_event_bus and records one entry per tool call: name, the arguments the model chose, both timestamps, and a preview of the output. It listens to the framework rather than wrapping the tools, because after this refactor there is no local function left to wrap — the tools live in another process behind MCP.

Things worth doing in class:

  • Take latest_quote away from the Research agent in the sidebar and re-run. Watch the report still quote a price, and watch the trace show where it did not come from.
  • Take search_symbols away from the sector agent. It can no longer confirm a peer exists, and the brief tells it to drop unconfirmed candidates — see whether it does, or whether it quietly compares against tickers it never checked.
  • Ask for a symbol that Yahoo has no data for. The tools answer {"error": "no_data"} in band rather than raising, the agent is supposed to say so, and the trace shows the call really happened.
  • Run with an empty tool list for every agent. The crew produces a confident, entirely fabricated report and the trace is empty. That contrast is the lesson.

Note what the sector agent does to the trace: it calls up to three tools per peer, so a five-peer run is a dozen-plus calls against the two or three of a run without it. Cost scales with the crew, and it scales in the trace, where you can see it.

There is no explain tool

The old server exposed an explain tool that called OpenAI from inside the MCP process, and the old Technical Analyst listed it among its tools. It is gone. The LLM layer sits above MCP, in the crew that consumes these tools — if the crew's own output were also a tool, the crew could call itself. tests/test_mcp_server.py pins this.

Guardrails

Three of our own, at three different points in the run — plus the framework's, which is worth a section of its own for what it does not do.

On the input. symbol is untrusted text on its way into four agent prompts and into MCP tool arguments, so it is validated (crew.validate_symbol) before it can become instructions: letters, digits, . and -, up to 10 characters. A bad ticker is a 422 from the API and an invalid_symbol result from run_analysis — before any prompt or subprocess runs.

On the agents' reach. The tools: list in agents.yaml is an allow-list per agent, applied when the crew is built. The report writer has none. The sector agent deliberately lacks price_series: a year of history for five peers is thousands of rows through the model's context, for a comparison latest_quote and indicators already support. An allow-list is a budget as much as a boundary.

On the output. guardrails.py runs after crew.kickoff() and checks the finished report against the tool output the agents actually received:

  • Grounding — every figure in the report must match a number some tool returned. This is the trace turned from evidence into a verdict.
  • Advice — the task briefs forbid investment advice, price targets and predictions. This checks whether that held.

It is deterministic Python, not a second model, because a guardrail that can hallucinate is not a guardrail. The result rides back on the run_analysis result as guardrail and is rendered between the report and the trace: claim, verdict, evidence.

Things worth arguing about in class, all of them visible in guardrails.py:

  • It reports, it does not block. Should a failed check withhold the report instead, or hand it back to the writer for one retry? Where does that loop end?
  • Its tolerances are chosen to under-report. It accepts rounding (187.7712 → 187.77), rescaling (44,123,456 → "44.1 million") and a sign carried by the prose ("down 1.24" for -1.24), and it ignores bare integers under 1000 because indicator windows are not market figures. Every one of those is a hole a determined hallucination could fit through. The alternative is a guardrail that fires on every honest run and gets switched off in week two.
  • The advice check matches phrases, never the bare word "buy" — otherwise the mandatory disclaimer ("not a recommendation to buy or sell") fails the report that complied. tests/test_guardrails.py pins that case.
  • It checks against ToolTracer.evidence, the full tool output, not the 240-character result_preview the UI shows. A display limit is not a reason to blind the check.
  • The sector agent widens one of these holes, and it is worth watching. Five peers' worth of quotes and indicators is a much larger pool of grounded numbers, so a coincidental match is likelier — a figure can pass as "grounded" while being grounded in the wrong peer's data. The check answers "where did this number come from?", never "is this sentence true".

And the framework's own. CrewAI ships HallucinationGuardrail, attached to a task rather than run afterwards. The report task opts into it in tasks.yaml:

report:
  ...
  hallucination_guardrail: true    # or a mapping: {threshold: 8.0}
from crewai.tasks.hallucination_guardrail import HallucinationGuardrail

Task(..., guardrail=HallucinationGuardrail(llm=llm))

In the open-source crewai package this class does nothing. It is a placeholder whose __call__ returns (True, output) for every input and logs "Hallucination detection is a no-op in open source, use it for free at https://app.crewai.com". threshold and context are stored and ignored. Hand it a report saying a stock trades at 999999.00 and that you should buy it, and it passes. The real implementation is installed by the hosted platform, which sets the module-level _validate_output_hook the placeholder defers to.

It is wired up here anyway, and crew.guardrail_status() reports enforcing: False so the UI can say so in as many words. Four things worth taking from that:

  • A guardrail nobody has verified is a liability, not a safeguard. The import succeeds, the constructor succeeds, the task runs, the logs look healthy. The only signal that nothing is being checked is a warning line the size of every other warning line. Check what your guardrails actually do before you count them.
  • It could not do the job here even if it were live. It is constructed before kickoff(), so the tool output the report should be grounded against does not exist yet — the best reference context available at that moment is the task's own expected_output. guardrails.py runs after and gets the real evidence. When a check runs decides what it is able to know.
  • Deterministic beats delegated for this question. "Does 187.77 appear in any tool result?" is settled by arithmetic in 190 lines that run offline, on any machine, with no vendor account. Asking a model costs a call, adds latency, and puts a thing that can hallucinate in charge of catching hallucinations.
  • It does not close this exercise's open gap either. The sector agent's peer choice is ungrounded by construction — see "The gap that remains" in EXERCISE_INSTRUCTIONS.md. A faithfulness score over the task output would not catch an airline compared against a bank, because every number in that comparison is real.

Good exam question: what would you have to build to make an LLM judge worth trusting here, and how would you test it?


Project structure

pyproject.toml                deps, entry points, pytest config
uv.lock                       pinned, committed — this is the reproducible bit
streamlit_crewai_app.py       UI (:8501)
src/stocks_crew/
  datasource.py               yfinance + Yahoo search; returns DataFrames/dicts
  analytics.py                pure pandas: SMA, EMA, RSI, gap/vol/52w flags
  tools.py                    the five tool functions; return JSON strings
  mcp_server.py               FastMCP registration + main() -> stdio transport
  crew.py                     the MCP client + the crew builder
  llm.py                      which provider and model the crew runs on
  trace.py                    records tool calls off the CrewAI event bus
  guardrails.py               output check: report figures vs. tool results
  console.py                  UTF-8 stdout, so CrewAI's emoji do not crash cp1252
  config/agents.yaml          roles, goals, backstories, per-agent tool lists
  config/tasks.yaml           task briefs, run order, context wiring
  api.py                      optional FastAPI bridge (:8001)
tests/                        pytest; no network or LLM calls by default
hints/                        the exercise hints, rewritten for the YAML crew
EXERCISE_INSTRUCTIONS.md      what the exercise was, and how it was solved
review-pr/                    an unrelated Claude Code skill; not part of the demo

MCP tools

Served by stocks-crew-mcp, all returning JSON strings:

Tool Arguments
search_symbols query
latest_quote symbol
price_series symbol, interval="daily", lookback=180
indicators symbol, window_sma=20, window_ema=50, window_rsi=14
detect_events symbol

That is the whole menu. Nothing here returns fundamentals — see The P/E trap.

HTTP API (optional)

Method Path Body Returns
GET /health — {"status": "ok"}
GET /mcp/tools — live handshake: {tools: [{name, description}], count}
GET /agents — each agent and the tools it may call
POST /search {q} array of {symbol, name, region, currency}
POST /quote {symbol} {symbol, price, change, change_percent, volume, timestamp}
POST /series {symbol, interval, lookback} array of OHLCV rows
POST /indicators {symbol, window_sma, window_ema, window_rsi} {symbol, last_close, sma, ema, rsi}
POST /events {symbol} {symbol, date, gap_up, gap_down, vol_spike, is_52w_high, is_52w_low}
GET /providers — {providers: [{name, label, api_key_env, model, configured}], default}
POST /analyze {symbol, provider, api_key, language, tone, horizon_days, tools} {success, result, provider, model, tool_trace, agent_tools, mcp_tools, guardrail, …}

The data routes call tools.py in-process — a button click should not pay for spawning a subprocess. /analyze and /mcp/tools go over MCP, because that is the part the demo is about.

tools on /analyze is a per-agent allow-list keyed by agent name from agents.yaml; omitted agents keep their YAML defaults:

{
  "symbol": "AAPL",
  "tools": {
    "research": ["latest_quote"],
    "technical": ["indicators", "detect_events"],
    "sector": ["search_symbols", "latest_quote"]
  }
}

The older research_tools / technical_tools / sector_tools / report_tools fields still work and are folded into tools.

Tool failures come back as {"error": "<code>", "message": "..."} with a 200, so the UI renders them inline. A malformed request is a real 422.

Choosing a model provider

The crew runs on OpenAI or Google Gemini. You need a key for one of them, not both.

OPENAI_API_KEY=sk-...     # .env — or
GEMINI_API_KEY=AIza...

Which one a given run uses is decided in src/stocks_crew/llm.py, in this order:

  1. the provider named by the caller — the Streamlit sidebar dropdown, or {"provider": "gemini"} in an /analyze body;
  2. LLM_PROVIDER in .env;
  3. whichever provider has a key set;
  4. OpenAI, which is also whose "key required" error you get when nothing is set.

Everything below that decision is vendor-neutral. CrewAI routes on the model string, written in LiteLLM's <provider>/<model> syntax — openai/gpt-4o-mini builds an OpenAI client that reads OPENAI_API_KEY, gemini/gemini-2.5-flash builds a Gemini client that reads GEMINI_API_KEY — so switching providers is one model string and one environment variable:

llm = LLM(model="gemini/gemini-2.5-flash", temperature=0.2)

build_crew() takes that string and never learns which vendor is behind it; the agents, the MCP server and the guardrail do not change at all. That is the point worth showing on screen: the provider is a detail of the LLM layer, not of the agent design — and the sector agent added by this exercise needed no change to run on a different vendor.

CrewAI 1.x calls each vendor through its own SDK rather than through LiteLLM, so crewai[google-genai] is in the dependencies — without it, a gemini/… model has nothing to route to. It also validates the model part against a list of models it knows; a name it does not recognise falls through to LiteLLM, which this project does not install. If you point GEMINI_MODEL or OPENAI_MODEL at something newer than the pinned crewai, upgrade crewai.

GET /providers reports both vendors and which of them currently has a key.

Environment variables

Variable Purpose
OPENAI_API_KEY Key for the OpenAI provider. Without a key for the chosen provider, /analyze returns openai_api_key_required / gemini_api_key_required — there is no offline fallback.
GEMINI_API_KEY Key for the Gemini provider (Google AI Studio).
LLM_PROVIDER Optional; openai or gemini. Which provider a run uses when the caller does not name one.
OPENAI_MODEL Optional; defaults to gpt-4o-mini.
GEMINI_MODEL Optional; defaults to gemini-2.5-flash.
LOG_LEVEL Optional; DEBUG shows the MCP handshake and yfinance internals.

Either model variable takes a bare name or the fully-qualified LiteLLM <provider>/<model> form — the routing prefix is added for you.

The Streamlit sidebar also accepts a provider and a key at runtime, which take precedence. Sidebar keys are stored per provider, so switching vendors never sends an OpenAI key to Google.

Note the key is now needed for any run. The old README said it was "only needed for the AI-powered explanation tool" and that the rest worked without it — true when explain was the only LLM call. The crew itself is the LLM layer now.

Tests

uv run pytest              # unit + a real MCP stdio round trip; no network, no tokens
uv run pytest -m network   # additionally hits Yahoo Finance
uv run pytest -m llm       # additionally runs the crew for real (needs a key, spends tokens)

tests/test_mcp_server.py launches the server as a subprocess and does a real list_tools() handshake — that is the regression test that keeps MCP from silently becoming decorative again.

The sector agent's own tests are in tests/test_crew.py, under "The exercise", and every one of them is an assertion about YAML.

Troubleshooting

  • openai_api_key_required / gemini_api_key_required — no key for the provider the run chose, neither in .env nor typed into the Streamlit sidebar. The code tells you which provider it picked and which variable it looked for; if that is the wrong provider, set LLM_PROVIDER or pass provider explicitly.
  • The crew hangs, or the adapter reports a protocol error — almost always something printing to stdout inside the server process. Check for stray print() calls in datasource.py, analytics.py or tools.py.
  • Empty trace after a successful run — the agents answered without calling anything. That is a finding, not a bug; see "The tool trace" above.
  • The sector section quotes P/E ratios — the brief drifted. No tool returns them; see The P/E trap.
  • A run takes a long time — there is no timeout, and four agents take longer than three: the sector agent calls up to three tools per peer. The old version had a threading.Timer that raised in a timer thread, which could not actually interrupt kickoff(); it was removed rather than left in as reassurance. Interrupt the process, or cap max_iter on the agents in crew.build_crew.
  • Empty series / "No data available" — Yahoo rate-limits aggressively. Wait a minute or try another symbol. With several peers to fetch, the sector agent hits this sooner than the rest of the crew does.
  • [CrewAIEventsBus] Sync handler error … 'charmap' codec can't encode (Windows) — CrewAI's console listener prints emoji and the legacy cp1252 console cannot encode them. Harmless, but noisy in a demo, so the entry points call console.use_utf8_console(). If you import stocks_crew.crew from your own script, call it there too.
  • uv sync picks the wrong Python — .python-version pins 3.11; run uv python install 3.11 if uv cannot find one.

License

For demo/educational purposes only. No investment advice.