Construyendo un agente de investigación bursátil con ReAct y LangGraph
Un agente LangGraph ReAct toma un símbolo bursátil, consulta datos fundamentales, noticias, análisis técnicos y herramientas de analistas en su propio orden, y escribe un resumen breve, con pausas para la intervención humana.
Fuentes: Building a Financial Research Agent with ReAct, LangGraph, and LangChain
Investigar una acción antes de que abra el mercado es una tarea habitual. Quien sigue las acciones indias como ICICI, HDFC, Reliance o HAL puede perder fácilmente 40 minutos navegando entre Screener.in, Moneycontrol y diversas pestañas de noticias para reunir la información manualmente. Ese tipo de recopilación repetitiva es exactamente lo que puede asumir un agente.
A continuación se presenta una implementación completa y funcional de un agente de investigación basado en el patrón ReAct, con código real y sin pseudocódigo. Se introduce un símbolo bursátil como RELIANCE.NS o AAPL. El agente analiza los fundamentos, el sentimiento de las noticias, los indicadores técnicos y el consenso de los analistas, para luego redactar un resumen estructurado de la inversión.
Lo sorprendente es que no existe un script fijo. El agente decide qué analizar y en qué orden según lo que encuentra, y ese razonamiento adaptativo es la verdadera esencia de ReAct.
Qué hace el agente
Dado un símbolo bursátil, el agente trabaja por su cuenta en las siguientes tareas:
- Obtener los fundamentos: relación precio/beneficio, crecimiento de ingresos, beneficio por acción, relación deuda/equity y márgenes.
- Recopilar noticias recientes y calificarlas como alcistas, bajistas o neutras, con la justificación correspondiente.
- Calcular los indicadores técnicos: RSI, MACD, las medias móviles de 50 y 200 días y la tendencia del volumen.
- Obtener el consenso de los analistas, es decir, la distribución de las calificaciones de comprar, mantener y vender.
- Fusionar todo en un resumen estructurado que finaliza con una recomendación.
El orden en que se realizan las llamadas a las herramientas es una decisión del agente. Puede que primero se utilicen los análisis técnicos y luego los fundamentales. O bien podría leer las noticias, detectar un evento corporativo importante y verificar de inmediato los datos financieros para comprobar si las cifras respaldan la información. Decisiones de este tipo diferencian a un agente ReAct de uno basado en cadenas. El diseño general:
User: "Research RELIANCE.NS"
│
▼
┌─────────────┐ tool_call ┌─────────────┐
│ Agent Node │ ──────────────────► │ Tools Node │
│ (LLM) │ ◄────────────────── │ (ToolNode) │
└─────────────┘ observation └─────────────┘
│
│ no more tool calls
▼
Final Investment Brief
Este es el bucle ReAct de Razonar, Actuar, Observar y luego volver a razonar, que se repite hasta que el agente considera que cuenta con suficiente información [3].
Configuración
Primero instale las dependencias:
pip install langgraph langchain langchain-openai yfinance pandas-ta \
tavily-python python-dotenv pydantic
A continuación se encuentra un archivo .env que contiene las claves de la API:
OPENAI_API_KEY=your_openai_key
TAVILY_API_KEY=your_tavily_key
El diseño del proyecto es sencillo:
financial_agent/
├── .env
├── state.py # Agent state definition
├── tools.py # All 4 research tools
├── agent.py # LLM + tool binding
├── graph.py # LangGraph graph definition
└── main.py # Entry point
Estado del agente
El estado es la memoria compartida que circula por cada nodo del grafo. En LangGraph [1], cada nodo la lee y escribe en ella, por lo que funciona como la memoria de trabajo del agente durante toda la sesión de investigación.
# state.py
from typing import Annotated, TypedDict
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
# The full message history - drives the ReAct loop
# add_messages is a reducer: appends new messages rather than overwriting
messages: Annotated[list[BaseMessage], add_messages]
# The ticker being researched
ticker: str
# Structured research outputs (populated by tools)
financials: dict
news_sentiment: dict
technical_indicators: dict
analyst_ratings: dict
# Final output
investment_brief: str
Dos detalles destacan. El campo messages utiliza add_messages como reductor [1], por lo que cada actualización agrega mensajes en lugar de reemplazarlos. Esto mantiene todo el historial de Pensamiento, Acción y Observación a lo largo del bucle. Sin el reductor, cada nodo sobrescribiría la conversación anterior y el agente olvidaría lo que había verificado, un error que comúnmente se manifiesta al llamar una y otra vez a la misma herramienta. Los campos de investigación estructurados como financials y news_sentiment son simples diccionarios, que se llenan a medida que se ejecutan las herramientas y se consultan en el razonamiento posterior.
Herramientas
Las definiciones de las herramientas pueden ser más importantes que la forma del gráfico o el prompt del sistema. El LLM lee la descripción de cada herramienta para decidir cuál llamar y cuándo. Las descripciones vagas causan que se omitan herramientas o se produzca un orden extraño, por lo que es mejor redactarlas como documentación para un colega competente y no como un esquema para un validador.
# tools.py
import json
from datetime import datetime, timedelta
import pandas_ta as ta
import yfinance as yf
from langchain_core.tools import tool
from tavily import TavilyClient
tavily = TavilyClient()
@tool
def get_stock_financials(ticker: str) -> str:
"""
Fetch key fundamental financial metrics for a stock ticker.
Returns P/E ratio, forward P/E, revenue growth (YoY), EPS (TTM),
profit margin, debt-to-equity ratio, and free cash flow yield.
Use this tool first to understand whether a company's fundamentals
are strong before diving into price-based analysis.
Works for both NSE tickers (e.g. RELIANCE.NS) and US tickers (e.g. AAPL).
"""
try:
stock = yf.Ticker(ticker) # [4]
info = stock.info
fundamentals = {
"company_name": info.get("longName", ticker),
"sector": info.get("sector", "N/A"),
"market_cap_b": round(info.get("marketCap", 0) / 1e9, 2),
"pe_ratio_ttm": info.get("trailingPE"),
"pe_ratio_forward": info.get("forwardPE"),
"eps_ttm": info.get("trailingEps"),
"revenue_growth_yoy": info.get("revenueGrowth"),
"earnings_growth_yoy": info.get("earningsGrowth"),
"profit_margin": info.get("profitMargins"),
"operating_margin": info.get("operatingMargins"),
"debt_to_equity": info.get("debtToEquity"),
"current_ratio": info.get("currentRatio"),
"return_on_equity": info.get("returnOnEquity"),
"free_cashflow_b": round(info.get("freeCashflow", 0) / 1e9, 2),
"dividend_yield": info.get("dividendYield"),
"52_week_high": info.get("fiftyTwoWeekHigh"),
"52_week_low": info.get("fiftyTwoWeekLow"),
"current_price": info.get("currentPrice"),
}
# Flag key concerns automatically
concerns = []
if fundamentals["debt_to_equity"] and fundamentals["debt_to_equity"] > 200:
concerns.append("High debt-to-equity ratio - leverage risk")
if fundamentals["profit_margin"] and fundamentals["profit_margin"] < 0:
concerns.append("Negative profit margin - company is unprofitable")
if fundamentals["revenue_growth_yoy"] and fundamentals["revenue_growth_yoy"] < -0.05:
concerns.append("Revenue declining year-over-year")
fundamentals["flagged_concerns"] = concerns
return json.dumps(fundamentals, indent=2)
except Exception as e:
return json.dumps({"error": str(e), "ticker": ticker})
@tool
def get_news_sentiment(ticker: str) -> str:
"""
Search for recent news about a stock ticker and analyse overall sentiment.
Returns the top 5 recent headlines with publication dates and URLs,
a sentiment classification (BULLISH / BEARISH / NEUTRAL), and
a brief reasoning for the sentiment call.
Use this tool to understand current market narrative and any recent
events (earnings, regulatory issues, product launches, macro exposure)
that could affect the stock's near-term trajectory.
"""
try:
stock = yf.Ticker(ticker)
company_name = stock.info.get("longName", ticker)
# Search for recent news via Tavily [6]
results = tavily.search(
query=f"{company_name} {ticker} stock news analysis 2025 2026",
max_results=5,
search_depth="advanced",
)
headlines = []
content_for_sentiment = []
for r in results.get("results", []):
headlines.append({
"title": r.get("title", ""),
"url": r.get("url", ""),
"published_date": r.get("published_date", ""),
"snippet": r.get("content", "")[:300],
})
content_for_sentiment.append(r.get("content", ""))
# Simple keyword-based sentiment scoring
combined_text = " ".join(content_for_sentiment).lower()
bullish_keywords = [
"beat", "exceeded", "strong", "growth", "upgrade", "buy",
"outperform", "record", "surge", "rally", "positive", "robust"
]
bearish_keywords = [
"miss", "disappointed", "weak", "decline", "downgrade", "sell",
"underperform", "concern", "risk", "drop", "fell", "negative",
"lawsuit", "investigation", "layoff"
]
bullish_score = sum(combined_text.count(kw) for kw in bullish_keywords)
bearish_score = sum(combined_text.count(kw) for kw in bearish_keywords)
if bullish_score > bearish_score * 1.5:
sentiment = "BULLISH"
sentiment_reasoning = f"News skews positive ({bullish_score} bullish signals vs {bearish_score} bearish)"
elif bearish_score > bullish_score * 1.5:
sentiment = "BEARISH"
sentiment_reasoning = f"News skews negative ({bearish_score} bearish signals vs {bullish_score} bullish)"
else:
sentiment = "NEUTRAL"
sentiment_reasoning = f"Mixed signals ({bullish_score} bullish, {bearish_score} bearish)"
output = {
"ticker": ticker,
"company": company_name,
"headlines": headlines,
"overall_sentiment": sentiment,
"sentiment_reasoning": sentiment_reasoning,
"bullish_signal_count": bullish_score,
"bearish_signal_count": bearish_score,
}
return json.dumps(output, indent=2)
except Exception as e:
return json.dumps({"error": str(e), "ticker": ticker})
@tool
def get_technical_indicators(ticker: str) -> str:
"""
Calculate technical analysis indicators for a stock using the last
200 days of price data. Returns RSI (14-day), MACD and signal line,
50-day and 200-day SMAs, Golden/Death Cross status, and volume analysis.
Use this tool to assess whether the stock is in a technically
favourable or unfavourable position for entry.
RSI above 70 = overbought. RSI below 30 = oversold.
Golden cross (SMA50 > SMA200) is bullish; death cross is bearish.
"""
try:
stock = yf.Ticker(ticker)
df = stock.history(period="200d") # [4]
if df.empty or len(df) < 50:
return json.dumps({"error": "Insufficient price history", "ticker": ticker})
# RSI [5]
df.ta.rsi(length=14, append=True)
rsi = round(df["RSI_14"].iloc[-1], 2)
# MACD [5]
df.ta.macd(fast=12, slow=26, signal=9, append=True)
macd_val = round(df["MACD_12_26_9"].iloc[-1], 4)
macd_signal = round(df["MACDs_12_26_9"].iloc[-1], 4)
macd_histogram = round(df["MACDh_12_26_9"].iloc[-1], 4)
# SMAs [5]
df.ta.sma(length=50, append=True)
df.ta.sma(length=200, append=True)
sma_50 = round(df["SMA_50"].iloc[-1], 2)
sma_200 = round(df["SMA_200"].iloc[-1], 2) if len(df) >= 200 else None
current_price = round(df["Close"].iloc[-1], 2)
# Golden/Death Cross
if sma_200:
cross_status = (
"GOLDEN CROSS (bullish - SMA50 above SMA200)"
if sma_50 > sma_200
else "DEATH CROSS (bearish - SMA50 below SMA200)"
)
else:
cross_status = "N/A (insufficient data for SMA200)"
# Volume
avg_volume_30d = int(df["Volume"].tail(30).mean())
current_volume = int(df["Volume"].iloc[-1])
volume_ratio = round(current_volume / avg_volume_30d, 2)
rsi_signal = (
"OVERBOUGHT - potential reversal ahead" if rsi > 70
else "OVERSOLD - potential buying opportunity" if rsi < 30
else f"NEUTRAL ({rsi})"
)
macd_signal_str = (
"BULLISH - MACD above signal line"
if macd_val > macd_signal
else "BEARISH - MACD below signal line"
)
technicals = {
"ticker": ticker,
"current_price": current_price,
"rsi_14": rsi,
"rsi_signal": rsi_signal,
"macd": macd_val,
"macd_signal_line": macd_signal,
"macd_histogram": macd_histogram,
"macd_interpretation": macd_signal_str,
"sma_50": sma_50,
"sma_200": sma_200,
"cross_status": cross_status,
"price_vs_sma50_pct": round((current_price - sma_50) / sma_50 * 100, 2),
"volume_30d_avg": avg_volume_30d,
"volume_today": current_volume,
"volume_ratio_vs_avg": volume_ratio,
"volume_note": (
"Above average" if volume_ratio > 1.2
else "Below average" if volume_ratio < 0.8
else "In line with average"
),
}
return json.dumps(technicals, indent=2)
except Exception as e:
return json.dumps({"error": str(e), "ticker": ticker})
@tool
def get_analyst_ratings(ticker: str) -> str:
"""
Fetch current Wall Street / institutional analyst consensus ratings for a stock.
Returns the distribution of Strong Buy / Buy / Hold / Sell / Strong Sell
recommendations, the consensus, and the mean price target with implied
upside or downside from the current price.
Use this tool to understand what professional analysts collectively
think about the stock's near-term potential.
"""
try:
stock = yf.Ticker(ticker) # [4]
info = stock.info
current_price = info.get("currentPrice", 0)
target_mean = info.get("targetMeanPrice")
target_high = info.get("targetHighPrice")
target_low = info.get("targetLowPrice")
recommendation = info.get("recommendationKey", "N/A").upper()
num_analysts = info.get("numberOfAnalystOpinions", 0)
implied_upside = None
if target_mean and current_price:
implied_upside = round((target_mean - current_price) / current_price * 100, 2)
rec_df = stock.recommendations
rec_summary = {}
if rec_df is not None and not rec_df.empty:
latest = rec_df.tail(1)
for col in ["strongBuy", "buy", "hold", "sell", "strongSell"]:
if col in latest.columns:
rec_summary[col] = int(latest[col].values[0])
output = {
"ticker": ticker,
"current_price": current_price,
"consensus_recommendation": recommendation,
"number_of_analysts": num_analysts,
"price_target_mean": target_mean,
"price_target_high": target_high,
"price_target_low": target_low,
"implied_upside_pct": implied_upside,
"ratings_distribution": rec_summary,
}
return json.dumps(output, indent=2)
except Exception as e:
return json.dumps({"error": str(e), "ticker": ticker})
# Register all tools for the agent
TOOLS = [
get_stock_financials,
get_news_sentiment,
get_technical_indicators,
get_analyst_ratings,
]
La puntuación de sentimiento aquí es deliberadamente básica y cuenta la frecuencia de las palabras clave. Una configuración en producción sustituiría esto por un análisis de sentimiento basado en LLM o un clasificador ajustado. Para demostrar el bucle ReAct, la versión simple cumple con el objetivo y reduce las dependencias.
Prompt del sistema y agente
El prompt del sistema determina cómo razona el agente: sus prioridades, su orden y el formato de la salida final. La precisión es clave aquí, ya que un prompt vago genera razonamientos vagos.
# agent.py
from langchain_core.messages import SystemMessage
from langchain_openai import ChatOpenAI
from tools import TOOLS
SYSTEM_PROMPT = """You are an expert financial research analyst with deep experience
in fundamental analysis, technical analysis, and market sentiment.
Your task is to produce a comprehensive investment brief for a given stock ticker.
RESEARCH APPROACH:
1. Start with fundamentals - understand the business quality first
2. Then check news sentiment - what is the current market narrative?
3. Run technical analysis - is the price action favourable for entry?
4. Finally check analyst consensus - how does Wall Street see it?
You have access to four research tools. Use ALL of them before writing your brief.
Do not skip any tool - each provides a different dimension of analysis.
INVESTMENT BRIEF FORMAT (use this exact structure in your final response):
## Investment Brief: [TICKER] - [COMPANY NAME]
**Date:** [today's date]
**Recommendation:** [STRONG BUY / BUY / HOLD / SELL / STRONG SELL]
**Conviction:** [HIGH / MEDIUM / LOW]
### The Bull Case
[2-3 sentences on why someone would buy this stock]
### The Bear Case
[2-3 sentences on the key risks and why someone would avoid it]
### Fundamental Snapshot
[Key metrics: P/E, growth, margins, debt. 3-4 bullet points]
### Technical Picture
[RSI, MACD, moving averages, volume. 2-3 bullet points]
### News Sentiment
[Current sentiment and what is driving it. 2-3 bullet points]
### Analyst Consensus
[What analysts think. Price target. 1-2 bullet points]
### Final Verdict
[3-4 sentences synthesising everything into a clear recommendation with reasoning]
---
Be direct. Be specific. Use actual numbers from your research.
Avoid generic statements - every claim must be backed by data you retrieved.
"""
def create_agent():
llm = ChatOpenAI(
model="gpt-4o",
temperature=0.1, # Low temperature for consistent analytical output
)
# Bind tools - this injects tool schemas into every LLM call
return llm.bind_tools(TOOLS)
El gráfico
LangGraph [1][2] se impone sobre el AgentExecutor estándar de LangChain en esta etapa. El gráfico permite un control explícito del ciclo de razonamiento. Se puede ver cada transición de estado, se pueden añadir interrupciones para revisión humana y se pueden conectar nuevos nodos sin afectar la lógica existente.
# graph.py
from langchain_core.messages import HumanMessage, SystemMessage
from langgraph.graph import END, START, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition
from agent import SYSTEM_PROMPT, create_agent
from state import AgentState
from tools import TOOLS
def agent_node(state: AgentState) -> dict:
"""
Core reasoning node. Calls the LLM with the full message history.
The LLM either:
- Returns a tool call → routes to tools node
- Returns a final text response → routes to END
"""
llm_with_tools = create_agent()
messages = state["messages"]
if not any(isinstance(m, SystemMessage) for m in messages):
messages = [SystemMessage(content=SYSTEM_PROMPT)] + messages
response = llm_with_tools.invoke(messages)
return {"messages": [response]}
def build_graph() -> StateGraph:
"""
Construct and compile the ReAct agent graph.
Flow:
START → agent_node → (tool call?) → tools_node → agent_node → ... → END
"""
graph_builder = StateGraph(AgentState)
graph_builder.add_node("agent", agent_node)
graph_builder.add_node("tools", ToolNode(TOOLS))
graph_builder.add_edge(START, "agent")
# tools_condition checks if the last message has a tool_call.
# If yes → "tools". If no → END.
graph_builder.add_conditional_edges("agent", tools_condition)
# After tools execute, always return to the agent for the next reasoning step
graph_builder.add_edge("tools", "agent")
return graph_builder.compile()
La función tools_condition [1] hace muchas cosas de forma silenciosa. Después de cada paso del agente, examina el último mensaje. Una tool_call proveniente del LLM se dirige al nodo tools, mientras que el texto plano, es decir, cuando el modelo ha terminado de razonar y está listo para escribir, se dirige a END.
No existe un número predeterminado de pasos. El bucle continúa hasta que el agente esté satisfecho, lo cual podría ser cinco o seis rondas en un mercado con un intenso flujo de noticias y unas tres en uno más tranquilo. El punto de entrada lo integra todo:
# main.py
import sys
from dotenv import load_dotenv
from langchain_core.messages import HumanMessage
from graph import build_graph
load_dotenv()
def research_stock(ticker: str, stream: bool = True) -> str:
"""
Run the financial research agent on a given ticker.
Args:
ticker: Stock symbol - e.g. "AAPL", "RELIANCE.NS", "VEDL.NS"
stream: If True, prints the ReAct loop steps in real-time
Returns:
Final investment brief as a string
"""
graph = build_graph()
initial_state = {
"messages": [
HumanMessage(
content=f"Please research {ticker.upper()} and produce a comprehensive "
f"investment brief using all available research tools."
)
],
"ticker": ticker.upper(),
"financials": {},
"news_sentiment": {},
"technical_indicators": {},
"analyst_ratings": {},
"investment_brief": "",
}
final_brief = ""
if stream:
print(f"\n{'='*60}")
print(f" Financial Research Agent - {ticker.upper()}")
print(f"{'='*60}\n")
for event in graph.stream(initial_state, stream_mode="values"):
messages = event.get("messages", [])
if messages:
last_msg = messages[-1]
msg_type = type(last_msg).__name__
if msg_type == "AIMessage":
if hasattr(last_msg, "tool_calls") and last_msg.tool_calls:
for tc in last_msg.tool_calls:
print(f"🔧 Calling tool: {tc['name']}")
print(f" Args: {tc['args']}\n")
elif last_msg.content:
print("📋 INVESTMENT BRIEF\n")
print(last_msg.content)
final_brief = last_msg.content
elif msg_type == "ToolMessage":
print(f"✅ Tool result received: {last_msg.name}")
content_preview = last_msg.content[:200].replace('\n', ' ')
print(f" Preview: {content_preview}...\n")
else:
result = graph.invoke(initial_state)
final_brief = result["messages"][-1].content
return final_brief
if __name__ == "__main__":
ticker = sys.argv[1] if len(sys.argv) > 1 else "RELIANCE.NS"
research_stock(ticker, stream=True)
Ejecutarlo
El agente se ejecuta desde la línea de comandos contra cualquiera de los mercados:
# Indian equity
python main.py RELIANCE.NS
# US equity
python main.py AAPL
La salida de streaming se ve así:
============================================================
Financial Research Agent — RELIANCE.NS
============================================================
🔧 Calling tool: get_stock_financials
Args: {'ticker': 'RELIANCE.NS'}
✅ Tool result received: get_stock_financials
Preview: { "company_name": "Reliance Industries Limited",
"sector": "Energy", "market_cap_b": 198.4, "pe_ratio_ttm": 24.1 ...
🔧 Calling tool: get_news_sentiment
Args: {'ticker': 'RELIANCE.NS'}
✅ Tool result received: get_news_sentiment
Preview: { "overall_sentiment": "BULLISH", "sentiment_reasoning":
"News skews positive (42 bullish signals vs 11 bearish) ...
🔧 Calling tool: get_technical_indicators
Args: {'ticker': 'RELIANCE.NS'}
✅ Tool result received: get_technical_indicators
Preview: { "rsi_14": 55.7, "rsi_signal": "NEUTRAL (55.7)",
"cross_status": "GOLDEN CROSS (bullish - SMA50 above SMA200)" ...
🔧 Calling tool: get_analyst_ratings
Args: {'ticker': 'RELIANCE.NS'}
✅ Tool result received: get_analyst_ratings
Preview: { "consensus_recommendation": "BUY", "number_of_analysts": 32,
"implied_upside_pct": 18.6 ...
📋 INVESTMENT BRIEF
## Investment Brief: RELIANCE.NS - Reliance Industries Limited
**Date:** March 22, 2026
**Recommendation:** BUY
**Conviction:** MEDIUM
...
El agente utilizó las cuatro herramientas antes de redactar el resumen y no se detuvo solo en los aspectos fundamentales. Eso es lo que hace funcionar correctamente a ReAct: razonar, actuar, observar, razonar de nuevo, hasta estar satisfecho [3].
La intervención humana
Una característica destacada de LangGraph [1][2] en entornos de producción es la posibilidad de pausar el proceso para esperar a una persona. En finanzas esto es útil, ya que puede ser necesario realizar una verificación de cumplimiento antes de finalizar el resumen, o bien alguien podría querer orientar la investigación a mitad del proceso.
# In graph.py
from langgraph.checkpoint.memory import MemorySaver
def build_graph_with_hitl() -> StateGraph:
graph_builder = StateGraph(AgentState)
graph_builder.add_node("agent", agent_node)
graph_builder.add_node("tools", ToolNode(TOOLS))
graph_builder.add_edge(START, "agent")
graph_builder.add_conditional_edges("agent", tools_condition)
graph_builder.add_edge("tools", "agent")
checkpointer = MemorySaver()
return graph_builder.compile(
checkpointer=checkpointer,
interrupt_before=["agent"], # Pause before the final synthesis step
)
Con el estado persistente, el operador puede ver lo que se ha recopilado, agregar una instrucción y continuar:
config = {"configurable": {"thread_id": "reliance-research-001"}}
graph = build_graph_with_hitl()
# Run until the interrupt
events = list(graph.stream(initial_state, config=config))
# Inspect intermediate state
state = graph.get_state(config)
print("Last message:", state.values["messages"][-1].content)
# Redirect the agent's focus before it writes the brief
graph.update_state(
config,
{"messages": [HumanMessage(
"Please pay particular attention to Reliance's Jio and retail "
"segment revenue contributions in your final brief."
)]}
)
# Resume to completion
for event in graph.stream(None, config=config):
print(event)
Un AgentExecutor estándar no puede hacer esto de manera limpia. La persistencia basada en puntos de control convierte la intervención durante la ejecución en una característica principal en lugar de una solución temporal.
Ampliando el grafo
La estructura del grafo simplifica las adiciones. Dos direcciones parecen prometedoras.
La primera es un nodo de evaluación de riesgos. Colocado después de las herramientas pero antes del resumen final, verifica los resultados y marca los riesgos compuestos:
def risk_assessment_node(state: AgentState) -> dict:
"""Compute a risk score from gathered research before writing the brief."""
risk_flags = []
if state.get("financials"):
fins = state["financials"]
if fins.get("debt_to_equity", 0) > 200:
risk_flags.append("HIGH_LEVERAGE")
if fins.get("revenue_growth_yoy", 0) < -0.05:
risk_flags.append("DECLINING_REVENUE")
# Cross-reference: overbought on bad news is a high-risk combination
if (state.get("news_sentiment", {}).get("overall_sentiment") == "BEARISH"
and state.get("technical_indicators", {}).get("rsi_14", 50) > 65):
risk_flags.append("OVERBOUGHT_ON_BAD_NEWS")
risk_message = f"Risk flags: {risk_flags}" if risk_flags else "No major risk flags."
return {"messages": [HumanMessage(content=f"[Risk Assessment]: {risk_message}")]}
El segundo es una herramienta de comparación con pares. En el caso de una acción de gran capitalización india, una pregunta obvia es si es económica o cara en comparación con sus pares del mismo sector:
@tool
def get_peer_comparison(ticker: str) -> str:
"""
Compare the stock's valuation multiples (P/E, P/S, EV/EBITDA) against
its top 5 sector peers. Returns whether the stock trades at a premium
or discount to peers on key metrics. Use this when you need to contextualise
whether the current valuation is justified relative to competitors.
"""
# Implementation using yfinance sector data
...
Basta con registrarlo en TOOLS, y el agente lo detectará en la siguiente ejecución sin cambios en el gráfico.
Agente versus cadena
El término “agente ReAct” se utiliza de forma amplia, por lo que la diferencia merece precisión. Una cadena es una secuencia fija: paso 1, luego 2, luego 3, sea cual sea el resultado del paso 1. Su composición se determina en el momento del diseño.
Un agente ReAct decide qué hacer a continuación después de cada observación [3]. Supongamos que get_stock_financials devuelve información de una empresa no rentable con ingresos en descenso. Un buen agente ponderará su siguiente llamada en consecuencia, quizás ejecutando get_news_sentiment para ver si el mercado cuenta con alguna noticia que pueda cambiar la situación. Si get_technical_indicators muestra un RSI profundamente sobrecomprado en una empresa con fundamentos sólidos, podría pasar directamente a get_analyst_ratings para comprobar si los profesionales también han detectado esa discrepancia.
Esa secuenciación adaptativa surge del ciclo de Pensamiento, Acción y Observación, y es visible en la salida en tiempo real porque el agente razona entre cada llamada a una herramienta y no solo al final.
También hay beneficios prácticos. Las entradas inesperadas no lo dañan. Una acción de empresa pequeña sin cobertura de analistas no lo hará colapsar, ya que el agente detecta la brecha y razona al respecto. Un símbolo bursátil en plena temporada de resultados con noticias importantes mostrará más incertidumbre en su puntuación de convicción. Esa flexibilidad es la razón para aceptar una mayor complejidad en los gráficos en lugar de una cadena simple.
Función de cada archivo
state.pycontiene el TypedDictAgentStatecon el reductoradd_messages[1]. Si se elimina el reductor, el agente pierde el contexto en cada paso y debe volver a realizar el trabajo.
tools.py define cuatro herramientas de investigación basadas en yfinance [4] y pandas-ta [5]. Sus cadenas de documentación son lo que lee el LLM al elegir una herramienta, por lo que su calidad afecta directamente la precisión de la selección.agent.py vincula las herramientas a GPT-4o y aplica un prompt estructurado que determina el formato de salida.graph.py crea el StateGraph [1][2] utilizando tools_condition para un enrutamiento dinámico de ReAct. No existe un número fijo de iteraciones, y la ejecución finaliza cuando el agente indica que ha terminado.main.py transmite en tiempo real la ejecución, de modo que todo el ciclo de Thought, Act y Observe se muestra al instante.La extensión de intervención humana muestra por qué LangGraph [2] merece ser elegido en lugar de un bucle ReAct implementado manualmente. El estado persistente, las interrupciones mediante puntos de control y la intervención durante la ejecución son características útiles en entornos reales cuando se trabajan con carteras financieras reales.
Referencias
[1] Documentación de LangGraph sobre StateGraph, ToolNode, tools_condition y add_messages, en docs.langchain.com/langgraph.
[2] El repositorio de LangGraph, en github.com/langchain-ai/langgraph.
[3] El artículo de ReAct sobre la combinación de razonamiento y acción en modelos de lenguaje (Yao et al., 2023), en arxiv.org/abs/2210.03629.
[4] yfinance, un descargador de datos de Yahoo Finance, en pypi.org/project/yfinance.
[5] pandas-ta, una biblioteca de análisis técnico para pandas, en pypi.org/project/pandas-ta.
[6] Tavily, una API de búsqueda optimizada para aplicaciones con LLM, en tavily.com.