core/graph/ — LangGraph Workflow Orchestration

Defines AgentState (shared state contract) and builds the StateGraph workflow

The graph module is the nervous system of the AI Hedge Fund — it declares the typed shared state that every agent reads and writes, and wires together the LangGraph StateGraph that orchestrates the entire multi-agent pipeline from market data ingestion through Portfolio Tracking & Performance Metrics" href="../../backtesting_doc/portfolio/index.html">portfolio decisions.

2
Core Types
3
Files
~0.2k
Lines
19
Agent Slots

What is this & why does it exist?

The graph module is the nervous system of the hedge fund. AgentState is a TypedDict with three fields — messages, data, and metadata — that every agent reads from and writes to. The merge_dicts reducer handles concurrent writes from parallel agents. The create_workflow() function in main.py builds a LangGraph StateGraph by connecting start_node → analyst agents (fan-out) → risk_manager → portfolio_manager → END. Although only ~180 lines, this is the most critical code — it defines how agents compose.

Real-world analogy

AgentState is like a shared whiteboard in a conference room. Each analyst (agent) walks up, reads what’s there, writes their analysis, and sits down. The merge_dicts operator ensures two analysts writing at the same time don’t erase each other’s work. The StateGraph is the meeting agenda — it says who presents when and in what order. The risk manager only speaks after all analysts have finished, and the portfolio manager always has the last word.

Why Is It Designed This Way?

LangGraph’s StateGraph was chosen over a hand-rolled orchestrator because it provides built-in support for parallel fan-out (all analyst agents run concurrently), typed state channels with reducer functions (so concurrent writes merge cleanly), and a compile-to-graph execution model that enables visualization and debugging. The AgentState TypedDict pattern gives type safety without runtime overhead — every agent declares exactly what it reads and writes. The merge_dicts reducer was chosen over operator.or_ because it preserves all keys from both dicts even when nested keys overlap.

Design ChoiceWhat Was ChosenWhat Was RejectedWhy
Orchestration frameworkLangGraph StateGraphRaw asyncio / sequential loopBuilt-in fan-out, typed channels, and graph visualization; agents run in parallel automatically
State shapeTypedDict with 3 fieldsFlat dict or dataclassTypedDict gives IDE autocomplete + LangGraph channel typing without runtime overhead
Dict merge strategyCustom merge_dictsoperator.or_ (Python 3.9+)Explicit shallow merge preserves all keys; | operator would silently drop conflicting keys
Analyst wiringDynamic add_node loopHardcoded node listUsers select analysts at CLI; dynamic wiring avoids unused nodes in the graph

Contents

Architecture

The workflow follows a fan-out / fan-in pattern: a single start node broadcasts to all selected analyst agents in parallel, their outputs converge at the risk manager, and finally the portfolio manager produces trading decisions.

flowchart TD
  START["start_node"] --> A1["Analyst 1\n(e.g. warren_buffett)"]
  START --> A2["Analyst 2\n(e.g. ben_graham)"]
  START --> A3["Analyst 3\n(e.g. cathie_wood)"]
  START --> AN["Analyst N\n(up to 19 agents)"]
  A1 --> RM["risk_management_agent"]
  A2 --> RM
  A3 --> RM
  AN --> RM
  RM --> PM["portfolio_manager"]
  PM --> END_NODE["END"]

Data Flow

Every node receives and returns the same AgentState shape. The three channels — messages, data, and metadata — use different reducers to merge parallel writes.

flowchart LR
  subgraph AgentState
    M["messages\n(Annotated, operator.add)"]
    D["data\n(Annotated, merge_dicts)"]
    MD["metadata\n(Annotated, merge_dicts)"]
  end
  CLI["CLI / main.py"] -->|"tickers, dates,\nportfolio, config"| D
  CLI -->|"HumanMessage"| M
  CLI -->|"model_name,\nshow_reasoning"| MD
  D -->|"analyst_signals"| RM["risk_management_agent"]
  RM -->|"risk_assessment"| PM["portfolio_manager"]
  PM -->|"trading decisions\n(AIMessage)"| M

Key Classes & Functions

Class / FunctionFilePurpose
AgentStategraph/state.pyTypedDict defining the three shared state channels (messages, data, metadata)
merge_dicts()graph/state.pyReducer that shallow-merges two dicts for the data and metadata channels
show_agent_reasoning()graph/state.pyDebug utility that pretty-prints agent output as JSON to the console
create_workflow()main.pyBuilds and returns a StateGraph with dynamic analyst wiring
start()main.pyIdentity function used as the entry-point node (passes state through)
run_hedge_fund()main.pyTop-level runner that compiles the workflow, invokes it, and returns decisions
parse_hedge_fund_response()main.pySafely parses JSON from the final AIMessage content
ANALYST_CONFIGutils/analysts.pySingle source of truth mapping analyst keys to display names and agent functions
get_analyst_nodes()utils/analysts.pyReturns {key: (node_name, agent_func)} mapping for dynamic graph wiring
ANALYST_ORDERutils/analysts.pyDerived list of (display_name, key) tuples for CLI display ordering
get_agents_list()utils/analysts.pyReturns agent metadata dicts for API responses

Data Structures

TypeNameDescription
TypedDictAgentStateThree annotated fields: messages (list of BaseMessage, appended via operator.add), data (dict merged via merge_dicts), metadata (dict merged via merge_dicts)
dictANALYST_CONFIGMaps analyst key strings to config dicts with display_name, description, investing_style, agent_func, type, and order
listANALYST_ORDERSorted list of (display_name, key) tuples derived from ANALYST_CONFIG
dictportfolioNested dict with cash, margin_requirement, margin_used, positions (per-ticker long/short), and realized_gains

Interactions

DirectionPartnerInterfaceDescription
→All analyst agentsagent_func(state)Each analyst receives AgentState, reads tickers/dates from data, writes signals to data["analyst_signals"]
→risk_management_agentrisk_management_agent(state)Reads all analyst signals, produces risk assessment
→portfolio_managerportfolio_management_agent(state)Reads risk assessment + signals, produces final trading decisions as an AIMessage
←CLI / main.pyrun_hedge_fund()Entry point that constructs initial state, compiles graph, and invokes it
←utils/analysts.pyget_analyst_nodes()Provides the mapping of analyst keys to node names and functions for dynamic wiring
→utils/progressprogress.start() / stop()Tracks execution progress with a spinner/bar during workflow invocation
→utils/displayprint_trading_output()Formats and prints the final trading decisions after workflow completion
→utils/visualizesave_graph_as_png()Optionally renders the compiled graph as a PNG image for debugging

Configuration

Knob / ParameterDefaultDescription
selected_analystsAll 19Which analyst agents to include in the workflow; None means all
model_name"gpt-4.1"LLM model name passed through metadata to every agent
model_provider"OpenAI"LLM provider key (OpenAI, Groq, Anthropic, etc.)
show_reasoningFalseWhen true, agents call show_agent_reasoning() to dump intermediate output
initial_cashCLI inputStarting cash for the portfolio
margin_requirementCLI inputMargin requirement ratio for short positions

Directory Map

Source tree for graph
src/graph/
  state.py               ← AgentState TypedDict + merge_dicts + show_agent_reasoning
src/
  main.py                ← create_workflow(), run_hedge_fund(), start(), CLI entry point
src/utils/
  analysts.py            ← ANALYST_CONFIG, get_analyst_nodes(), ANALYST_ORDER

Annotated Code Walkthrough

The most important code in this sub-module, annotated with WHY each piece exists:

AgentState & merge_dicts (graph/state.py)

from typing_extensions import Annotated, Sequence, TypedDict
import operator
from langchain_core.messages import BaseMessage

def merge_dicts(a: dict[str, any], b: dict[str, any]) -> dict[str, any]:
    # WHY: Shallow merge ensures parallel agents don't overwrite each other's keys.
    # operator.or_ (|) would work for flat dicts but merge_dicts is explicit and safe.
    return {**a, **b}

class AgentState(TypedDict):
    # WHY: Annotated channels tell LangGraph HOW to merge concurrent writes.
    messages: Annotated[Sequence[BaseMessage], operator.add]  # append-only
    data: Annotated[dict[str, any], merge_dicts]              # shallow merge
    metadata: Annotated[dict[str, any], merge_dicts]          # shallow merge

Line-by-line:

  • Annotated[..., operator.add]: Messages use list concatenation — each agent appends its messages rather than replacing. Without this, only the last agent’s message would survive parallel execution.
  • Annotated[..., merge_dicts]: Both data and metadata use shallow dict merge. When agent A writes {"analyst_signals": {"buffett": ...}} and agent B writes {"analyst_signals": {"graham": ...}}, the reducer produces a dict with both keys.
  • Three fields, not one: Separating messages (LLM conversation), data (business data), and metadata (config) prevents accidental overwrites and keeps each channel’s merge semantics clean.

create_workflow() (main.py)

def create_workflow(selected_analysts=None):
    """Create the workflow with selected analysts."""
    workflow = StateGraph(AgentState)
    workflow.add_node("start_node", start)

    analyst_nodes = get_analyst_nodes()

    # WHY: Default to all analysts when none selected (CLI passed empty list)
    if selected_analysts is None:
        selected_analysts = list(analyst_nodes.keys())

    # WHY: Dynamic wiring - only add nodes the user actually selected
    for analyst_key in selected_analysts:
        node_name, node_func = analyst_nodes[analyst_key]
        workflow.add_node(node_name, node_func)
        workflow.add_edge("start_node", node_name)  # fan-out from start

    # WHY: Risk and portfolio management are always present (non-optional)
    workflow.add_node("risk_management_agent", risk_management_agent)
    workflow.add_node("portfolio_manager", portfolio_management_agent)

    # WHY: Fan-in - every analyst must complete before risk assessment
    for analyst_key in selected_analysts:
        node_name = analyst_nodes[analyst_key][0]
        workflow.add_edge(node_name, "risk_management_agent")

    workflow.add_edge("risk_management_agent", "portfolio_manager")
    workflow.add_edge("portfolio_manager", END)
    workflow.set_entry_point("start_node")
    return workflow

Line-by-line:

  • StateGraph(AgentState): Binds the graph to the typed state — LangGraph validates that every node returns compatible state updates.
  • add_node + add_edge loop: Creates the fan-out pattern. Each add_edge("start_node", analyst) tells LangGraph to run these nodes in parallel after start_node completes.
  • Second loop for fan-in: Each add_edge(analyst, "risk_management_agent") creates the convergence point. LangGraph waits for ALL incoming edges before running risk management.
  • set_entry_point: Marks start_node as the first node to execute. Without this, LangGraph wouldn’t know where to begin.

Edge Cases & Boundaries

Edge CaseWhat HappensWhere in CodeRisk Level
No analysts selectedselected_analysts=None defaults to all 19 analystscreate_workflow:L5Low
Invalid analyst keyKeyError from analyst_nodes[key] — crashes with tracebackcreate_workflow:L12High
LLM returns non-JSONparse_hedge_fund_response catches JSONDecodeError, returns Nonemain.py:L32Medium
Concurrent dict key collisionmerge_dicts uses {**a, **b} — last writer wins for same keystate.py:L10Medium
Agent raises exceptionLangGraph propagates the error; progress.stop() runs in finally blockrun_hedge_fund:L22Medium

Code Examples

Example: Running the hedge fund from code

from src.main import run_hedge_fund

result = run_hedge_fund(
    tickers=["AAPL", "MSFT", "NVDA"],
    start_date="2025-01-01",
    end_date="2025-12-31",
    portfolio={"cash": 100000, "margin_requirement": 0.5,
               "margin_used": 0.0, "positions": {}, "realized_gains": {}},
    show_reasoning=True,
    selected_analysts=["warren_buffett", "cathie_wood", "technicals"],
    model_name="gpt-4.1",
    model_provider="OpenAI",
)
print(result["decisions"])   # Trading decisions dict
print(result["analyst_signals"])  # Per-analyst signal details

Example: Building a custom workflow with subset of analysts

from src.main import create_workflow

# Only use value-oriented analysts
workflow = create_workflow(["warren_buffett", "ben_graham", "charlie_munger"])
agent = workflow.compile()

# Invoke with custom state
final_state = agent.invoke({
    "messages": [HumanMessage(content="Analyze AAPL")],
    "data": {"tickers": ["AAPL"], "portfolio": {...}, ...},
    "metadata": {"model_name": "gpt-4.1", "model_provider": "OpenAI"},
})