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
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-analystcd stock-analystStep 2: Create the Virtual Environment
pip install --upgrade pip virtualenvvirtualenv --python=python3.12 .venvsource .venv/bin/activateStep 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
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.txtProject 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_messagesreducer appends to the messages list rather than replacing it. Without this, a second parallel node writing tomessageswould silently discard the first node's messages. - Optional fields for all research data - Starting as
Noneallows 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
errorfield doubles as a routing signal: the conditional edge ingraph.pyreads 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
StructuredToolobjects. 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).inforeturns 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_earningsattribute across different ticker types (stocks, ETFs, ADRs). Returns a DataFrame indexed by financial line items. - DuckDuckGoSearchRun - A pre-built LangChain tool from
langchain_communitythat 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_nodestores an error dict inearnings_datawithout 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_validationreturns["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 thatanalystdepends on all three predecessors. LangGraph holds the analyst node until all three have completed and their state updates have been merged. No explicitasyncio.gatheror join logic is required. - Conditional short-circuit - If
validate_tickersetserror, the router returnsEND(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.
ChatGoogleGenerativeAIreadsGOOGLE_API_KEYwhen the class is imported, not when it's called. Sincegraph.pyimportsagents.pywhich 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. Ifearnings_nodefails 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.pyStreamlit will open your browser at http://localhost:8501. Enter a ticker like AAPL, MSFT, or NVDA and click Analyse.
Expected Output (for ticker 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.