Build an AI Stock Research Agent

Build a LangGraph agent that fetches SEC and market data into research briefs.

Introduction

30 Second Summary

Hedge fund analysts spend hours reading financial filings, checking market data, and synthesizing it all into research reports. What if an AI agent could do that same work in under a minute?

In this project, you will build an AI-powered stock research agent that autonomously gathers financial data from multiple sources and produces structured investment briefs. Your agent mirrors the core technology behind platforms like Trata (YC W25), which uses AI agents to compress hours of company analysis into minutes for hedge funds.

What You'll Build

A command-line AI research agent that takes a stock ticker as input and produces a structured "Tension Point Analysis" identifying the 1-3 key debates driving a stock's valuation.

By the end of this project, you'll have:

  • A multi-tool LangGraph agent with state machine orchestration that coordinates data gathering and analysis steps.
  • Custom financial data tools that fetch real fundamentals from SEC EDGAR and live market data from Yahoo Finance.
  • A structured output schema using Pydantic that produces hedge-fund-quality research briefs with clear tension points and recommendations.
  • Secret Mission: Add news sentiment analysis to identify where market narrative diverges from fundamentals.

Are there any prerequisites?

You should be comfortable with basic Python (functions, dictionaries, f-strings) and know how to use the terminal. No prior experience with financial data or AI agents is needed.

Before We Start

Before your AI agent can research stocks, you need a working Python environment with the right libraries installed.

In this step, you will install Python, create an isolated project environment, and configure your OpenAI API key so the agent can call GPT-4o for analysis.

In this step, get ready to:
  • Install Python and create an isolated project environment.
  • Install all required packages for the agent.
  • Configure your OpenAI API key and verify everything works.
Install Python and create your project

Python is the runtime that powers your agent, the financial data tools, and the LangGraph orchestration layer.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (both macOS and Windows).
  • Check which version of Python is installed by running this command:
python --version

✔️ I see version 3.10 or higher

Your Python version is ready. Move on to creating your project folder below.

ⓧ I see an older version

You need Python 3.10 or higher for this project. The libraries we use require it.

  • Visit python.org/downloads and download the latest Python 3 installer for your operating system.
  • Run the installer. On Windows, make sure you check Add Python to PATH before clicking Install Now.
  • Close and reopen your VS Code terminal, then run python --version again to confirm it shows 3.10 or higher.

macOS users

On macOS, the system may respond to python3 --version instead of python --version. If you see a version below 3.10 from the system Python, install a newer version from python.org and use python3 for all commands in this project.

ⓧ Command not found

Python is not installed yet. Let's fix that.

  • Visit python.org/downloads and download the latest Python 3 installer for your operating system.
  • Run the installer. On Windows, make sure you check Add Python to PATH before clicking Install Now.
  • On macOS, double-click the downloaded .pkg file and follow the installer prompts.
  • Close and reopen your VS Code terminal, then run python --version again to confirm it shows 3.10 or higher.

macOS users

On macOS, Python installs as python3. Use python3 --version to verify, and use python3 instead of python for all commands in this project.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir stock-research-agent
cd stock-research-agent
  • Create a virtual environment by running:
python -m venv venv

What is a virtual environment?

A virtual environment is an isolated Python installation inside your project folder. It keeps this project's packages separate from any other Python projects on your machine.

This prevents version conflicts between different projects that might need different versions of the same library.

  • Activate the virtual environment:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

Make sure you are inside the stock-research-agent folder before activating. Run ls (macOS) or dir (Windows) and confirm you see a venv folder listed.

On Windows, if you get an execution policy error, run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned in PowerShell first, then try activation again.

Help me troubleshoot my virtual environment activation

Install the required packages

Your agent needs several libraries for orchestration, AI calls, and financial data retrieval.

  • Install all project dependencies by running this command:
pip install langgraph==1.2.0 langchain-openai==0.3.12 yfinance==1.3.0 pydantic==2.11.3 requests==2.32.3 python-dotenv==1.0.1

What are these packages?

  • langgraph orchestrates your agent as a state machine with defined nodes and edges.
  • langchain-openai connects your agent to OpenAI's GPT-4o model for analysis.
  • yfinance retrieves live market data (price, P/E ratio, analyst targets) from Yahoo Finance.
  • pydantic defines the structured schema for your research brief output.
  • requests handles HTTP calls to the SEC EDGAR API for financial filings.
  • python-dotenv loads your API key from the .env file so you never hardcode secrets.

You should see output ending with Successfully installed followed by a list of package names.

Seeing a permissions error or install failure?

Make sure (venv) appears in your prompt. If it does not, re-run the activation command from the previous substep.

On macOS, if pip is not found, try pip3 instead.

Help me fix my package installation error

Create your environment file and verify the setup

Your agent needs an OpenAI API key to call GPT-4o for the analysis step. You will store it in a .env file to keep it out of your code.

  • If you do not already have an OpenAI API key, go to platform.openai.com, sign in (or create an account), and navigate to API keys to generate a new key.
  • In VS Code, click File in the top menu bar.
  • Click New File.
  • Paste the following into the new file, replacing your-api-key-here with the key you just copied:
OPENAI_API_KEY=your-api-key-here
  • Save the file as .env inside your stock-research-agent folder. Press Cmd+S (macOS) or Ctrl+S (Windows), then type .env as the filename.

Why a .env file?

Storing your API key in a .env file keeps it separate from your code. This means you can share your code without accidentally exposing your credentials.

The python-dotenv library reads this file at runtime and makes the key available to your Python code as an environment variable.

  • Verify that all packages are installed correctly by running this command in your terminal:
python -c "import langgraph; import langchain_openai; import yfinance; import pydantic; import requests; import dotenv; print('All packages imported successfully!')"

You should see All packages imported successfully! printed in your terminal.

Seeing a ModuleNotFoundError?

This means a package did not install correctly. Check that (venv) is still showing in your prompt. If it is not, re-activate the virtual environment and re-run the pip install command.

If a specific package failed, try installing it individually to see the full error message.

Help me fix my ModuleNotFoundError

✔️ Awesome, I've got everything!

Great. Double check you have saved your .env file.

ⓧ I'd like to double check the full code

Here is what your .env file should look like:

OPENAI_API_KEY=your-api-key-here

Your environment is ready. Next up, you will build the financial data tools that pull real numbers from SEC EDGAR and Yahoo Finance.

Build the SEC EDGAR and Yahoo Finance Tools

Your Python environment is ready with all the packages installed and verified. Now it's time to build the tools your AI agent will use to gather real financial data.

Every research analyst needs data sources. Your agent will pull from two: SEC EDGAR for official financial filings, and Yahoo Finance for live market data. In this step, you'll build Python functions that fetch and structure this data so the LLM can reason over it.

In this step, get ready to:
  • Create helper functions to look up companies on SEC EDGAR and fetch their financial data.
  • Build a tool that extracts quarterly revenue, net income, and EPS from official SEC filings.
  • Build a tool that fetches live market data and analyst recommendations from Yahoo Finance.
Create the SEC EDGAR helper functions

SEC EDGAR is the SEC's free public database of all company filings. It exposes a JSON API that lets you fetch structured financial data directly from 10-Q and 10-K filings.

The API identifies companies by a CIK (Central Index Key) number, not by ticker symbol. Your first helper function will translate a ticker like AAPL into its CIK so you can query the right company.

  • Create a new file called tools.py in your project folder by clicking File then New File in VS Code.
  • Save the file as tools.py in your project directory (the same folder as your .env file).
  • Add the imports and CIK lookup helper by pasting this code:
import requests
import yfinance as yf
from langchain_core.tools import tool


def _get_cik_for_ticker(ticker: str) -> str | None:
    """Map a ticker symbol to SEC CIK number."""
    url = "https://www.sec.gov/files/company_tickers.json"
    headers = {"User-Agent": "ResearchAgent research@example.com"}
    response = requests.get(url, headers=headers)
    response.raise_for_status()
    data = response.json()

    ticker_upper = ticker.upper()
    for entry in data.values():
        if entry["ticker"] == ticker_upper:
            return str(entry["cik_str"]).zfill(10)
    return None

What does this code do?

The imports bring in requests for HTTP calls, yfinance for market data, and the @tool decorator from LangChain that lets the LLM call your functions by name.

The _get_cik_for_ticker function downloads SEC's master ticker-to-CIK mapping file and searches for your ticker. It returns the CIK zero-padded to 10 digits (the format the SEC API expects).

The User-Agent header is required by SEC EDGAR for all programmatic requests. Without it, the API will throttle or reject your calls.

Next, you need a helper that fetches a specific financial metric from the SEC's XBRL API. XBRL is the structured data format that companies use when they file financial reports.

  • Add the XBRL concept fetcher below _get_cik_for_ticker by pasting this code:

def _fetch_xbrl_concept(cik: str, concept: str) -> list[dict] | None:
    """Fetch a single XBRL concept for a company."""
    url = f"https://data.sec.gov/api/xbrl/companyconcept/CIK{cik}/us-gaap/{concept}.json"
    headers = {"User-Agent": "ResearchAgent research@example.com"}
    try:
        response = requests.get(url, headers=headers)
        if response.status_code != 200:
            return None
        data = response.json()
        usd_values = data.get("units", {}).get("USD", [])
        return usd_values
    except Exception:
        return None

What does this code do?

This function queries the SEC's companyconcept endpoint for a specific financial metric (like Revenue or Net Income) for a single company.

It returns a list of historical values in USD. If the concept doesn't exist for that company (or the request fails), it returns None so your code can try alternative concept names.

  • Save tools.py.
  • Verify the CIK lookup works by running this in your terminal:
python -c "from tools import _get_cik_for_ticker; print(_get_cik_for_ticker('AAPL'))"

You should see 0000320193 printed in your terminal. This is Apple's CIK number on SEC EDGAR.

Seeing an import error or None?

If you see ModuleNotFoundError, make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.

If you see None instead of a number, check that you typed AAPL with correct capitalization in the test command.

Help me debug my SEC EDGAR helper function

Build the SEC fundamentals tool

Now you'll build the main tool that uses both helpers to extract quarterly financial data. One challenge with SEC EDGAR is concept fragmentation: different companies use different XBRL concept names for the same metric.

For example, Apple reports revenue under RevenueFromContractWithCustomerExcludingAssessedTax, while other companies use Revenues or SalesRevenueNet. Your tool will try multiple concept names until one returns data.

  • Add the get_sec_fundamentals tool below _fetch_xbrl_concept by pasting this code:

@tool
def get_sec_fundamentals(ticker: str) -> dict:
    """Fetch fundamental financial data from SEC EDGAR for a given stock ticker.
    Returns quarterly revenue, net income, and EPS from official SEC filings."""

    cik = _get_cik_for_ticker(ticker)
    if not cik:
        return {"error": f"Could not find CIK for ticker {ticker}"}

    results = {"ticker": ticker, "cik": cik}

    # Try multiple concept names for revenue (handles XBRL fragmentation)
    revenue_concepts = [
        "Revenues",
        "RevenueFromContractWithCustomerExcludingAssessedTax",
        "SalesRevenueNet",
    ]
    for concept in revenue_concepts:
        values = _fetch_xbrl_concept(cik, concept)
        if values:
            quarterly = [v for v in values if v.get("form") == "10-Q"]
            quarterly.sort(key=lambda x: x["end"], reverse=True)
            results["revenue_quarterly"] = [
                {"period": v["end"], "value": v["val"]} for v in quarterly[:4]
            ]
            results["revenue_concept"] = concept
            break

    # Net income
    values = _fetch_xbrl_concept(cik, "NetIncomeLoss")
    if values:
        quarterly = [v for v in values if v.get("form") == "10-Q"]
        quarterly.sort(key=lambda x: x["end"], reverse=True)
        results["net_income_quarterly"] = [
            {"period": v["end"], "value": v["val"]} for v in quarterly[:4]
        ]

    # EPS
    usd_per_share_concepts = ["EarningsPerShareDiluted"]
    for concept in usd_per_share_concepts:
        url = f"https://data.sec.gov/api/xbrl/companyconcept/CIK{cik}/us-gaap/{concept}.json"
        headers = {"User-Agent": "ResearchAgent research@example.com"}
        try:
            response = requests.get(url, headers=headers)
            if response.status_code == 200:
                data = response.json()
                usd_values = data.get("units", {}).get("USD/shares", [])
                if usd_values:
                    quarterly = [v for v in usd_values if v.get("form") == "10-Q"]
                    quarterly.sort(key=lambda x: x["end"], reverse=True)
                    results["eps_quarterly"] = [
                        {"period": v["end"], "value": v["val"]}
                        for v in quarterly[:4]
                    ]
                    break
        except Exception:
            continue

    return results

What does this code do?

The @tool decorator registers this function with LangChain so the LLM can call it by name. The docstring becomes the tool's description, which the LLM reads to decide when to use it.

The revenue section tries three different XBRL concept names in order. It stops at the first one that returns data. This handles the fragmentation problem across different companies.

For EPS, the function uses USD/shares as the unit (instead of plain USD) because earnings per share is a per-share metric. The function filters for 10-Q filings only, giving you the last 4 quarters of data.

  • Save tools.py.
  • Test the SEC fundamentals tool by running:
python -c "from tools import get_sec_fundamentals; import json; print(json.dumps(get_sec_fundamentals.invoke('AAPL'), indent=2))"

You should see a JSON dictionary with Apple's CIK, quarterly revenue figures, net income, and EPS values from their most recent filings.

Seeing an empty result or error?

If you see an error dict with "Could not find CIK", double-check that the ticker AAPL is spelled correctly.

If revenue_quarterly is missing from the output, the SEC API might be temporarily rate-limiting. Wait a few seconds and try again. SEC EDGAR allows approximately 10 requests per second.

Help me debug my SEC fundamentals tool

Build the Yahoo Finance market data tool

SEC EDGAR gives you historical fundamentals from filings, but not live market prices. For current valuation metrics, you'll use yfinance, a Python library that pulls real-time data from Yahoo Finance.

  • Add the get_market_data tool at the bottom of tools.py by pasting this code:

@tool
def get_market_data(ticker: str) -> dict:
    """Fetch current market data from Yahoo Finance for a given stock ticker.
    Returns price, P/E ratio, market cap, 52-week range, and analyst recommendations."""

    stock = yf.Ticker(ticker)
    info = stock.info

    results = {
        "ticker": ticker,
        "current_price": info.get("currentPrice") or info.get("regularMarketPrice"),
        "pe_ratio": info.get("trailingPE"),
        "forward_pe": info.get("forwardPE"),
        "market_cap": info.get("marketCap"),
        "fifty_two_week_high": info.get("fiftyTwoWeekHigh"),
        "fifty_two_week_low": info.get("fiftyTwoWeekLow"),
        "revenue_growth": info.get("revenueGrowth"),
        "profit_margins": info.get("profitMargins"),
        "analyst_target_price": info.get("targetMeanPrice"),
        "recommendation": info.get("recommendationKey"),
        "short_name": info.get("shortName"),
    }

    return results

What does this code do?

This tool creates a yf.Ticker object and pulls the .info dictionary, which contains dozens of pre-computed metrics from Yahoo Finance.

The .get() calls with fallbacks handle cases where Yahoo Finance doesn't have a particular field. For current_price, it tries currentPrice first, then falls back to regularMarketPrice.

Why two data sources instead of one?

SEC EDGAR provides authoritative historical data straight from official filings. This is the same raw data that paid providers like Bloomberg clean up and resell for thousands of dollars.

Yahoo Finance provides real-time market data (price, P/E, analyst targets) that doesn't appear in SEC filings. Together, they give your agent both the fundamental truth and the current market's opinion.

  • Save tools.py.
  • Test the market data tool by running:
python -c "from tools import get_market_data; import json; print(json.dumps(get_market_data.invoke('AAPL'), indent=2))"

You should see a JSON dictionary with Apple's current stock price, P/E ratio, market cap, 52-week high and low, and analyst recommendation.

Seeing None values or an error?

If most values show null, Yahoo Finance may be temporarily unavailable. Wait a moment and try again.

If you see a ModuleNotFoundError for yfinance, confirm your virtual environment is active and that you ran pip install yfinance==1.3.0 in the previous step.

Help me debug my Yahoo Finance market data tool

✔️ Awesome, I've got everything!

Great. Double check you've saved tools.py and both tools returned data successfully.

ⓧ I'd like to double check the full code

Here is the complete tools.py file. Compare your code against this:

import requests
import yfinance as yf
from langchain_core.tools import tool


def _get_cik_for_ticker(ticker: str) -> str | None:
    """Map a ticker symbol to SEC CIK number."""
    url = "https://www.sec.gov/files/company_tickers.json"
    headers = {"User-Agent": "ResearchAgent research@example.com"}
    response = requests.get(url, headers=headers)
    response.raise_for_status()
    data = response.json()

    ticker_upper = ticker.upper()
    for entry in data.values():
        if entry["ticker"] == ticker_upper:
            return str(entry["cik_str"]).zfill(10)
    return None


def _fetch_xbrl_concept(cik: str, concept: str) -> list[dict] | None:
    """Fetch a single XBRL concept for a company."""
    url = f"https://data.sec.gov/api/xbrl/companyconcept/CIK{cik}/us-gaap/{concept}.json"
    headers = {"User-Agent": "ResearchAgent research@example.com"}
    try:
        response = requests.get(url, headers=headers)
        if response.status_code != 200:
            return None
        data = response.json()
        usd_values = data.get("units", {}).get("USD", [])
        return usd_values
    except Exception:
        return None


@tool
def get_sec_fundamentals(ticker: str) -> dict:
    """Fetch fundamental financial data from SEC EDGAR for a given stock ticker.
    Returns quarterly revenue, net income, and EPS from official SEC filings."""

    cik = _get_cik_for_ticker(ticker)
    if not cik:
        return {"error": f"Could not find CIK for ticker {ticker}"}

    results = {"ticker": ticker, "cik": cik}

    # Try multiple concept names for revenue (handles XBRL fragmentation)
    revenue_concepts = [
        "Revenues",
        "RevenueFromContractWithCustomerExcludingAssessedTax",
        "SalesRevenueNet",
    ]
    for concept in revenue_concepts:
        values = _fetch_xbrl_concept(cik, concept)
        if values:
            quarterly = [v for v in values if v.get("form") == "10-Q"]
            quarterly.sort(key=lambda x: x["end"], reverse=True)
            results["revenue_quarterly"] = [
                {"period": v["end"], "value": v["val"]} for v in quarterly[:4]
            ]
            results["revenue_concept"] = concept
            break

    # Net income
    values = _fetch_xbrl_concept(cik, "NetIncomeLoss")
    if values:
        quarterly = [v for v in values if v.get("form") == "10-Q"]
        quarterly.sort(key=lambda x: x["end"], reverse=True)
        results["net_income_quarterly"] = [
            {"period": v["end"], "value": v["val"]} for v in quarterly[:4]
        ]

    # EPS
    usd_per_share_concepts = ["EarningsPerShareDiluted"]
    for concept in usd_per_share_concepts:
        url = f"https://data.sec.gov/api/xbrl/companyconcept/CIK{cik}/us-gaap/{concept}.json"
        headers = {"User-Agent": "ResearchAgent research@example.com"}
        try:
            response = requests.get(url, headers=headers)
            if response.status_code == 200:
                data = response.json()
                usd_values = data.get("units", {}).get("USD/shares", [])
                if usd_values:
                    quarterly = [v for v in usd_values if v.get("form") == "10-Q"]
                    quarterly.sort(key=lambda x: x["end"], reverse=True)
                    results["eps_quarterly"] = [
                        {"period": v["end"], "value": v["val"]}
                        for v in quarterly[:4]
                    ]
                    break
        except Exception:
            continue

    return results


@tool
def get_market_data(ticker: str) -> dict:
    """Fetch current market data from Yahoo Finance for a given stock ticker.
    Returns price, P/E ratio, market cap, 52-week range, and analyst recommendations."""

    stock = yf.Ticker(ticker)
    info = stock.info

    results = {
        "ticker": ticker,
        "current_price": info.get("currentPrice") or info.get("regularMarketPrice"),
        "pe_ratio": info.get("trailingPE"),
        "forward_pe": info.get("forwardPE"),
        "market_cap": info.get("marketCap"),
        "fifty_two_week_high": info.get("fiftyTwoWeekHigh"),
        "fifty_two_week_low": info.get("fiftyTwoWeekLow"),
        "revenue_growth": info.get("revenueGrowth"),
        "profit_margins": info.get("profitMargins"),
        "analyst_target_price": info.get("targetMeanPrice"),
        "recommendation": info.get("recommendationKey"),
        "short_name": info.get("shortName"),
    }

    return results

Your data tools are built and tested. Next up, you'll define the structured output schema that tells the LLM exactly how to format its research brief.

Define the Research Brief Schema and Agent State

Your tools now know how to gather raw financial data from SEC EDGAR and Yahoo Finance. The next challenge is defining what the agent's final output should look like.

In this step, you'll create structured schemas that tell the LLM exactly what shape its analysis should take. You'll also define the agent's state, which is the container that holds all the data as it flows through the pipeline.

In this step, get ready to:
  • Define Pydantic models for the research brief output.
  • Create the agent state that tracks data through the pipeline.
  • Connect your existing tools to the agent via imports.
Define the TensionPoint schema

A "tension point" is the core debate that drives a stock's valuation at any given moment. For example, whether Tesla is valued as a car company or an AI company is a tension point.

  • Create a new file called schemas.py in your project folder.
  • Add the following code to define the TensionPoint model:
from pydantic import BaseModel, Field


class TensionPoint(BaseModel):
    debate: str = Field(description="The core question or debate driving valuation")
    bull_case: str = Field(description="Why this resolves positively for the stock")
    bear_case: str = Field(description="Why this resolves negatively for the stock")
    what_to_watch: str = Field(description="Upcoming catalysts or data points to monitor")

What does this code do?

Each TensionPoint represents one key debate investors are actively arguing about. The four fields capture the full picture: what the debate is, both sides of the argument, and what data to watch for a resolution.

The Field(description=...) metadata tells the LLM what each field should contain when generating structured output.

Define the ResearchBrief schema

The ResearchBrief is the agent's final deliverable. It wraps multiple tension points together with a recommendation and supporting data.

  • Add the ResearchBrief class below your TensionPoint class in schemas.py:
class ResearchBrief(BaseModel):
    company_name: str = Field(description="Full company name")
    ticker: str = Field(description="Stock ticker symbol")
    recommendation: str = Field(description="STRONG BUY, BUY, HOLD, SELL, or STRONG SELL")
    conviction: str = Field(description="HIGH, MEDIUM, or LOW")
    tension_points: list[TensionPoint] = Field(
        description="1-3 key debates driving the stock's valuation"
    )
    fundamental_snapshot: str = Field(
        description="Key metrics summary: revenue trend, margins, valuation"
    )
    final_verdict: str = Field(
        description="3-4 sentence synthesis with clear reasoning"
    )

What does this code do?

The ResearchBrief is a Pydantic model that defines the exact shape of the agent's output. When you pass this to the LLM later, it forces the model to return data matching this exact structure.

The tension_points field is a list of TensionPoint objects, so your schema composes cleanly. This is the same pattern production AI systems use to guarantee predictable, parseable output from LLMs.

  • Save schemas.py.

Seeing a red underline on TensionPoint?

Make sure TensionPoint is defined above ResearchBrief in the same file. Python reads top to bottom, so the class must exist before it's referenced.

help me fix my schemas.py file

✔️ Awesome, I've got everything!

Great. Make sure you've saved schemas.py before moving on.

ⓧ I'd like to double check the full code

from pydantic import BaseModel, Field


class TensionPoint(BaseModel):
    debate: str = Field(description="The core question or debate driving valuation")
    bull_case: str = Field(description="Why this resolves positively for the stock")
    bear_case: str = Field(description="Why this resolves negatively for the stock")
    what_to_watch: str = Field(description="Upcoming catalysts or data points to monitor")


class ResearchBrief(BaseModel):
    company_name: str = Field(description="Full company name")
    ticker: str = Field(description="Stock ticker symbol")
    recommendation: str = Field(description="STRONG BUY, BUY, HOLD, SELL, or STRONG SELL")
    conviction: str = Field(description="HIGH, MEDIUM, or LOW")
    tension_points: list[TensionPoint] = Field(
        description="1-3 key debates driving the stock's valuation"
    )
    fundamental_snapshot: str = Field(
        description="Key metrics summary: revenue trend, margins, valuation"
    )
    final_verdict: str = Field(
        description="3-4 sentence synthesis with clear reasoning"
    )
Create the agent state

A LangGraph agent uses a state object to pass data between steps in the pipeline. Each node in the graph reads from and writes to this shared state.

  • Create a new file called agent.py in your project folder.
  • Add the following code to set up the imports and define the agent state:
import json
import os
from typing import TypedDict

from dotenv import load_dotenv
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import END, START, StateGraph

from schemas import ResearchBrief
from tools import get_market_data, get_sec_fundamentals

load_dotenv()


class AgentState(TypedDict, total=False):
    ticker: str
    fundamentals: dict
    market_data: dict
    research_brief: ResearchBrief

What does this code do?

The imports bring in everything the agent will need: LangGraph for the state graph, ChatOpenAI for the LLM, and your custom schemas and tools.

AgentState is a TypedDict that defines what data flows through the pipeline. The total=False setting means fields are optional, so the state starts empty and fills up as each node runs.

load_dotenv() loads your .env file so the OpenAI API key is available when the agent runs.

  • Save agent.py.
  • Verify the imports work by running this in your terminal:
python -c "from agent import AgentState; print('AgentState loaded successfully')"

You should see AgentState loaded successfully printed to the terminal.

Seeing a ModuleNotFoundError?

Make sure your virtual environment is active (you should see (venv) in your terminal prompt). If not, activate it again with venv\Scripts\activate on Windows or source venv/bin/activate on macOS.

If you see an error about schemas or tools not found, confirm both files are saved in the same folder as agent.py.

help me debug my agent.py imports

✔️ Awesome, I've got everything!

Great. Make sure you've saved agent.py before moving on.

ⓧ I'd like to double check the full code

import json
import os
from typing import TypedDict

from dotenv import load_dotenv
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import END, START, StateGraph

from schemas import ResearchBrief
from tools import get_market_data, get_sec_fundamentals

load_dotenv()


class AgentState(TypedDict, total=False):
    ticker: str
    fundamentals: dict
    market_data: dict
    research_brief: ResearchBrief
Understand how your tools connect to the agent

In the previous step, you decorated both financial functions with the @tool decorator from LangChain. This is what makes them callable by the LLM.

Why does @tool matter?

The @tool decorator does two things: it registers the function's name and docstring so the LLM knows what tools are available, and it wraps the function so LangGraph can invoke it programmatically.

When the agent's gather_data node runs (which you'll build next), it calls get_sec_fundamentals.invoke(ticker) and get_market_data.invoke(ticker) to populate the state. The .invoke() method is provided by the @tool wrapper.

Here's how the pieces connect: your tools fill in the fundamentals and market_data fields of AgentState, then the LLM uses that data to produce a ResearchBrief.

Your schemas and state are locked in. Next up, you'll wire these pieces into a working LangGraph pipeline that gathers data, reasons about it, and produces the final brief.

Build the LangGraph Agent Pipeline

Your financial data tools are ready. You have functions that pull real SEC filings and live market data for any stock ticker. The missing piece is intelligence. Something needs to call those tools, read the results, and reason about what the data means.

In this step, you will build the LangGraph agent that orchestrates everything. It follows a simple pattern: gather data, then analyze it with GPT-4o to produce a structured research brief.

In this step, get ready to:
  • Define the agent's node functions that gather and analyze financial data.
  • Wire the nodes into a compiled StateGraph with LangGraph.
  • Create a CLI entry point that runs the agent and prints a formatted research brief.
Add the system prompt and data-gathering node

Every AI agent needs instructions. The system prompt tells GPT-4o to behave like a buy-side equity research analyst and focus on identifying specific "tension points" instead of generic commentary.

  • In agent.py, find the AgentState class definition at the bottom of the file.
  • Add the following code below it:

ANALYSIS_SYSTEM_PROMPT = """You are an expert buy-side equity research analyst at a top hedge fund.
You produce "tension point" analyses - identifying the 1-3 core debates that drive a stock's valuation.

A tension point is NOT a generic risk. It is the specific, current question that investors are
actively debating right now. For example:
- "Can NVDA maintain 70%+ data center growth as hyperscaler capex normalizes?"
- "Will AAPL's services revenue acceleration offset iPhone unit decline?"
- "Is TSLA a car company at 60x earnings or a robotics/AI company worth 100x?"

Your job is to identify these precise debates from the financial data provided and synthesize
them into a structured research brief.

Be specific. Use actual numbers from the data. Never be generic."""


def gather_data(state: AgentState) -> dict:
    """Gather financial data from SEC EDGAR and Yahoo Finance."""
    ticker = state["ticker"]

    fundamentals = get_sec_fundamentals.invoke(ticker)
    market_data = get_market_data.invoke(ticker)

    return {"fundamentals": fundamentals, "market_data": market_data}

What does this code do?

You added two things here:

  • ANALYSIS_SYSTEM_PROMPT is a detailed instruction set for GPT-4o. It tells the model to act as a hedge fund analyst and focus on "tension points" (the 1-3 specific debates investors are actively arguing about). The examples teach the model what a good tension point looks like.
  • gather_data is the first node in your agent graph. It reads the ticker from the agent's state, calls both of your financial tools (get_sec_fundamentals and get_market_data), and returns the results so they flow into the next node.

You will not see a change yet. The next chunk uses this prompt and data to produce the research brief.

  • Next, add the analyze function directly below gather_data:

def analyze(state: AgentState) -> dict:
    """Analyze gathered data and produce a structured research brief."""
    llm = ChatOpenAI(model="gpt-4o", temperature=0.1)
    structured_llm = llm.with_structured_output(ResearchBrief)

    data_summary = f"""## SEC EDGAR Fundamentals
{json.dumps(state['fundamentals'], indent=2)}

## Yahoo Finance Market Data
{json.dumps(state['market_data'], indent=2)}"""

    messages = [
        SystemMessage(content=ANALYSIS_SYSTEM_PROMPT),
        HumanMessage(
            content=f"Produce a tension point analysis for {state['ticker']}.\n\n"
            f"Here is the financial data:\n\n{data_summary}"
        ),
    ]

    brief = structured_llm.invoke(messages)
    return {"research_brief": brief}

What does this code do?

  • ChatOpenAI(model="gpt-4o", temperature=0.1) creates a low-temperature LLM instance. Low temperature means less randomness, which is what you want for financial analysis.
  • llm.with_structured_output(ResearchBrief) tells the LLM to return its response in the exact shape of your Pydantic schema. No manual JSON parsing needed.
  • The function formats all the gathered financial data into a single prompt, sends it to GPT-4o alongside the system prompt, and returns the structured ResearchBrief object.

You will not see a change yet. The next chunk wires these functions into an executable graph.

Wire the nodes into a StateGraph

A StateGraph defines how data flows between your node functions. Each node receives the current state, does its work, and returns updates that flow into the next node.

  • Add the build_graph function below analyze in agent.py:

def build_graph() -> StateGraph:
    """Build and compile the research agent graph."""
    graph = StateGraph(AgentState)

    graph.add_node("gather_data", gather_data)
    graph.add_node("analyze", analyze)

    graph.add_edge(START, "gather_data")
    graph.add_edge("gather_data", "analyze")
    graph.add_edge("analyze", END)

    return graph.compile()

What does this code do?

  • StateGraph(AgentState) creates a graph that uses your AgentState TypedDict as its shared memory.
  • add_node registers each function as a named step in the pipeline.
  • add_edge defines the execution order: START flows to gather_data, which flows to analyze, which flows to END.
  • graph.compile() turns the graph definition into a runnable object you can invoke with a ticker.
  • Save agent.py.
  • Verify the module loads correctly by running:
python -c "from agent import build_graph; print('Agent ready')"

You should see Agent ready printed to the terminal with no errors.

Seeing an import error?

  • If you see ModuleNotFoundError, make sure your virtual environment is still activated. You should see (venv) in your terminal prompt.
  • If you see ImportError referencing schemas or tools, confirm that schemas.py and tools.py are saved in the same folder as agent.py.
  • Check for indentation errors. Every function body must be indented with 4 spaces.

Help me debug my agent.py import error.

✔️ Awesome, I've got everything!

Great. Make sure you have saved agent.py before moving on.

ⓧ I'd like to double check the full code

import json
import os
from typing import TypedDict

from dotenv import load_dotenv
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import END, START, StateGraph

from schemas import ResearchBrief
from tools import get_market_data, get_sec_fundamentals

load_dotenv()


class AgentState(TypedDict, total=False):
    ticker: str
    fundamentals: dict
    market_data: dict
    research_brief: ResearchBrief


ANALYSIS_SYSTEM_PROMPT = """You are an expert buy-side equity research analyst at a top hedge fund.
You produce "tension point" analyses - identifying the 1-3 core debates that drive a stock's valuation.

A tension point is NOT a generic risk. It is the specific, current question that investors are
actively debating right now. For example:
- "Can NVDA maintain 70%+ data center growth as hyperscaler capex normalizes?"
- "Will AAPL's services revenue acceleration offset iPhone unit decline?"
- "Is TSLA a car company at 60x earnings or a robotics/AI company worth 100x?"

Your job is to identify these precise debates from the financial data provided and synthesize
them into a structured research brief.

Be specific. Use actual numbers from the data. Never be generic."""


def gather_data(state: AgentState) -> dict:
    """Gather financial data from SEC EDGAR and Yahoo Finance."""
    ticker = state["ticker"]

    fundamentals = get_sec_fundamentals.invoke(ticker)
    market_data = get_market_data.invoke(ticker)

    return {"fundamentals": fundamentals, "market_data": market_data}


def analyze(state: AgentState) -> dict:
    """Analyze gathered data and produce a structured research brief."""
    llm = ChatOpenAI(model="gpt-4o", temperature=0.1)
    structured_llm = llm.with_structured_output(ResearchBrief)

    data_summary = f"""## SEC EDGAR Fundamentals
{json.dumps(state['fundamentals'], indent=2)}

## Yahoo Finance Market Data
{json.dumps(state['market_data'], indent=2)}"""

    messages = [
        SystemMessage(content=ANALYSIS_SYSTEM_PROMPT),
        HumanMessage(
            content=f"Produce a tension point analysis for {state['ticker']}.\n\n"
            f"Here is the financial data:\n\n{data_summary}"
        ),
    ]

    brief = structured_llm.invoke(messages)
    return {"research_brief": brief}


def build_graph() -> StateGraph:
    """Build and compile the research agent graph."""
    graph = StateGraph(AgentState)

    graph.add_node("gather_data", gather_data)
    graph.add_node("analyze", analyze)

    graph.add_edge(START, "gather_data")
    graph.add_edge("gather_data", "analyze")
    graph.add_edge("analyze", END)

    return graph.compile()
Create the CLI entry point

The agent graph is built. Now you need a way to actually run it from the command line and display the results in a readable format.

  • Create a new file called main.py in the same folder as your other files.
  • Add the following code to define the output formatter:
import sys

from agent import build_graph


def format_brief(brief) -> str:
    """Format the research brief for terminal display."""
    lines = []
    lines.append("=" * 70)
    lines.append(f"  TENSION POINT ANALYSIS: {brief.company_name} ({brief.ticker})")
    lines.append("=" * 70)
    lines.append("")
    lines.append(f"  Recommendation: {brief.recommendation}  |  Conviction: {brief.conviction}")
    lines.append("")
    lines.append("-" * 70)
    lines.append("  FUNDAMENTAL SNAPSHOT")
    lines.append("-" * 70)
    lines.append(f"  {brief.fundamental_snapshot}")
    lines.append("")

    for i, tp in enumerate(brief.tension_points, 1):
        lines.append("-" * 70)
        lines.append(f"  TENSION POINT #{i}: {tp.debate}")
        lines.append("-" * 70)
        lines.append(f"  Bull Case: {tp.bull_case}")
        lines.append(f"  Bear Case: {tp.bear_case}")
        lines.append(f"  What to Watch: {tp.what_to_watch}")
        lines.append("")

    lines.append("-" * 70)
    lines.append("  FINAL VERDICT")
    lines.append("-" * 70)
    lines.append(f"  {brief.final_verdict}")
    lines.append("")
    lines.append("=" * 70)

    return "\n".join(lines)

What does this code do?

The format_brief function takes the structured ResearchBrief object and formats it into a clean terminal display. It loops through each tension point and prints the debate, bull/bear cases, and catalysts to watch.

The separator lines (= and -) make the output easy to scan, similar to how an analyst would format a research note.

You will not see a change yet. The next chunk adds the entry point that invokes the agent.

  • Add the main function and entry point below format_brief in main.py:

def main():
    if len(sys.argv) < 2:
        print("Usage: python main.py <TICKER>")
        print("Example: python main.py AAPL")
        sys.exit(1)

    ticker = sys.argv[1].upper()
    print(f"\n  Researching {ticker}...\n")

    graph = build_graph()
    result = graph.invoke({"ticker": ticker})

    brief = result["research_brief"]
    print(format_brief(brief))


if __name__ == "__main__":
    main()

What does this code do?

  • if len(sys.argv) < 2 checks that the learner provided a ticker symbol when running the script. If not, it prints usage instructions and exits.
  • build_graph() creates the compiled LangGraph pipeline.
  • graph.invoke({"ticker": ticker}) kicks off the entire agent. It runs gather_data first, then passes the results to analyze, and returns the final state.
  • if __name__ == "__main__" ensures the script only runs when called directly, not when imported.
  • Save main.py.
  • Verify the file has no syntax errors by running:
python -c "import main; print('main.py loaded')"

You should see main.py loaded with no errors.

Seeing a syntax error?

  • Check that the f-strings in format_brief use curly braces correctly. Each {brief.field_name} must be inside an f-string (a string starting with f").
  • Make sure the triple-quote f-string in the analyze function is not accidentally closed early. The opening and closing """ must match.
  • Confirm that if __name__ == "__main__": uses double underscores on both sides of name and main.

Help me fix my main.py syntax error.

✔️ Awesome, I've got everything!

Great. Make sure you have saved main.py before moving on.

ⓧ I'd like to double check the full code

import sys

from agent import build_graph


def format_brief(brief) -> str:
    """Format the research brief for terminal display."""
    lines = []
    lines.append("=" * 70)
    lines.append(f"  TENSION POINT ANALYSIS: {brief.company_name} ({brief.ticker})")
    lines.append("=" * 70)
    lines.append("")
    lines.append(f"  Recommendation: {brief.recommendation}  |  Conviction: {brief.conviction}")
    lines.append("")
    lines.append("-" * 70)
    lines.append("  FUNDAMENTAL SNAPSHOT")
    lines.append("-" * 70)
    lines.append(f"  {brief.fundamental_snapshot}")
    lines.append("")

    for i, tp in enumerate(brief.tension_points, 1):
        lines.append("-" * 70)
        lines.append(f"  TENSION POINT #{i}: {tp.debate}")
        lines.append("-" * 70)
        lines.append(f"  Bull Case: {tp.bull_case}")
        lines.append(f"  Bear Case: {tp.bear_case}")
        lines.append(f"  What to Watch: {tp.what_to_watch}")
        lines.append("")

    lines.append("-" * 70)
    lines.append("  FINAL VERDICT")
    lines.append("-" * 70)
    lines.append(f"  {brief.final_verdict}")
    lines.append("")
    lines.append("=" * 70)

    return "\n".join(lines)


def main():
    if len(sys.argv) < 2:
        print("Usage: python main.py <TICKER>")
        print("Example: python main.py AAPL")
        sys.exit(1)

    ticker = sys.argv[1].upper()
    print(f"\n  Researching {ticker}...\n")

    graph = build_graph()
    result = graph.invoke({"ticker": ticker})

    brief = result["research_brief"]
    print(format_brief(brief))


if __name__ == "__main__":
    main()

Why a linear graph instead of a ReAct loop?

Production AI agents at companies like Trata often use conditional edges and retry loops. For this project, a linear graph (gather then analyze) is the right starting point. It keeps the architecture clear while still demonstrating the StateGraph pattern.

The key LangGraph concept is that each node receives the full state, does one job, and returns only the fields it changed. LangGraph merges those changes back into state automatically.

Your agent pipeline is fully wired. In the next step, you will run it against real stock tickers and see the research briefs it produces.

Run Your Agent and Analyze the Output

You've built all four files that make up your research agent. The LangGraph pipeline is wired, the financial tools are ready, and the structured output schema is defined.

Now it's time to see if your agent can actually produce hedge-fund-quality research. You'll run it on real tickers and evaluate whether the output contains specific, data-driven insights or generic filler.

In this step, get ready to:
  • Run the agent on a well-known ticker and verify it produces a complete research brief.
  • Test the agent on a second ticker in a different sector to confirm it generalizes.
  • Evaluate output quality against real financial data.
Run the agent on Apple (AAPL)

Apple is a great first test because it's one of the most heavily covered stocks. There's abundant SEC filing data and analyst coverage, so you can easily cross-check what your agent produces.

  • Run your research agent on Apple by running this command:
python main.py AAPL

What should I see?

The agent takes 10-30 seconds to complete. It's fetching data from SEC EDGAR, pulling market data from Yahoo Finance, then sending everything to GPT-4o for analysis.

When it finishes, you'll see a formatted research brief in your terminal with sections for the recommendation, fundamental snapshot, 1-3 tension points (each with bull/bear cases), and a final verdict.

  • Wait for the output to appear. You should see a full research brief starting with a line of = characters and TENSION POINT ANALYSIS: Apple Inc. (AAPL) near the top.
  • Confirm the output includes all sections: Recommendation, Fundamental Snapshot, at least one Tension Point, and Final Verdict.

Seeing an error?

  • If you see AuthenticationError, your OpenAI API key is missing or invalid. Open .env and confirm the key is correct with no extra spaces.
  • If you see ModuleNotFoundError, make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
  • If the SEC data comes back empty or with an error, SEC EDGAR may be rate-limiting you. Wait 10 seconds and try again.

Still stuck? Help me debug my agent run

Test with a second ticker in a different sector

One ticker proves the plumbing works. Two tickers in different sectors prove the agent actually reasons about different businesses instead of producing cookie-cutter output.

  • Run the agent on Tesla to test a company with very different fundamentals:
python main.py TSLA
  • Wait for the output to appear. You should see a complete research brief for Tesla.
  • Compare the tension points between your AAPL and TSLA runs. The agent should identify fundamentally different debates for each company.

What makes a good comparison?

Apple's tension points typically revolve around services growth offsetting hardware decline, or iPhone cycle timing. Tesla's tension points tend to focus on valuation multiples, delivery growth rates, or the robotics/AI narrative vs. auto fundamentals.

If both briefs have identical-sounding tension points, the agent is being too generic. You'll address that in the next substep.

Agent timing out or running slowly?

Each run makes several API calls to SEC EDGAR, Yahoo Finance, and OpenAI. A 20-30 second wait is normal. If it takes longer than 60 seconds, your network connection to one of these services may be slow.

Need help? My agent is taking too long to respond

Review output quality against real data

A research brief is only useful if the data is accurate and the analysis is specific. You'll now evaluate your agent's output against three quality criteria.

  • Check the SEC data accuracy. Look at the revenue and net income figures in the Fundamental Snapshot. Cross-reference one number against Apple's or Tesla's most recent 10-Q filing on SEC EDGAR.
  • Evaluate tension point specificity. Each tension point should reference actual numbers from the data, not vague statements like "growth is uncertain."
  • Verify the recommendation logic. The Final Verdict should clearly connect to the tension points. If it says BUY, the bull cases should outweigh the bear cases in the analysis above.

What does good quality look like?

  • Revenue figures match SEC filings within rounding (millions vs. exact cents).
  • Tension points name specific metrics: "Can Apple sustain 8% services growth at 71% margins?" not "Can Apple keep growing?"
  • The recommendation has a clear logical thread from data to conclusion.

Improve generic output with the system prompt

If your tension points feel too generic, open agent.py and look at the ANALYSIS_SYSTEM_PROMPT variable. You can add more specific instructions like "Always reference at least one exact dollar amount or percentage from the data" to push the model toward concrete analysis.

Your agent is producing real financial research from live data. Next up, take on the Secret Mission to add news sentiment analysis and uncover where market narrative diverges from the numbers.

Secret mission

Add News Sentiment Analysis

Your agent analyzes numbers, but markets move on narratives too. What if headlines are screaming 'AI revolution' while the revenue numbers tell a different story? Add a news sentiment tool that feeds recent headlines into your agent, so it can spot where market narrative diverges from financial reality.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs locally and only costs money when you actively run the agent (each run uses a small amount of OpenAI API credits).

Resources you used:

  • Project folder containing main.py, agent.py, tools.py, schemas.py, .env, and the venv virtual environment.
  • OpenAI API key (stored in your .env file and on your OpenAI account).

Keep everything running

No action needed. Choose this if you plan to keep experimenting with different tickers or extending the agent.

  • Your project only incurs costs when you run python main.py <TICKER>. Each run costs a few cents of OpenAI API usage.
  • The virtual environment and installed packages take up a small amount of disk space but have no ongoing cost.
  • Your OpenAI API key remains active. You will only be charged when you make API calls.

Pause - I'll come back to this later

Deactivate the virtual environment to free up your terminal. Your files and packages stay on disk, ready when you return.

  • Deactivate the virtual environment by running this in your terminal:
deactivate
  • When you come back, navigate to your project folder and reactivate with source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows).
  • No ongoing costs while paused. Your OpenAI API key only charges when used.

Delete - I don't want to use this again

Remove all project files from your machine and optionally revoke your API key.

  • Deactivate the virtual environment first:
deactivate
  • Delete the project folder from your Desktop by running:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your-project-folder-name"]]

On Windows, use this instead:

cd ~\Desktop
rmdir /s /q [[PROJECT_FOLDER="your-project-folder-name"]]
  • To revoke your OpenAI API key, log in to platform.openai.com and navigate to the API keys page. Find the key you created for this project and delete it.

Nice Work!

Nice Work!

You just built the kind of AI agent that powers companies like Trata, the YC W25 startup building AI-powered research for hedge funds.

You've learned how to:

  • Build a multi-tool AI agent using LangGraph's StateGraph pattern to orchestrate autonomous research workflows.
  • Fetch real financial data from SEC EDGAR's free XBRL API and live market metrics from Yahoo Finance.
  • Produce structured tension point research briefs using Pydantic schemas and GPT-4o's structured output.
  • Secret Mission: Added news sentiment analysis to identify where market narrative diverges from fundamentals.

Ready to quiz yourself?