You want a research process that stress-tests investment ideas before capital gets committed. A single LLM call produces a plausible narrative; two agents with opposing incentives produce a pressure-tested thesis. This tutorial builds a CrewAI pipeline where an analyst builds the bull case and a skeptic tears it down, both grounded in live market data.
Prerequisites
- Python 3.10+
- An OpenAI-compatible API key (OpenRouter, n4n.ai, or direct OpenAI)
crewai,crewai-tools,yfinance,pandas,python-dotenv
pip install crewai crewai-tools yfinance pandas python-dotenv
Create a .env file:
OPENAI_API_KEY=your_key_here
OPENAI_API_BASE=https://api.n4n.ai/v1 # or your preferred OpenAI-compatible endpoint
OPENAI_MODEL_NAME=gpt-4o-mini
The OPENAI_API_BASE points to any OpenAI-compatible gateway. Using a gateway that supports 200+ models with automatic fallback means you can swap models without changing code when a provider is rate-limited.
Project structure
finance_crew/
├── .env
├── config/
│ ├── agents.yaml
│ └── tasks.yaml
├── tools/
│ └── market_data.py
├── main.py
└── output/
Market data tool
CrewAI tools wrap callable functions. We’ll expose a minimal surface: price history, fundamentals, and news sentiment.
# tools/market_data.py
import yfinance as yf
import pandas as pd
from crewai.tools import BaseTool
from typing import Type
from pydantic import BaseModel, Field
class TickerInput(BaseModel):
ticker: str = Field(..., description="Stock ticker symbol, e.g., AAPL")
class PriceHistoryTool(BaseTool):
name: str = "price_history"
args_schema: Type[BaseModel] = TickerInput
def _run(self, ticker: str) -> str:
df = yf.Ticker(ticker).history(period="1y")
if df.empty:
return f"No data for {ticker}"
# Return last 30 rows as CSV string for token efficiency
return df.tail(30).to_csv()
class FundamentalsTool(BaseTool):
name: str = "fundamentals"
args_schema: Type[BaseModel] = TickerInput
def _run(self, ticker: str) -> str:
info = yf.Ticker(ticker).info
keys = [
"trailingPE", "pegRatio", "debtToEquity", "returnOnEquity",
"profitMargins", "currentRatio", "freeCashflow", "revenueGrowth"
]
data = {k: info.get(k) for k in keys}
return pd.Series(data).to_json()
class NewsSentimentTool(BaseTool):
name: str = "news_sentiment"
args_schema: Type[BaseModel] = TickerInput
def _run(self, ticker: str) -> str:
news = yf.Ticker(ticker).news
if not news:
return "No recent news"
headlines = [n.get("title", "") for n in news[:10]]
return "\n".join(f"- {h}" for h in headlines)
Agent definitions
YAML keeps prompts version-controllable and separate from orchestration logic.
# config/agents.yaml
analyst:
role: "Equity Research Analyst"
goal: >
Build a rigorous, data-driven bull case for {ticker} using fundamentals,
technical trends, and catalysts. Cite specific metrics from tools.
backstory: >
You are a CFA charterholder with 15 years covering technology and growth stocks.
You've learned that conviction requires evidence, not narrative. You always
quantify your claims: revenue growth %, margin trajectory, FCF yield, valuation
vs. comps. You flag assumptions explicitly.
allow_delegation: false
verbose: true
tools:
- price_history
- fundamentals
- news_sentiment
skeptic:
role: "Short-Side Skeptic"
goal: >
Stress-test the bull case for {ticker}. Identify key risks, flawed assumptions,
valuation stretch, and catalysts that could break the thesis. Use the same data.
backstory: >
You ran a long/short fund that survived 2008 and 2020 by asking "what breaks this?"
You look for: revenue concentration, customer churn, accounting red flags,
insider selling, competitive moat erosion, and macro sensitivity. You demand
a margin of safety.
allow_delegation: false
verbose: true
tools:
- price_history
- fundamentals
- news_sentiment
synthesizer:
role: "Research Synthesizer"
goal: >
Produce a final investment memo that integrates the analyst's bull case and
the skeptic's bear case into a clear recommendation with conviction level.
backstory: >
You sit on an investment committee. You've seen analysts fall in love with
stories and skeptics miss structural shifts. Your job: weigh evidence, highlight
the swing factors, and output a decision framework — not just a summary.
allow_delegation: false
verbose: true
Task definitions
Tasks chain outputs. The analyst runs first, the skeptic receives the analyst’s output, the synthesizer receives both.
# config/tasks.yaml
analyst_task:
Research {ticker} and produce a structured bull case covering:
1. Business model & moat (2-3 sentences)
2. Key fundamentals table (PE, PEG, D/E, ROE, FCF yield, revenue growth)
3. Technical context: 50/200 DMA, recent volume, support/resistance
4. Catalysts: product cycles, margin drivers, TAM expansion
5. Valuation framework: DCF assumptions or comps-based target
6. Explicit assumptions & confidence level (1-10)
Use tools to fetch data. Cite specific numbers.
expected_output: >
A markdown report with sections labeled exactly as above.
Include a "Key Metrics" table in markdown format.
agent: analyst
skeptic_task:
Review the analyst's bull case for {ticker} and produce a structured bear case:
1. Top 3 risks that could cut the thesis in half
2. Valuation stress test: what assumptions must hold for current price?
3. Fundamental red flags: deteriorating margins, FCF conversion, balance sheet
4. Technical warning signs: divergence, volume dry-up, broken levels
5. Catalyst risks: execution, competition, regulation, macro
6. Short interest & institutional ownership changes (if available)
7. Probability-weighted downside scenario
Reference the analyst's specific claims by section.
expected_output: >
A markdown report with sections labeled exactly as above.
Include a "Risk Matrix" table: Risk | Likelihood | Impact | Mitigation.
agent: skeptic
context: [analyst_task]
synthesizer_task:
Synthesize the analyst and skeptic reports for {ticker} into an investment memo:
1. Executive summary (3 bullets)
2. Thesis vs. Antithesis comparison table
3. Swing factors: the 2-3 variables that determine the outcome
4. Recommendation: Buy / Hold / Sell / Avoid with conviction (1-10)
5. Position sizing guidance: max portfolio weight, stop-loss logic
6. Monitoring plan: specific metrics & thresholds to watch quarterly
expected_output: >
A complete investment memo in markdown, ready for an investment committee.
agent: synthesizer
context: [analyst_task, skeptic_task]
Main orchestration
# main.py
import os
import yaml
from crewai import Agent, Task, Crew, Process, LLM
from tools.market_data import PriceHistoryTool, FundamentalsTool, NewsSentimentTool
from dotenv import load_dotenv
load_dotenv()
# --- LLM ---
llm = LLM(
model=os.getenv("OPENAI_MODEL_NAME", "gpt-4o-mini"),
base_url=os.getenv("OPENAI_API_BASE"),
api_key=os.getenv("OPENAI_API_KEY"),
temperature=0.1,
)
# --- Tools ---
tools = [PriceHistoryTool(), FundamentalsTool(), NewsSentimentTool()]
tool_map = {t.name: t for t in tools}
# --- Load configs ---
with open("config/agents.yaml") as f:
agent_configs = yaml.safe_load(f)
with open("config/tasks.yaml") as f:
task_configs = yaml.safe_load(f)
def build_agents(ticker: str) -> dict[str, Agent]:
agents = {}
for key, cfg in agent_configs.items():
agent_tools = [tool_map[name] for name in cfg.pop("tools", [])]
# Inject ticker into goal/backstory
cfg["goal"] = cfg["goal"].format(ticker=ticker)
cfg["backstory"] = cfg["backstory"].format(ticker=ticker)
agents[key] = Agent(llm=llm, tools=agent_tools, **cfg)
return agents
def build_tasks(agents: dict[str, Agent], ticker: str) -> list[Task]:
tasks = []
task_objects = {}
for key, cfg in task_configs.items():
cfg["description"] = cfg["description"].format(ticker=ticker)
cfg["expected_output"] = cfg["expected_output"].format(ticker=ticker)
agent = agents[cfg.pop("agent")]
context = [task_objects[c] for c in cfg.pop("context", [])]
task = Task(agent=agent, context=context, **cfg)
tasks.append(task)
task_objects[key] = task
return tasks
def run_crew(ticker: str) -> str:
agents = build_agents(ticker)
tasks = build_tasks(agents, ticker)
crew = Crew(
agents=list(agents.values()),
tasks=tasks,
process=Process.sequential,
verbose=True,
)
result = crew.kickoff()
return str(result)
if __name__ == "__main__":
import sys
ticker = sys.argv[1] if len(sys.argv) > 1 else "NVDA"
print(f"\n=== Running finance research crew for {ticker} ===\n")
output = run_crew(ticker)
os.makedirs("output", exist_ok=True)
with open(f"output/{ticker}_memo.md", "w") as f:
f.write(output)
print(f"\n=== Memo saved to output/{ticker}_memo.md ===\n")
Run it
python main.py NVDA
Expected checkpoint: Analyst output (first ~30 seconds)
You’ll see the analyst invoke tools in sequence:
[DEBUG] == Working Agent: Equity Research Analyst
[DEBUG] == Tool: price_history | Args: {"ticker": "NVDA"}
[DEBUG] == Tool: fundamentals | Args: {"ticker": "NVDA"}
[DEBUG] == Tool: news_sentiment | Args: {"ticker": "NVDA"}
The analyst produces a structured markdown report. Key metrics table example:
| Metric | Value |
|---|---|
| Trailing PE | 62.4 |
| PEG Ratio | 1.15 |
| Debt/Equity | 0.18 |
| ROE | 91.2% |
| Profit Margin | 55.0% |
| Current Ratio | 4.1 |
| FCF (TTM) | $47.2B |
| Revenue Growth (YoY) | 125% |
Expected checkpoint: Skeptic output (next ~30 seconds)
The skeptic receives the analyst’s full output as context and produces a risk matrix:
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| China export restrictions | High | Severe | Diversify data center geography |
| Margin compression from competition | Medium | High | Monitor AMD/Intel roadmap, custom silicon |
| Cyclical demand normalization | High | Medium | Track hyperscaler capex commentary |
| Valuation re-rating | Medium | High | Require FCF yield > 3% for entry |
Final output: Synthesizer memo
The file output/NVDA_memo.md contains the complete investment memo. Example executive summary:
## Executive Summary
- **Thesis**: NVDA dominates AI compute with a 2-year moat; FCF inflection justifies premium
- **Antithesis**: Peak capex cycle priced in; China restrictions + competition = multiple compression risk
- **Swing factors**: (1) H100/H200 sell-through vs. Blackwell ramp, (2) China revenue trajectory, (3) Hyperscaler capex guidance at Q2 calls
Iteration hooks
Swap models without code changes
Change OPENAI_MODEL_NAME in .env to claude-3-5-sonnet-20241022 or meta-llama/llama-3.1-405b-instruct. The gateway handles routing. If one provider degrades, automatic fallback keeps the crew running.
Add a third agent: quantitative validator
# config/agents.yaml (add)
quant_validator:
role: "Quantitative Validator"
goal: >
Run a Monte Carlo simulation on {ticker} DCF using analyst assumptions.
Output probability distribution of fair value.
backstory: >
You build probabilistic valuation models. You know point estimates are
dangerous. You stress-test every driver: revenue growth, margins, WACC,
terminal growth. You output percentiles, not targets.
allow_delegation: false
verbose: true
tools: [] # Uses analyst output as context only
Add a task that consumes analyst_task and skeptic_task, runs a Python DCF (you’d write a DCFTool), and feeds the synthesizer.
Persist to a database
Replace the file write in main.py with a Postgres upsert:
import psycopg2
import json
def save_memo(ticker: str, memo: str, raw_outputs: dict):
conn = psycopg2.connect(os.getenv("DATABASE_URL"))
with conn.cursor() as cur:
cur.execute("""
INSERT INTO research_memos (ticker, memo_md, agent_outputs, created_at)
VALUES (%s, %s, %s, NOW())
ON CONFLICT (ticker) DO UPDATE SET
memo_md = EXCLUDED.memo_md,
agent_outputs = EXCLUDED.agent_outputs,
updated_at = NOW()
""", (ticker, memo, json.dumps(raw_outputs)))
conn.commit()
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Analyst hallucinates metrics | Tools not invoked | Lower temperature, verify tools list matches tool names exactly |
| Skeptic repeats analyst | Context not passed | Ensure context: [analyst_task] in skeptic task config |
| Crew hangs | Rate limits | Use a gateway with automatic fallback; add max_rpm to Crew |
| Output truncated | Token limits | Summarize tool outputs in tools (we used .tail(30)), or use a larger context model |
Extending the pattern
This architecture generalizes:
- Credit research: Analyst = credit analyst, Skeptic = distressed debt PM, Synthesizer = credit committee memo
- M&A analysis: Analyst = strategic rationale, Skeptic = integration risk, Synthesizer = fairness opinion framework
- Macro strategy: Analyst = bull case for rates/equities/FX, Skeptic = bear case, Synthesizer = portfolio positioning
The invariant: opposing incentives + shared data + structured synthesis = better decisions than any single prompt.
Run python main.py AAPL next. Compare the memo structure. The skeleton stays the same; the evidence changes. That’s the point.