n4nAI

CrewAI finance research: analyst and skeptic agents

Build a multi-agent finance research system with CrewAI — analyst and skeptic agents that debate investment theses using real market data.

n4n Team2 min read520 words

Audio narration

Coming soon — every post will get a voice note here.

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.

Tagscrewaifinancemulti-agentresearch

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All framework tutorials: finance & trading analysis agents posts →