Créer un agent de recherche boursière avec ReAct et LangGraph
Un agent LangGraph ReAct prend un symbole boursier, consulte les données fondamentales, les actualités, les indicateurs techniques ainsi que les outils d’analystes dans son propre ordre, et rédige un bref rapport, en intégrant des pauses pour l’intervention humaine.
Sources: Building a Financial Research Agent with ReAct, LangGraph, and LangChain
Rechercher des actions avant l’ouverture du marché est une tâche courante. Ceux qui suivent les actions indiennes telles que ICICI, HDFC, Reliance ou HAL peuvent facilement passer 40 minutes à naviguer entre Screener.in, Moneycontrol et divers onglets d’actualités pour rassembler manuellement les informations nécessaires. C’est précisément ce type de collecte répétitive que peut assumer un agent.
Ce qui suit est une implémentation complète et fonctionnelle d’un agent de recherche basé sur le modèle ReAct, avec du code réel et sans pseudocode. On y entre un symbole boursier comme RELIANCE.NS ou AAPL. L’agent analyse les fondamentaux, l’opinion des médias, les indicateurs techniques ainsi que le consensus des analystes, avant de rédiger un rapport d’investissement structuré.
La partie surprenante, c’est qu’il n’existe pas de script fixe. L’agent choisit ce qu’il faut examiner et dans quel ordre en fonction de ce qu’il découvre, et c’est cette capacité d’adaptation qui constitue l’essence même de ReAct.
Que fait l’agent
À partir d’un symbole boursier, l’agent travaille de manière autonome selon les étapes suivantes :
- Récupérer les données fondamentales : rapport cours/bénéfice, croissance des revenus, bénéfice par action, ratio dette/actif et marges.
- Rassembler les actualités récentes et les évaluer comme positives, négatives ou neutres, en indiquant les raisons.
- Calculer les indicateurs techniques : RSI, MACD, moyennes mobiles sur 50 et 200 jours ainsi que la tendance du volume.
- Récupérer le consensus des analystes, c’est-à-dire la répartition des avis « acheter », « conserver » et « vendre ».
- Fusionner tout cela en un rapport structuré se terminant par une recommandation.
L’ordre des appels aux outils relève du choix de l’agent. Les aspects techniques peuvent précéder les aspects fondamentaux. Ou bien il peut lire les nouvelles, repérer un événement majeur au sein d’une entreprise et vérifier immédiatement les données financières pour voir si les chiffres confirment ce qui est rapporté. De telles décisions distinguent un agent ReAct d’une chaîne de traitement. La conception globale :
User: "Research RELIANCE.NS"
│
▼
┌─────────────┐ tool_call ┌─────────────┐
│ Agent Node │ ──────────────────► │ Tools Node │
│ (LLM) │ ◄────────────────── │ (ToolNode) │
└─────────────┘ observation └─────────────┘
│
│ no more tool calls
▼
Final Investment Brief
C’est le cycle ReAct de Raisonnement, Action, Observation, puis Raisonnement à nouveau, répété jusqu’à ce que l’agent juge disposer d’informations suffisantes [3].
Mise en place
Installez d’abord les dépendances :
pip install langgraph langchain langchain-openai yfinance pandas-ta \
tavily-python python-dotenv pydantic
Vient ensuite un fichier .env contenant les clés API :
OPENAI_API_KEY=your_openai_key
TAVILY_API_KEY=your_tavily_key
La structure du projet est simple :
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
État de l’agent
L’état correspond à la mémoire partagée qui circule à travers chaque nœud du graphe. Dans LangGraph [1], chaque nœud le lit et y écrit à son tour, ce qui en fait la mémoire de travail de l’agent pendant toute la session de recherche.
# 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
Deux détails se détachent. Le champ messages utilise add_messages comme réducteur [1] ; ainsi, chaque mise à jour ajoute des messages au lieu de les remplacer. Cela permet de conserver l’historique complet de Réflexion, Action et Observation tout au long du cycle. Sans ce réducteur, chaque nœud écraserait la conversation précédente et l’agent oublierait ce qu’il avait vérifié, erreur qui se manifeste souvent par l’appel répété du même outil. Les champs de recherche structurés tels que financials et news_sentiment sont de simples dictionnaires, remplis au fur et à mesure que les outils s’exécutent et consultés lors des raisonnements ultérieurs.
Outils
Les définitions des outils peuvent être plus importantes que la forme du graphe ou le prompt système. Le LLM lit la description de chaque outil pour décider lequel appeler et quand. Des descriptions vagues entraînent l’omission d’outils ou un ordre anormal, il est donc préférable de les rédiger comme de la documentation destinée à un collègue compétent et non comme un schéma pour un validateur.
# 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,
]
Le calcul de la note d’opinion est ici délibérément simplifié et se base sur la fréquence des mots-clés. Une configuration en production utiliserait plutôt un analyseur d’opinion basé sur un LLM ou un classificateur affiné. Afin de démontrer le cycle ReAct, la version simple suffit et permet de réduire les dépendances.
Prompt système et agent
Le prompt système détermine la manière dont l’agent raisonne : ses priorités, leur ordre ainsi que le format du résultat final. La précision est essentielle ici, car un prompt flou entraîne des raisonnements flous.
# 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)
Le graphe
LangGraph [1][2] se distingue à ce stade par rapport au AgentExecutor standard de LangChain. Le graphe permet un contrôle explicite du cycle de raisonnement : chaque transition d’état est visible, des interruptions pour révision humaine peuvent être ajoutées, et de nouveaux nœuds peuvent être associés sans affecter la logique existante.
# 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 fonction tools_condition [1] effectue silencieusement de nombreuses tâches. Après chaque étape de l’agent, elle examine le dernier message reçu. Une tool_call émise par le LLM est acheminée vers le nœud tools, tandis que du texte brut, indiquant que le modèle a terminé sa réflexion et est prêt à écrire, est dirigé vers END.
Il n’y a pas de nombre d’étapes prédéfini. La boucle se poursuit jusqu’à ce que l’agent soit satisfait, ce qui peut représenter cinq ou six itérations pour un marché à fort flux d’informations et environ trois pour un marché calme. Le point d’entrée coordonne tout cela :
# 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)
Lancement
L’agent s’exécute depuis la ligne de commande sur n’importe quel marché :
# Indian equity
python main.py RELIANCE.NS
# US equity
python main.py AAPL
La sortie en streaming a l’aspect suivant :
============================================================
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
...
L’agent a utilisé les quatre outils avant de rédiger le rapport et n’a pas cessé à l’étape des fondamentaux. C’est ainsi que ReAct fonctionne correctement : raisonner, agir, observer, raisonner à nouveau, jusqu’à être satisfait [3].
L’humain dans le processus
Une fonctionnalité remarquable de LangGraph [1][2] est la capacité d’arrêter temporairement le traitement du graphe pour attendre une intervention humaine. Cela est utile en finance, car un contrôle de conformité peut être nécessaire avant la finalisation d’un rapport, ou quelqu’un peut vouloir orienter la recherche en cours de route.
# 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
)
Avec un état persistant, l’opérateur peut consulter ce qui a été collecté, ajouter une instruction et reprendre le travail :
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 standard ne peut pas le faire de manière propre. La persistance basée sur des points de contrôle fait de l’intervention pendant l’exécution une fonctionnalité à part entière plutôt qu’une solution de fortune.
Étendre le graphe
La structure du graphe rend les ajouts simples. Deux directions semblent prometteuses.
La première est un nœud d’évaluation des risques. Placé après les outils mais avant le résumé final, il vérifie en croisant les résultats et met en évidence les risques cumulatifs :
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}")]}
Le deuxième est un Outil de comparaison avec des pairs. Pour une action indienne de grande capitalisation, une question évidente est de savoir si elle est bon marché ou chère par rapport à ses concurrents du même secteur :
@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
...
Il suffit de l’enregistrer dans TOOLS, et l’agent le prendra en compte lors du prochain exécution sans aucun changement dans le graphe.
Agent versus chaîne
Le terme « agent ReAct » est utilisé de manière assez vague, donc la différence mérite d’être précisée. Une chaîne est une séquence fixe : étape 1, puis 2, puis 3, quel que soit le résultat de l’étape 1. Sa composition est déterminée au moment de la conception.
Un agent ReAct décide de ce qu’il doit faire ensuite après chaque observation [3]. Supposons que get_stock_financials renvoie une entreprise non rentable dont les revenus diminuent. Un bon agent pondérera en conséquence sa prochaine requête, en exécutant peut-être get_news_sentiment pour voir s’il existe des signes d’amélioration sur le marché. Si get_technical_indicators montre un RSI fortement sous-évalué pour une entreprise fondamentalement solide, il pourrait se tourner directement vers get_analyst_ratings pour savoir si les professionnels ont également remarqué cette différence.
Cette séquenciation adaptative provient du cycle Pensée, Action, Observation, et elle est visible dans la sortie en flux continu, car l’agent raisonne entre chaque appel d’outil et non seulement à la fin.
Il y a aussi des avantages pratiques : des entrées inattendues ne le perturbent pas. Une petite société sans couverture par les analystes ne le fera pas planter, car l’agent note cette lacune et trouve une solution en conséquence. Un titre en plein exercice de publication de résultats, avec beaucoup de nouvelles, montrera plus d’incertitude dans son score de conviction. C’est cette flexibilité qui justifie l’acceptation d’une complexité graphique accrue par rapport à une chaîne simple.
Quel est le rôle de chaque fichier
state.pycontient le TypedDictAgentStateavec le réducteuradd_messages[1]. En supprimant ce réducteur, l’agent perd le contexte à chaque étape et doit refaire son travail.
tools.py définit quatre outils de recherche basés sur yfinance [4] et pandas-ta [5]. Leurs strings de documentation sont ceux que le LLM lit lors du choix d’un outil, de sorte que leur qualité influence directement la précision du choix.agent.py relie ces outils à GPT-4o et applique un prompt système structuré qui fixe le format de sortie.graph.py crée le StateGraph [1][2] en utilisant tools_condition pour un routage dynamique ReAct. Aucun nombre d’itérations n’est fixé, et l’exécution se termine lorsque l’agent indique qu’elle est terminée.main.py diffuse l’exécution en temps réel, de manière à ce que tout le cycle Thought, Act, Observe soit visible en direct.L’extension « human-in-the-loop » montre pourquoi LangGraph [2] mérite d’être choisi plutôt qu’un cycle ReAct conçu manuellement. L’état persistant, les interruptions par points de contrôle et l’intervention en cours d’exécution sont des fonctionnalités adaptées à la production qui s’avèrent essentielles lorsqu’il s’agit de portefeuilles réels.
Références
[1] Documents de LangGraph sur StateGraph, ToolNode, tools_condition et add_messages, disponibles à docs.langchain.com/langgraph.
[2] Le répertoire GitHub de LangGraph, accessible à github.com/langchain-ai/langgraph.
[3] L’article ReAct sur la combinaison du raisonnement et de l’action dans les modèles de langage (Yao et al., 2023), disponible à arxiv.org/abs/2210.03629.
[4] yfinance, un outil de téléchargement de données Yahoo Finance, disponible à pypi.org/project/yfinance.
[5] pandas-ta, une bibliothèque d’analyse technique pour pandas, disponible à pypi.org/project/pandas-ta.
[6] Tavily, une API de recherche optimisée pour les applications basées sur des LLM, accessible à tavily.com.