Accueil / Articles

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.

Éditeur : REACTAPP.TOP16 min de lecture3776 mots

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 :

  1. 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.
  2. Rassembler les actualités récentes et les évaluer comme positives, négatives ou neutres, en indiquant les raisons.
  3. Calculer les indicateurs techniques : RSI, MACD, moyennes mobiles sur 50 et 200 jours ainsi que la tendance du volume.
  4. Récupérer le consensus des analystes, c’est-à-dire la répartition des avis « acheter », « conserver » et « vendre ».
  5. 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.py contient le TypedDict AgentState avec le réducteur add_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.