Cash Management is the nervous system of corporate fintech. Unlike consumer banking, it involves complex entity hierarchies, multi-bank liquidity structures, regulatory constraints (Basel III LCR, NSFR), and real-time payment rails. Traditional RAG fails here because cash positioning is structurally determined—you cannot vector-search your way to understanding a subsidiary’s trapped cash or a cross-border pooling arrangement. This article demonstrates how to combine graph traversal with LLM reasoning in a production-grade Cash Management system. We implement an enterprise Multi-Agent LangGraph architecture where graph queries provide structural ground truth, RAG supplies regulatory/policy context, and LLMs synthesize both into actionable treasury intelligence—all with persistent state and audit-grade memory.
1. How Graph Traversal + LLM/RAG Reasoning Actually Works
Before the code, let’s demystify the integration pattern. There are three distinct modes of combining these technologies, each serving a different purpose in cash management:
Mode 1: Graph-as-Ground-Truth for LLM Reasoning
The LLM never guesses about entity relationships, balances, or hierarchies. Graph traversal returns structured facts; the LLM reasons over those facts.
User Question → Agent decomposes → Cypher query → Structured result → LLM synthesizes answer
Why this matters: Hallucinating a subsidiary relationship or cash balance in treasury management is catastrophic. The graph is the single source of truth; the LLM is the reasoning layer.
Mode 2: RAG-for-Policy + Graph-for-Position
Regulatory constraints live in unstructured documents (central bank circulars, internal treasury policies). Entity positions live in the graph. The agent fuses both before answering.
Question → Parallel execution:
├─ Vector search → Relevant policy excerpts
└─ Graph traversal → Current cash positions & hierarchy
→ LLM combines policy constraints + actual positions → Compliant recommendation
Why this matters: "Can we sweep €50M from our Dutch BV to the Singapore hub?" requires knowing BOTH the current balance (graph) AND the ECB transfer pricing / tax implications (RAG).
Mode 3: Graph-Informed Query Planning
The LLM uses lightweight graph schema introspection to plan which traversals to execute, rather than generating Cypher blindly.
Question → Schema-aware planning agent → Validated Cypher → Execution → Result validation → Synthesis
Why this matters: Cash management graphs have domain-specific patterns (pooling structures, notional vs. physical sweeps, currency buckets). Blind text-to-Cypher fails on these. Schema-grounded planning succeeds.
![407]()
2. Cash Management Graph Ontology
// Corporate Structure
(:CorporateGroup)-[:CONTAINS]->(:LegalEntity)
(:LegalEntity)-[:OWNED_BY {pct: float}]->(:LegalEntity)
(:LegalEntity)-[:HAS_ACCOUNT]->(:BankAccount)
(:LegalEntity)-[:SUBJECT_TO]->(:RegulatoryJurisdiction)
// Banking & Liquidity
(:BankAccount)-[:HELD_AT]->(:Bank)
(:BankAccount)-[:DENOMINATED_IN]->(:Currency)
(:BankAccount)-[:PARTICIPATES_IN]->(:CashPool)
(:CashPool)-[:MANAGED_BY]->(:HeaderAccount)
(:CashPool)-[:POOL_TYPE]->(:PoolType) // NOTIONAL, PHYSICAL, ZERO_BALANCE
// Transactions & Positions
(:BankAccount)-[:HAS_POSITION]->(:CashPosition)
(:CashPosition)-[:AS_OF]->(:Timestamp)
(:BankAccount)-[:SENT_PAYMENT]->(:PaymentInstruction)
(:PaymentInstruction)-[:VIA_RAIL]->(:PaymentRail) // SEPA, FEDWIRE, SWIFT_GPI
// Regulatory & Policy
(:RegulatoryJurisdiction)-[:GOVERNED_BY]->(:Regulation)
(:LegalEntity)-[:BOUND_BY]->(:TreasuryPolicy)
(:TreasuryPolicy)-[:SUPERSEDES]->(:TreasuryPolicy)
(:Regulation)-[:CONSTRAINS]->(:CashMovement)
Key design decisions:
CashPosition as separate node (not account property): Enables historical position snapshots without temporal bloat on accounts
PoolType as node (not string): Allows policy constraints to reference pool types as first-class entities
PaymentRail as node: Rail-specific limits, cut-off times, and fees become traversable attributes
Ownership percentage on edge: Critical for consolidation logic and minority interest calculations
3. Real-Time Use Case: Cross-Border Liquidity Optimization Under Regulatory Constraints
Scenario: A multinational treasurer asks at 6:00 AM EST:
"What’s our global cash position by currency? Can we fund the APAC deficit from European surplus without violating ECB liquidity coverage rules or triggering Chinese SAFE repatriation limits? Recommend optimal sweep structure."
This requires:
Global position aggregation across 47 entities (graph traversal)
ECB LCR constraint retrieval (RAG)
Chinese SAFE limit lookup (graph + RAG)
Pool structure feasibility analysis (graph traversal)
Synthesized sweep recommendation with compliance validation (LLM reasoning)
All within a single conversational session with memory of prior queries.
4. End-to-End Implementation
Step 1: Typed State with Treasury-Specific Memory
from typing import Annotated, List, Dict, Any, Optional
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages
class CashManagementState(TypedDict):
"""Persistent state for treasury investigation sessions."""
messages: Annotated[List[Any], add_messages]
# Session context
session_id: str
treasurer_id: str
corporate_group_id: str
# Graph-derived positions
global_positions_by_currency: Dict[str, float]
entity_hierarchy: List[Dict[str, Any]]
cash_pool_structures: List[Dict[str, Any]]
payment_rail_constraints: List[Dict[str, Any]]
trapped_cash_entities: List[str]
# RAG-derived regulatory context
applicable_regulations: List[Dict[str, str]]
treasury_policy_excerpts: List[Dict[str, str]]
regulatory_constraints: List[Dict[str, Any]]
# Synthesized output
liquidity_gap_analysis: Optional[Dict[str, float]]
sweep_recommendations: List[Dict[str, Any]]
compliance_warnings: List[str]
# Workflow control
phase: str # "position_aggregation", "constraint_retrieval",
# "optimization", "compliance_check", "complete"
requires_human_approval: bool
error_log: List[str]
Step 2: Graph Tools for Cash Position Intelligence
from langchain_neo4j import Neo4jGraph
from langchain_core.tools import tool
from decimal import Decimal
graph = Neo4jGraph(
url="bolt://treasury-graph-prod.databases.neo4j.io:7687",
username="neo4j",
password="${NEO4J_PASSWORD}",
database="cash-management",
read_only=True
)
@tool
def get_global_cash_position(group_id: str, as_of_date: str = None) -> dict:
"""
Aggregates cash positions across all entities in corporate group.
Returns per-currency totals and identifies trapped cash jurisdictions.
"""
date_filter = f"AND cp.as_of >= datetime('{as_of_date}')" if as_of_date else ""
query = f"""
MATCH (cg:CorporateGroup {{group_id: $gid}})-[:CONTAINS]->(le:LegalEntity)
-[:HAS_ACCOUNT]->(ba:BankAccount)-[:DENOMINATED_IN]->(cur:Currency)
MATCH (ba)-[:HAS_POSITION]->(cp:CashPosition)
WHERE cp.is_current = true {date_filter}
OPTIONAL MATCH (le)-[:SUBJECT_TO]->(jur:RegulatoryJurisdiction)
OPTIONAL MATCH (ba)-[:PARTICIPATES_IN]->(pool:CashPool)
WITH cur.code AS currency,
sum(cp.available_balance) AS total_available,
sum(cp.ledger_balance) AS total_ledger,
collect(DISTINCT {{
entity: le.name,
jurisdiction: jur.code,
balance: cp.available_balance,
pool: pool.pool_id,
trapped: jur.has_capital_controls
}}) AS entity_breakdown
RETURN currency, total_available, total_ledger, entity_breakdown,
[eb IN entity_breakdown WHERE eb.trapped = true | eb.entity] AS trapped_entities
ORDER BY total_available DESC
"""
results = graph.query(query, {"gid": group_id})
positions = {}
all_trapped = []
for r in results:
positions[r["currency"]] = {
"available": float(r["total_available"]),
"ledger": float(r["total_ledger"]),
"entities": r["entity_breakdown"]
}
all_trapped.extend(r["trapped_entities"])
return {
"positions_by_currency": positions,
"trapped_cash_entities": list(set(all_trapped)),
"total_currencies": len(positions)
}
@tool
def analyze_pool_feasibility(source_entity: str, target_entity: str, amount: float, currency: str) -> dict:
"""
Evaluates whether a sweep between two entities is structurally feasible.
Checks pool membership, rail availability, and intermediary requirements.
"""
query = """
// Check direct pool connectivity
MATCH (src:LegalEntity {name: $source})-[:HAS_ACCOUNT]->(sa:BankAccount)
-[:DENOMINATED_IN]->(cur:Currency {code: $currency})
MATCH (tgt:LegalEntity {name: $target})-[:HAS_ACCOUNT]->(ta:BankAccount)
-[:DENOMINATED_IN]->(cur)
OPTIONAL MATCH (sa)-[:PARTICIPATES_IN]->(sp:CashPool)<-[:PARTICIPATES_IN]-(ta)
OPTIONAL MATCH (sa)-[:SENT_PAYMENT]->(pi:PaymentInstruction)-[:VIA_RAIL]->(rail:PaymentRail)
WHERE pi.status = 'ACTIVE' AND rail.supports_currency = $currency
// Check regulatory path
OPTIONAL MATCH (src)-[:SUBJECT_TO]->(sj:RegulatoryJurisdiction)
OPTIONAL MATCH (tgt)-[:SUBJECT_TO]->(tj:RegulatoryJurisdiction)
OPTIONAL MATCH (sj)-[:GOVERNED_BY]->(reg:Regulation)-[:CONSTRAINS]->(cm:CashMovement)
RETURN
sp IS NOT NULL AS same_pool,
sp.pool_type AS pool_type,
collect(DISTINCT rail.name) AS available_rails,
collect(DISTINCT {{
regulation: reg.name,
constraint_type: cm.type,
limit_amount: cm.limit_amount,
limit_currency: cm.limit_currency
}}) AS regulatory_constraints,
sj.code AS source_jurisdiction,
tj.code AS target_jurisdiction,
sj.has_capital_controls AS source_controls,
tj.has_capital_controls AS target_controls
"""
result = graph.query(query, {
"source": source_entity,
"target": target_entity,
"currency": currency
})
if not result:
return {"feasible": False, "reason": "No matching accounts found"}
r = result[0]
constraints_violated = [
c for c in r["regulatory_constraints"]
if c["limit_amount"] and float(c["limit_amount"]) < amount
]
return {
"feasible": len(constraints_violated) == 0 and len(r["available_rails"]) > 0,
"same_pool": r["same_pool"],
"pool_type": r["pool_type"],
"available_rails": r["available_rails"],
"regulatory_constraints": r["regulatory_constraints"],
"constraints_violated": constraints_violated,
"cross_border": r["source_jurisdiction"] != r["target_jurisdiction"],
"capital_controls": r["source_controls"] or r["target_controls"]
}
@tool
def retrieve_treasury_policy_and_regulation(context_keywords: List[str]) -> dict:
"""
Hybrid RAG: Retrieves both internal treasury policies AND external regulations.
Uses separate indices with unified relevance ranking.
"""
policy_query = """
CALL db.index.fulltext.queryNodes('treasuryPolicySearch', $query) YIELD node, score
WHERE node.effective_date <= datetime() AND score > 0.6
RETURN 'POLICY' AS source, node.title, node.policy_id AS ref_id,
substring(node.content, 0, 800) AS excerpt, score
ORDER BY score DESC LIMIT 3
"""
reg_query = """
CALL db.index.fulltext.queryNodes('regulationSearch', $query) YIELD node, score
WHERE node.effective_date <= datetime() AND score > 0.6
RETURN 'REGULATION' AS source, node.title, node.reg_code AS ref_id,
substring(node.text, 0, 800) AS excerpt, score
ORDER BY score DESC LIMIT 3
"""
combined = []
combined.extend(graph.query(policy_query, {"query": " ".join(context_keywords)}))
combined.extend(graph.query(reg_query, {"query": " ".join(context_keywords)}))
combined.sort(key=lambda x: x["score"], reverse=True)
return {
"policies": [c for c in combined if c["source"] == "POLICY"],
"regulations": [c for c in combined if c["source"] == "REGULATION"],
"total_relevant": len(combined)
}
Step 3: Multi-Agent LangGraph Orchestration
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o", temperature=0)
# Specialized agent bindings
position_llm = llm.bind_tools([get_global_cash_position, analyze_pool_feasibility])
policy_llm = llm.bind_tools([retrieve_treasury_policy_and_regulation])
def position_aggregation_agent(state: CashManagementState) -> CashManagementState:
"""Retrieves and structures global cash positions."""
prompt = f"""You are a Treasury Position Analyst. For corporate group {state['corporate_group_id']},
retrieve the global cash position by currency. Identify any trapped cash entities.
If the user's question references specific entities or sweep amounts, also analyze pool feasibility."""
response = position_llm.invoke(
[{"role": "user", "content": prompt}] + state["messages"]
)
return {"messages": [response], "phase": "position_aggregation"}
def constraint_retrieval_agent(state: CashManagementState) -> CashManagementState:
"""Fetches regulatory and policy constraints relevant to current positions."""
# Dynamically build context from graph findings
keywords = ["liquidity coverage", "cross-border sweep", "capital controls"]
currencies = list(state.get("global_positions_by_currency", {}).keys())
keywords.extend([f"{c} regulation" for c in currencies[:3]])
if state.get("trapped_cash_entities"):
keywords.append("trapped cash repatriation")
prompt = f"""Retrieve treasury policies and regulations relevant to: {keywords}.
Focus on constraints that would affect inter-entity sweeps and liquidity optimization."""
response = policy_llm.invoke([{"role": "user", "content": prompt}])
return {"messages": [response], "phase": "constraint_retrieval"}
def optimization_agent(state: CashManagementState) -> CashManagementState:
"""Synthesizes positions + constraints into sweep recommendations."""
prompt = f"""
You are a Senior Treasury Strategist. Produce a liquidity optimization recommendation:
GLOBAL POSITIONS:
{json.dumps(state.get('global_positions_by_currency', {}), indent=2)}
TRAPPED CASH ENTITIES: {state.get('trapped_cash_entities', [])}
REGULATORY CONSTRAINTS:
{chr(10).join(f"- [{r['ref_id']}] {r['title']}: {r['excerpt'][:300]}"
for r in state.get('applicable_regulations', []))}
TREASURY POLICIES:
{chr(10).join(f"- [{p['ref_id']}] {p['title']}: {p['excerpt'][:300]}"
for p in state.get('treasury_policy_excerpts', []))}
Produce:
1. Liquidity Gap Analysis (surplus/deficit by currency)
2. Recommended Sweep Structure (source → target, amount, rail, timing)
3. Compliance Validation (which constraints satisfied/violated)
4. Trapped Cash Mitigation Strategy
5. Risk Warnings & Caveats
6. Estimated Cost Savings vs. Current Position
Format as structured JSON for downstream consumption.
"""
response = llm.invoke(prompt)
# Parse structured output
try:
import json
parsed = json.loads(response.content)
return {
"messages": [response],
"liquidity_gap_analysis": parsed.get("gap_analysis"),
"sweep_recommendations": parsed.get("sweeps", []),
"compliance_warnings": parsed.get("warnings", []),
"phase": "complete",
"requires_human_approval": len(parsed.get("warnings", [])) > 0
}
except json.JSONDecodeError:
return {
"messages": [response],
"phase": "complete",
"error_log": ["Failed to parse optimization output as JSON"],
"requires_human_approval": True
}
# Build workflow
workflow = StateGraph(CashManagementState)
workflow.add_node("position_aggregation", position_aggregation_agent)
workflow.add_node("position_tools", ToolNode([
get_global_cash_position,
analyze_pool_feasibility
]))
workflow.add_node("constraint_retrieval", constraint_retrieval_agent)
workflow.add_node("constraint_tools", ToolNode([retrieve_treasury_policy_and_regulation]))
workflow.add_node("optimization", optimization_agent)
workflow.set_entry_point("position_aggregation")
workflow.add_edge("position_aggregation", "position_tools")
workflow.add_edge("position_tools", "constraint_retrieval")
workflow.add_edge("constraint_retrieval", "constraint_tools")
workflow.add_edge("constraint_tools", "optimization")
workflow.add_conditional_edges(
"optimization",
lambda s: END if not s.get("requires_human_approval") else "treasurer_review"
)
# Persistent checkpointing for multi-session treasury workflows
from langgraph.checkpoint.postgres import PostgresSaver
checkpointer = PostgresSaver.from_conn_string("postgresql://${PG_CONN}")
app = workflow.compile(checkpointer=checkpointer)
Step 4: Real-Time Streaming with Memory Resume
import json
from datetime import datetime
async def optimize_liquidity(group_id: str, question: str, treasurer_id: str):
session_id = f"CASH-{datetime.utcnow().strftime('%Y%m%d%H%M')}-{group_id}"
config = {"configurable": {"thread_id": session_id}}
initial_state: CashManagementState = {
"messages": [{"role": "user", "content": question}],
"session_id": session_id,
"treasurer_id": treasurer_id,
"corporate_group_id": group_id,
"phase": "position_aggregation",
"requires_human_approval": False,
"error_log": []
}
async for event in app.astream_events(initial_state, config=config, version="v2"):
kind = event["event"]
if kind == "on_tool_end":
yield {
"type": "position_signal",
"tool": event["name"],
"data": event["data"]["output"],
"ts": datetime.utcnow().isoformat()
}
elif kind == "on_chat_model_stream":
yield {
"type": "recommendation_token",
"content": event["data"]["chunk"].content
}
elif kind == "on_chain_end" and event["name"] == "optimization":
output = event["data"]["output"]
yield {
"type": "optimization_complete",
"session_id": session_id,
"requires_approval": output.get("requires_human_approval"),
"warnings_count": len(output.get("compliance_warnings", []))
}
# Resume after treasurer approval
async def approve_sweep(session_id: str, approved: bool, modifications: dict = None):
config = {"configurable": {"thread_id": session_id}}
update = {
"messages": [{
"role": "user",
"content": f"Treasurer decision: {'APPROVED' if approved else 'REJECTED'}."
+ (f" Modifications: {json.dumps(modifications)}" if modifications else "")
}],
"requires_human_approval": False
}
await app.aupdate_state(config, update)
async for event in app.astream(None, config=config):
yield event
5. Performance Engineering for Treasury Scale
| Challenge | Solution | Impact |
|---|
| 10K+ legal entities, 50K+ accounts | Materialized position views refreshed via CDC | Position queries < 50ms |
| Regulatory document corpus (100K+ pages) | Chunked embeddings with jurisdiction metadata filtering | RAG precision +35% |
| Pool structure complexity | Pre-computed reachability index per pool | Feasibility checks < 20ms |
| Multi-session state persistence | Checkpoint compression + TTL-based archival | Storage < 5KB/session |
| Concurrent treasurer sessions | Read replica routing + connection pooling | 200+ concurrent users |
6. Why This Architecture Beats Alternatives for Cash Management
| Approach | Failure Mode in Cash Mgmt | Graph+LangGraph Solution |
|---|
| Pure Vector RAG | Hallucinates entity relationships, misses pool structures | Graph provides verified topology; RAG limited to policy text |
| Text-to-SQL | Cannot express hierarchical ownership, pool semantics | Native graph traversal expresses treasury ontology directly |
| Rule Engine Only | Brittle to new regulations, no natural language interface | Agents adapt to new policies via RAG; graph absorbs new structures |
| Single LLM Call | No grounding, no audit trail, no stateful investigation | Multi-agent pipeline with checkpointed state and tool-use traces |
| Dashboard-Only | No reasoning over positions, no regulatory cross-reference | Conversational interface backed by grounded structural + policy knowledge |
7. Compliance & Governance
Basel III LCR/NSFR: Graph-traversed liquidity positions feed directly into regulatory ratio calculations. Agent outputs cite specific regulation nodes.
Transfer Pricing Documentation: Sweep recommendations include arm’s-length justification sourced from treasury policy RAG.
SOX Controls: Every recommendation passes through human approval gate when compliance warnings exist. Full LangGraph trace serves as control evidence.
Data Residency: Jurisdiction-tagged nodes enable geo-fenced queries. EU entity data never traverses non-EU graph replicas.
Model Risk: Structured state fields (not free-text LLM output) drive downstream systems. LLM is advisory; graph is authoritative.
Conclusion
Cash Management sits at the intersection of structural complexity (entity hierarchies, pool topologies, payment rails) and regulatory density (multi-jurisdictional constraints, evolving policies). Neither pure graph nor pure RAG suffices alone. The integration pattern demonstrated here—graph for ground truth, RAG for policy context, LLM for synthesis, LangGraph for orchestrated stateful reasoning—is the enterprise-grade solution. It transforms cash management from dashboard-driven position monitoring to conversational, compliant, structurally-grounded liquidity optimization. This implementation is for architectural reference. Production cash management systems require validation against your institution’s treasury policy framework, Basel III model risk review, SOX control testing, and coordination with your ALM and regulatory reporting teams. Never allow LLM-generated sweep recommendations to execute automatically without human approval gates and independent compliance validation. All monetary values should use Decimal types in production; floats shown here are for readability.