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.
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 Choice | What Was Chosen | What Was Rejected | Why |
|---|---|---|---|
| Orchestration framework | LangGraph StateGraph | Raw asyncio / sequential loop | Built-in fan-out, typed channels, and graph visualization; agents run in parallel automatically |
| State shape | TypedDict with 3 fields | Flat dict or dataclass | TypedDict gives IDE autocomplete + LangGraph channel typing without runtime overhead |
| Dict merge strategy | Custom merge_dicts | operator.or_ (Python 3.9+) | Explicit shallow merge preserves all keys; | operator would silently drop conflicting keys |
| Analyst wiring | Dynamic add_node loop | Hardcoded node list | Users 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 / Function | File | Purpose |
|---|---|---|
AgentState | graph/state.py | TypedDict defining the three shared state channels (messages, data, metadata) |
merge_dicts() | graph/state.py | Reducer that shallow-merges two dicts for the data and metadata channels |
show_agent_reasoning() | graph/state.py | Debug utility that pretty-prints agent output as JSON to the console |
create_workflow() | main.py | Builds and returns a StateGraph with dynamic analyst wiring |
start() | main.py | Identity function used as the entry-point node (passes state through) |
run_hedge_fund() | main.py | Top-level runner that compiles the workflow, invokes it, and returns decisions |
parse_hedge_fund_response() | main.py | Safely parses JSON from the final AIMessage content |
ANALYST_CONFIG | utils/analysts.py | Single source of truth mapping analyst keys to display names and agent functions |
get_analyst_nodes() | utils/analysts.py | Returns {key: (node_name, agent_func)} mapping for dynamic graph wiring |
ANALYST_ORDER | utils/analysts.py | Derived list of (display_name, key) tuples for CLI display ordering |
get_agents_list() | utils/analysts.py | Returns agent metadata dicts for API responses |
Data Structures
| Type | Name | Description |
|---|---|---|
| TypedDict | AgentState | Three annotated fields: messages (list of BaseMessage, appended via operator.add), data (dict merged via merge_dicts), metadata (dict merged via merge_dicts) |
| dict | ANALYST_CONFIG | Maps analyst key strings to config dicts with display_name, description, investing_style, agent_func, type, and order |
| list | ANALYST_ORDER | Sorted list of (display_name, key) tuples derived from ANALYST_CONFIG |
| dict | portfolio | Nested dict with cash, margin_requirement, margin_used, positions (per-ticker long/short), and realized_gains |
Interactions
| Direction | Partner | Interface | Description |
|---|---|---|---|
| → | All analyst agents | agent_func(state) | Each analyst receives AgentState, reads tickers/dates from data, writes signals to data["analyst_signals"] |
| → | risk_management_agent | risk_management_agent(state) | Reads all analyst signals, produces risk assessment |
| → | portfolio_manager | portfolio_management_agent(state) | Reads risk assessment + signals, produces final trading decisions as an AIMessage |
| ← | CLI / main.py | run_hedge_fund() | Entry point that constructs initial state, compiles graph, and invokes it |
| ← | utils/analysts.py | get_analyst_nodes() | Provides the mapping of analyst keys to node names and functions for dynamic wiring |
| → | utils/progress | progress.start() / stop() | Tracks execution progress with a spinner/bar during workflow invocation |
| → | utils/display | print_trading_output() | Formats and prints the final trading decisions after workflow completion |
| → | utils/visualize | save_graph_as_png() | Optionally renders the compiled graph as a PNG image for debugging |
Configuration
| Knob / Parameter | Default | Description |
|---|---|---|
selected_analysts | All 19 | Which 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_reasoning | False | When true, agents call show_agent_reasoning() to dump intermediate output |
initial_cash | CLI input | Starting cash for the portfolio |
margin_requirement | CLI input | Margin 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]: Bothdataandmetadatause 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), andmetadata(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_edgeloop: Creates the fan-out pattern. Eachadd_edge("start_node", analyst)tells LangGraph to run these nodes in parallel afterstart_nodecompletes.- 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: Marksstart_nodeas the first node to execute. Without this, LangGraph wouldn’t know where to begin.
Edge Cases & Boundaries
| Edge Case | What Happens | Where in Code | Risk Level |
|---|---|---|---|
| No analysts selected | selected_analysts=None defaults to all 19 analysts | create_workflow:L5 | Low |
| Invalid analyst key | KeyError from analyst_nodes[key] — crashes with traceback | create_workflow:L12 | High |
| LLM returns non-JSON | parse_hedge_fund_response catches JSONDecodeError, returns None | main.py:L32 | Medium |
| Concurrent dict key collision | merge_dicts uses {**a, **b} — last writer wins for same key | state.py:L10 | Medium |
| Agent raises exception | LangGraph propagates the error; progress.stop() runs in finally block | run_hedge_fund:L22 | Medium |
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"},
})