Capstone: AI Stock Analyst

Build a production-ready multi-agent application using LangGraph, Streamlit, and Gemini 2.5 Flash.

Introduction

Welcome to the AI Development capstone project! You'll build a multi-agent stock analysis system that puts everything from this course into practice: LangGraph for agent orchestration, real financial data from yfinance, web search via DuckDuckGo, and Gemini 2.5 Flash for intelligent synthesis. The result is a Streamlit application where a user enters a stock ticker and receives a full AI-generated investment brief, powered by three research agents running concurrently in parallel.

Project Purpose

The goal is to build a parallel multi-agent research pipeline. Three specialised agents gather different kinds of information simultaneously, then a fourth agent synthesises their findings into a structured investment insight. This is the fan-out/fan-in pattern, a fundamental building block in production multi-agent systems.

Graph Topology

Multi-Agent Stock Analyst Graph

STARTvalidatecompany_infoearningssentimentanalystENDinvalidfan-outfan-in

Three research agents run in parallel; the analyst node waits for all three before executing

Agent Responsibilities

  • validate - Entry guard: normalises the ticker and short-circuits to END if the input is invalid, preventing wasted API calls
  • company_info - Fetches company name, sector, industry, market cap, and description from Yahoo Finance via yfinance
  • earnings - Retrieves the last four quarters of EPS and revenue from yfinance's income statement
  • sentiment - Runs a DuckDuckGo web search for recent news about the stock and captures the results
  • analyst - Receives all three research outputs and uses Gemini 2.5 Flash to synthesise a structured investment brief

First Steps

Step 1: Create Your Project Folder

$ mkdir stock-analyst
$ cd stock-analyst

Step 2: Create the Virtual Environment

$ pip install --upgrade pip virtualenv
$ virtualenv --python=python3.12 .venv
$ source .venv/bin/activate

Step 3: Get a Gemini API Key

This project uses Gemini 2.5 Flash for the analyst node. Create a free API key at Google AI Studio, then add it to a .env file in your project root:

GOOGLE_API_KEY=your_gemini_api_key_here
Gemini 2.5 Flash is free-tier eligible with generous rate limits. It is one of the fastest and most cost-effective models available.

Step 4: Install Dependencies

Create a requirements.txt file with these dependencies:

streamlit>=1.40.0
langgraph>=0.2.0
langchain-core>=0.3.0
langchain-google-genai>=2.0.0
langchain-community>=0.3.0
yfinance>=0.2.40
ddgs>=9.14.2
duckduckgo-search>=6.0.0
python-dotenv>=1.0.0
$ pip install -r requirements.txt

Project Structure

We use a flat module structure with all files in the project root and no subpackages. This is the recommended approach for Streamlit projects because streamlit run app.py adds the current directory to sys.path automatically, so every module is directly importable.

stock-analyst/
├── .env              # GOOGLE_API_KEY=your_key_here
├── requirements.txt
├── state.py          # AnalystState TypedDict - shared state schema
├── tools.py          # @tool functions: yfinance + DuckDuckGo
├── agents.py         # Node functions: validate, 3 research agents, analyst
├── graph.py          # LangGraph StateGraph - fan-out/fan-in wiring
└── app.py            # Streamlit UI and graph invocation

state.py

Defines the shared TypedDict state that flows through every node. All agents read from and write to this single source of truth.

tools.py

LangChain @tool functions for fetching real data: yfinance for financial data, DuckDuckGo for web sentiment.

agents.py

Node functions for each agent: validator, three parallel research agents, and the Gemini-powered analyst.

graph.py

LangGraph StateGraph wiring: nodes, conditional edges, parallel fan-out, and implicit fan-in compilation.

Implementing state.py

The state schema is the contract between all agents. Every node receives the full state and returns only the fields it updates. LangGraph merges these partial updates into shared state automatically.

state.py - Complete Implementation

# -*- coding: utf-8 -*-

"""Shared state schema for the stock-analyst LangGraph pipeline."""

from typing import Annotated
from typing import Dict
from typing import List
from typing import Optional
from typing import TypedDict

from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages


class AnalystState(TypedDict):
    """Shared state passed between all agents in the stock analysis graph."""

    ticker: str
    messages: Annotated[List[BaseMessage], add_messages]
    company_info: Optional[Dict]
    earnings_data: Optional[Dict]
    sentiment_summary: Optional[str]
    final_analysis: Optional[str]
    error: Optional[str]

What's Happening Here?

  • TypedDict over Pydantic - LangGraph serialises state with its own reducers. Using TypedDict avoids Pydantic overhead and aligns with the LangGraph documentation patterns.
  • Annotated[list[BaseMessage], add_messages] - The add_messages reducer appends to the messages list rather than replacing it. Without this, a second parallel node writing to messages would silently discard the first node's messages.
  • Optional fields for all research data - Starting as None allows the validator to short-circuit to END before any research agent runs, and lets individual agents fail gracefully without crashing the whole graph.
  • error as a signal field - The error field doubles as a routing signal: the conditional edge in graph.py reads it to decide whether to fan out or abort.

Implementing tools.py

Tools are the agents' interface to the real world. Each function is decorated with @tool from LangChain, which adds metadata (name, description, input schema) that makes them compatible with LangGraph's tool-calling infrastructure.

tools.py - Complete Implementation

# -*- coding: utf-8 -*-

"""LangChain tools for fetching stock data and web-search sentiment."""

from __future__ import annotations

from typing import Dict
from typing import TypedDict

import yfinance as yf  # type: ignore[import-untyped]
from langchain_community.tools import DuckDuckGoSearchRun
from langchain_core.tools import tool


class CompanyInfo(TypedDict):
    """Company overview fields returned by get_company_info."""

    name: str
    description: str
    sector: str
    industry: str
    market_cap: str


@tool
def get_company_info(ticker: str) -> CompanyInfo:
    """
    Fetch company overview for a given stock ticker
    using yfinance.

    :returns: The `CompanyInfo` values.
    :raises: ValueError if the ticker is not found or the data is empty.
    """

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

    if not info or info.get("quoteType") is None:
        raise ValueError(f"Ticker '{ticker}' not found or returned no data.")

    return {
        "name": info.get("longName", "N/A"),
        "sector": info.get("sector", "N/A"),
        "industry": info.get("industry", "N/A"),
        "market_cap": info.get("marketCap", "N/A"),
        "description": info.get("longBusinessSummary", "N/A")[:800],
    }


@tool
def get_earnings_data(ticker: str) -> Dict:
    """
    Fetch the most recent quarterly earnings for a stock ticker
    using yfinance. And returns the last four quarters of EPS and
    revenue figures.
    """

    stock = yf.Ticker(ticker)
    earnings = stock.quarterly_income_stmt

    if earnings is None or earnings.empty:
        return {"error": f"No earnings data available for '{ticker}'."}

    quarters = []
    for col in list(earnings.columns)[:4]:
        eps_row = earnings.loc["Diluted EPS"] if "Diluted EPS" in earnings.index else None
        rev_row = earnings.loc["Total Revenue"] if "Total Revenue" in earnings.index else None
        quarters.append({
            "quarter": str(col.date()),
            "eps": float(eps_row[col]) if eps_row is not None else None,
            "revenue": float(rev_row[col]) if rev_row is not None else None,
        })

    return {"quarters": quarters}


@tool
def search_sentiment(query: str) -> str:
    """
    Search the web for recent news and sentiment about
    a given query and returns a text summary of the top search
    results (up to ~1 200 characters).
    """

    search = DuckDuckGoSearchRun()
    results = search.run(query)
    return results[:1200]

What's Happening Here?

  • @tool decorator - Converts plain Python functions into LangChain StructuredTool objects. The docstring becomes the tool's description. In this project we call tools directly, but the decorator keeps the code compatible with any LangGraph agent pattern.
  • yfinance for financial data - yf.Ticker(ticker).info returns a dict with hundreds of fields. We select only what the analyst needs to keep the state small and the LLM prompt focused.
  • quarterly_income_stmt - More reliable than the older quarterly_earnings attribute across different ticker types (stocks, ETFs, ADRs). Returns a DataFrame indexed by financial line items.
  • DuckDuckGoSearchRun - A pre-built LangChain tool from langchain_community that returns a clean concatenated string of search snippets. We cap the result at 1 200 characters to keep the analyst prompt manageable.

Real-World Caveats

  • yfinance scrapes Yahoo Finance's private API. Results can be inconsistent for non-US tickers or small-cap stocks during market hours.
  • DuckDuckGo may rate-limit requests if the app is called repeatedly in quick succession. In production, cache sentiment results or use a paid search API.

Implementing agents.py

Each agent is a plain Python function that accepts the full state and returns a dict with only the keys it owns. The LLM is used only in the analyst node - the three research agents call tools directly, which is faster and cheaper than a full ReAct loop.

agents.py - Complete Implementation

# -*- coding: utf-8 -*-

"""LangGraph agent nodes for the stock-analyst pipeline."""

from __future__ import annotations

from langchain_core.messages import HumanMessage
from langchain_google_genai import ChatGoogleGenerativeAI

from state import AnalystState
from tools import get_company_info
from tools import get_earnings_data
from tools import search_sentiment

# Single shared LLM instance, only the analyst
# node uses it...
_llm = ChatGoogleGenerativeAI(
    model="gemini-2.5-flash",
    temperature=0.3,
)


# Validator node (entry point in the graph)...
def validate_ticker(state: AnalystState) -> dict:
    """Validate ticker format before any API calls are made."""

    ticker = state.get("ticker", "").strip().upper()
    if not ticker or not ticker.isalpha() or len(ticker) > 5:
        return {"error": f"'{ticker}' is not a valid stock ticker."}
    return {"ticker": ticker}


# The below three functions are the `Research Agents` and
# run in parallel via fan-out...

def company_info_node(state: AnalystState) -> dict:
    """Fetch company overview and write the result to state."""

    try:
        info = get_company_info.invoke({"ticker": state["ticker"]})
        return {"company_info": info}

    except Exception as exc:  # pylint: disable=broad-exception-caught
        return {"company_info": {"error": str(exc)}}


def earnings_node(state: AnalystState) -> dict:
    """Fetch quarterly earnings and write the result to state."""

    try:
        data = get_earnings_data.invoke({"ticker": state["ticker"]})
        return {"earnings_data": data}

    except Exception as exc:  # pylint: disable=broad-exception-caught
        return {"earnings_data": {"error": str(exc)}}


def sentiment_node(state: AnalystState) -> dict:
    """Search for recent news sentiment and write the result to state."""

    try:
        query = f"{state['ticker']} stock news sentiment 2025"
        summary = search_sentiment.invoke({"query": query})
        return {"sentiment_summary": summary}

    except Exception as exc:  # pylint: disable=broad-exception-caught
        return {"sentiment_summary": f"Sentiment search failed: {exc}"}


# The `Analyst Agent` (fan-in synthesis)...

_ANALYST_PROMPT = """You are a senior equity analyst. Three specialised research agents
have gathered data for you independently and in parallel:

- Agent 1 (Company Info): {company_info}
- Agent 2 (Earnings):     {earnings_data}
- Agent 3 (Sentiment):    {sentiment_summary}

Based on these multi-agent findings, provide a concise investment insight for: {ticker}

Structure your response with:
1. Company snapshot (2-3 sentences)
2. Financial health (earnings trend interpretation)
3. Market sentiment (based on recent news)
4. Investment outlook (bullish / neutral / bearish with rationale)

Be direct and analytical."""


def analyst_node(state: AnalystState) -> dict:
    """Synthesize all research findings into an investment analysis using Gemini Flash."""

    prompt = _ANALYST_PROMPT.format(
        ticker=state["ticker"],
        company_info=state.get("company_info", "Not available"),
        earnings_data=state.get("earnings_data", "Not available"),
        sentiment_summary=state.get("sentiment_summary", "Not available"),
    )

    try:
        response = _llm.invoke([HumanMessage(content=prompt)])
        return {"final_analysis": response.content}

    except Exception as exc:  # pylint: disable=broad-exception-caught
        return {"error": f"Analyst agent failed: {exc}"}

What's Happening Here?

  • Partial state updates - Each node returns only the keys it writes. Returning {"company_info": info} leaves every other state field unchanged. LangGraph merges all node results into the shared state.
  • Direct tool invocation - get_company_info.invoke({"ticker": ...}) calls the tool without an LLM deciding which tool to pick. When the tool choice is predetermined, skipping the ReAct loop saves one LLM call per agent.
  • Error isolation per agent - Each research node has its own try/except. A failure in earnings_node stores an error dict in earnings_data without affecting the other agents. The analyst still runs and synthesises whatever data is available.
  • The analyst prompt names each agent - "Agent 1 (Company Info)", "Agent 2 (Earnings)", "Agent 3 (Sentiment)" makes the multi-agent architecture explicit to the LLM, guiding it to structure the response in sections.

Implementing graph.py - Orchestration

The graph file is where the multi-agent choreography is configured. The parallel fan-out is set up in just a few lines, and LangGraph handles all the concurrency automatically.

graph.py - Complete Implementation

# -*- coding: utf-8 -*-

"""Graph definition and compilation for the stock-analyst multi-agent pipeline."""

from __future__ import annotations

from langgraph.graph import StateGraph, START, END

from agents import (
    validate_ticker,
    company_info_node,
    earnings_node,
    sentiment_node,
    analyst_node,
)
from state import AnalystState


def _route_after_validation(state: AnalystState) -> list[str] | str:
    """
    Short-circuit to END on error; fan-out to 3 parallel research
    agents otherwise. Returning a list triggers LangGraph's parallel
    fan-out, all named nodes start concurrently without any
    additional configuration.
    """

    if state.get("error"):
        return END
    return ["company_info", "earnings", "sentiment"]


def build_graph():
    """
    Graph topology:
      START -> validate
      validate -> END                                   (invalid ticker - short circuit)
      validate -> company_info, earnings, sentiment     (parallel fan-out)
      company_info, earnings, sentiment -> analyst      (implicit fan-in)
      analyst -> END
    """

    graph = StateGraph(AnalystState)  # ty: ignore[invalid-argument-type]

    graph.add_node("validate", validate_ticker)
    graph.add_node("company_info", company_info_node)
    graph.add_node("earnings", earnings_node)
    graph.add_node("sentiment", sentiment_node)
    graph.add_node("analyst", analyst_node)

    graph.add_edge(START, "validate")
    graph.add_conditional_edges("validate", _route_after_validation)

    # Fan-in: all three research nodes must complete
    # before analyst runs...
    graph.add_edge("company_info", "analyst")
    graph.add_edge("earnings", "analyst")
    graph.add_edge("sentiment", "analyst")

    # After the analysis, just end process...
    graph.add_edge("analyst", END)
    return graph.compile()


# Module-level compiled graph - Streamlit won't recompile on every interaction
stock_analyst_graph = build_graph()

What's Happening Here?

  • Parallel fan-out via list return - _route_after_validation returns ["company_info", "earnings", "sentiment"]. LangGraph detects this list and starts all three nodes concurrently in the same superstep. This is the entire fan-out configuration.
  • Implicit fan-in - The three add_edge(..., "analyst") calls declare that analyst depends on all three predecessors. LangGraph holds the analyst node until all three have completed and their state updates have been merged. No explicit asyncio.gather or join logic is required.
  • Conditional short-circuit - If validate_ticker sets error, the router returns END (the LangGraph sentinel), immediately terminating the graph without running any research agents or the analyst.
  • Module-level compiled graph - stock_analyst_graph = build_graph() runs once at import time. Streamlit re-runs the script on every interaction but module-level state is cached, so the graph is compiled once per session.

Performance Benefit of Parallel Execution

If each research agent takes ~2 seconds (network I/O), sequential execution would take ~6 seconds total. With fan-out, all three run simultaneously and the wall-clock time is ~2 seconds - the time of the slowest agent, not the sum. This is the core benefit of the multi-agent parallel design.

Implementing app.py - Streamlit UI

The Streamlit app is the user-facing layer. It takes a ticker symbol as input, invokes the multi-agent graph, and renders the results from each agent in clearly separated sections.

app.py - Complete Implementation

# -*- coding: utf-8 -*-

"""Streamlit front-end for the AI Stock Analyst multi-agent application."""

from __future__ import annotations

import streamlit as st
from dotenv import load_dotenv

# `load_dotenv()` MUST run before importing `graph.py` because
# the `ChatGoogleGenerativeAI` module reads `GOOGLE_API_KEY` at
# module init, not at call time. Which is probably a BAD
# design...
load_dotenv()

from graph import stock_analyst_graph  # noqa: E402  # pylint: disable=wrong-import-position


st.set_page_config(
    page_title="AI Stock Analyst",
    page_icon="📈",
    layout="centered",
)

st.title("AI Stock Analyst")

st.markdown(
    "Powered by a **LangGraph multi-agent system**: three research agents run in "
    "parallel, then a **Gemini Flash** analyst syntheses their findings."
)

st.divider()

ticker_input = st.text_input(
    "Enter a stock ticker symbol",
    placeholder="e.g. AAPL, MSFT, NVDA, TSLA",
    max_chars=10,
)

analyze_btn = st.button("Analyse", type="primary", use_container_width=True)

if analyze_btn and ticker_input.strip():
    ticker = ticker_input.strip().upper()

    with st.spinner(f"Running multi-agent analysis for {ticker}..."):
        result = stock_analyst_graph.invoke({
            "ticker": ticker,
            "messages": [],
            "company_info": None,
            "earnings_data": None,
            "sentiment_summary": None,
            "final_analysis": None,
            "error": None,
        })

    if result.get("error"):
        st.error(f"Error: {result['error']}")
        st.stop()

    # Company overview...
    st.subheader("Company Overview")
    info = result.get("company_info") or {}

    if "error" in info:
        st.warning(info["error"])
    else:
        col1, col2 = st.columns(2)
        with col1:
            st.metric("Company", info.get("name", "N/A"))
            st.metric("Sector", info.get("sector", "N/A"))
        with col2:
            st.metric("Industry", info.get("industry", "N/A"))
            market_cap = info.get("market_cap")

            st.metric(
                "Market Cap",
                ("$" + f"{market_cap:,.0f}") if isinstance(market_cap, (int, float)) else "N/A",
            )

        if info.get("description"):
            st.caption(info["description"])

    st.divider()

    # Earnings summary...
    st.subheader("Earnings Summary (Last 4 Quarters)")
    earnings = result.get("earnings_data") or {}

    if "error" in earnings:
        st.warning(earnings["error"])
    elif "quarters" in earnings:
        for q in earnings["quarters"]:
            eps = q.get("eps")
            rev = q.get("revenue")
            eps_str = f"EPS: {eps:.2f}" if eps is not None else "EPS: N/A"
            rev_str = ("Revenue: $" + f"{rev:,.0f}") if rev is not None else "Revenue: N/A"
            st.markdown(f"**{q['quarter']}** - {eps_str} | {rev_str}")
    else:
        st.info("No earnings data available.")

    st.divider()

    # Market sentiment...
    st.subheader("Market Sentiment (Recent News)")
    sentiment = result.get("sentiment_summary")

    if sentiment:
        st.markdown(sentiment)
    else:
        st.info("No sentiment data available.")

    st.divider()

    # AI analysis...
    st.subheader("AI Investment Analysis")
    analysis = result.get("final_analysis")
    if analysis:
        st.markdown(analysis)

elif analyze_btn:
    st.warning("Please enter a ticker symbol before clicking Analyse.")

What's Happening Here?

  • load_dotenv() import ordering - The most common gotcha in Streamlit + LangChain projects. ChatGoogleGenerativeAI reads GOOGLE_API_KEY when the class is imported, not when it's called. Since graph.py imports agents.py which initialises the LLM at module scope, load_dotenv() must run before that import chain.
  • st.spinner for visual feedback - The graph invocation may take 5-10 seconds depending on network latency. The spinner gives users immediate feedback that work is in progress.
  • Four clearly separated sections - Each section maps to one agent's output: Company Overview, Earnings Summary, Market Sentiment, AI Investment Analysis. This makes the multi-agent architecture visible and understandable to the end user.
  • Graceful partial failures - The UI checks for an "error" key inside each result dict. If earnings_node fails for an ETF with no earnings data, the UI shows a warning for that section while still displaying company info, sentiment, and the AI analysis.

How to Run Your Project

Launch the Streamlit app from your project root with the virtual environment activated:

$ streamlit run app.py

Streamlit will open your browser at http://localhost:8501. Enter a ticker like AAPL, MSFT, or NVDA and click Analyse.

Expected Output (for ticker AAPL):
AI Stock Analyst app output for AAPL

Key Takeaways

  • Parallel fan-out - Returning a list of node names from a conditional edge router triggers concurrent execution with no extra configuration. LangGraph handles scheduling automatically.
  • Implicit fan-in - LangGraph holds a node until all predecessors complete and merge their state. No asyncio.gather or explicit barriers needed.
  • Partial state updates - Each node returns only the keys it owns. LangGraph merges all updates into shared state, making the system naturally composable.
  • add_messages reducer - Essential when multiple parallel nodes write to the same field. Without it, concurrent writes silently overwrite each other.
  • Direct tool invocation - Calling tool.invoke() directly is faster and cheaper than a ReAct agent loop when the tool choice is predetermined by the architecture.
  • Validation as a guard - A validator node that short-circuits the graph on bad input prevents wasted API calls and is a pattern applicable to any multi-agent workflow.
  • load_dotenv() ordering - In Streamlit + LangChain projects, dotenv must load before any module that reads env vars at import time. Get this wrong and you'll see cryptic authentication errors.
Software Engineering in AI EraLesson 16