Multi-agent portfolio risk analysis with live market intelligence, RAG and compliance checks.
Give it a portfolio and a question, for example "How exposed am I to US–China tensions and higher-for-longer rates?". A team of AI agents built with LangGraph then:
- gathers quant risk metrics and your internal knowledge;
- researches the question in parallel using the knowledge base plus live web search over MCP;
- combines and compliance-checks the findings, including against your investment policy;
- returns a risk score, action and rationale for every ticker.
⚠️ Output is AI-generated from public information and your uploaded documents. It is not investment advice.
- Features
- Architecture
- Agent workflow
- Sequence of a run
- RAG knowledge base
- Scoring
- Tech stack
- Getting started
- Configuration
- API reference
- Project structure
- Testing
- Limitations
| 🧭 Planner agent | Reads the portfolio, question, quant metrics and knowledge base, then selects only the specialists that matter and writes a focused brief for each |
| ⚡ Parallel specialists | Macro, Micro, Geopolitical and Sector agents run concurrently, each with web search, the knowledge base and market data |
| 🌐 Live information via MCP | Web search through a Model Context Protocol server: DuckDuckGo by default (no key), Tavily or Brave optional |
| 📚 RAG knowledge base | Upload research, filings, notes and your investment policy; web research is cached and reused; past recommendations are kept as memory |
| 📈 Quant risk metrics | Volatility, max drawdown, beta, VaR, correlations, sector weights and effective holdings, computed from 1 year of prices |
| 🧮 Risk analyser | Combines all evidence into portfolio and per-ticker risk, calibrated against the quant metrics |
| ✅ Compliance agent | Checks coverage, grounding, guarantee language, consistency, relevance and policy. It can send the report back for revision, or send specific specialists back for targeted re-research |
| 🎯 Recommendations | One action (Strong Buy → Sell) per ticker, with a blended risk score, confidence, rationale, risks, opportunities and change vs. the previous view |
| 📡 Live progress | Server-Sent Events stream every agent's status to the UI as it happens |
flowchart LR
user(["👤 User"])
subgraph FE["Frontend · Next.js 15"]
direction TB
ui_in["Portfolio + question"]
ui_kb["Knowledge base panel"]
ui_tl["Live agent timeline"]
ui_res["Results: scores, metrics,<br/>reports, sources"]
end
subgraph BE["Backend · FastAPI"]
direction TB
api["REST + SSE API"]
wf["LangGraph workflow<br/>(11 nodes)"]
api --> wf
end
subgraph EXT["External services"]
direction TB
llm["LLM<br/>Claude via LangChain"]
mcp["MCP search server<br/>DuckDuckGo / Tavily / Brave"]
yf["Yahoo Finance<br/>via yfinance"]
end
subgraph DATA["Local storage"]
direction TB
chroma[("Chroma vector store<br/>documents · research · memory")]
end
user --> FE
ui_in -- "POST /api/analyze/stream" --> api
ui_kb -- "POST/GET/DELETE /api/documents" --> api
api -- "SSE status / update / result" --> ui_tl
api -- "final result" --> ui_res
wf -- "structured output,<br/>tool calling" --> llm
wf -- "stdio MCP tools" --> mcp
wf -- "prices, fundamentals" --> yf
wf <-- "retrieve / cache / remember" --> chroma
Key design decisions
- The graph is the orchestrator. Agent order, parallelism and loops are explicit edges in LangGraph, not left to the LLM. Only which specialists run is decided by the planner.
- Specialists work in two phases: a tool-calling research loop, then a separate structured-output call that writes the report. This keeps typed outputs reliable and provider-agnostic.
- Deterministic checks sit alongside LLM checks. Ticker coverage, score/level bands and score blending are enforced in code, so the LLM can't skip them.
- Graceful degradation. If search, market data, retrieval or a specialist fails, the run continues and the problem appears in
warnings. - Everything stays local except the LLM, search and price calls. Embeddings run locally (ONNX), and uploaded documents never leave the server; only retrieved passages are sent to the LLM.
flowchart TD
start((START))
subgraph CTX["① Context · parallel"]
md["📈 market_data<br/>quant metrics from prices"]
kn["📚 knowledge<br/>RAG: passages, policy, prior views"]
end
pl{{"🧭 planner<br/>select specialists + briefs"}}
subgraph SPEC["② Specialists · parallel, only those selected"]
ma["🌍 macro"]
mi["🏢 micro"]
ge["🛡️ geopolitical"]
se["🏭 sector"]
end
ra["🧮 risk_analyser<br/>combine evidence + metrics"]
co{"✅ compliance<br/>coverage · grounding · guarantees<br/>consistency · relevance · policy"}
rec["🎯 recommendation<br/>action + blended score per ticker"]
mem["💾 memory<br/>store views in knowledge base"]
stop((END))
start --> md & kn
md & kn --> pl
pl -. "selected" .-> ma & mi & ge & se
ma & mi & ge & se --> ra
ra --> co
co -- "passed, or retries exhausted" --> rec
co -- "fail: fix the report" --> ra
co -- "fail: missing evidence →<br/>targeted re-research" --> SPEC
rec --> mem --> stop
| Node | Type | What it does |
|---|---|---|
market_data |
code | Downloads 1 year of prices for all tickers plus the benchmark in one batch. Computes returns, annualised volatility, max drawdown, beta, 1-day 95% VaR, a per-ticker quant score, portfolio volatility/VaR/beta, pairwise correlations, sector weights and effective holdings |
knowledge |
code (RAG) | Retrieves passages relevant to the question, investment-policy passages and each ticker's most recent stored view |
planner |
LLM | Selects specialists (all four when the question is broad) and writes a 2–4 sentence brief for each. User-forced agents are always added |
macro / micro / geopolitical / sector |
LLM + tools | Retrieve from the knowledge base, then research with web search (MCP), search_knowledge_base and (micro/sector) get_market_snapshot. They write a structured report with findings, severity, affected tickers and sources, and cache the web research |
risk_analyser |
LLM | Produces portfolio and per-ticker risk scores, themes and concentration risks, and answers the question directly |
compliance |
code + LLM | Checks ticker coverage in code, then has the LLM review the other checks. Outcomes: pass, revise (back to the risk analyser) or re-research (back to named specialists). Shares a limit of MAX_COMPLIANCE_REVISIONS (2 by default) |
recommendation |
LLM + code | Writes one recommendation per ticker. Code back-fills any missing ticker, blends the analyst and quant scores, and attaches the previous view |
memory |
code | Stores each ticker's view so the next run can compare against it |
sequenceDiagram
autonumber
actor U as User
participant FE as Next.js UI
participant API as FastAPI
participant G as LangGraph
participant KB as Chroma KB
participant YF as yfinance
participant LLM as LLM
participant MCP as MCP search
U->>FE: Portfolio + question
FE->>API: POST /api/analyze/stream
API->>G: astream(initial state)
API-->>FE: status: market_data, knowledge running
par Context gathering
G->>YF: 1y prices + fundamentals
YF-->>G: metrics, quant scores
and
G->>KB: search(question), policy(), prior_views()
KB-->>G: passages, policy, previous views
end
API-->>FE: status: planner running
G->>LLM: Plan(portfolio, metrics, KB summary)
LLM-->>G: agents + briefs
API-->>FE: status: specialists running / skipped
par Each selected specialist
G->>KB: retrieve for brief
loop up to SPECIALIST_MAX_STEPS
G->>LLM: research step (tools bound)
LLM-->>G: tool calls
G->>MCP: search / fetch_content
MCP-->>G: web results
end
G->>KB: cache web research
G->>LLM: SpecialistReport (structured)
LLM-->>G: findings + sources
end
API-->>FE: status: each specialist done
loop until passed or revisions exhausted
G->>LLM: RiskReport
LLM-->>G: scores, themes
G->>LLM: ComplianceResult (+ policy)
LLM-->>G: pass / issues / research requests
alt evidence gap
G->>LLM: named specialists re-research
else report problem
Note over G: back to risk_analyser with feedback
end
end
G->>LLM: FinalRecommendation
LLM-->>G: action + score per ticker
Note over G: blend scores, back-fill tickers, attach prior views
G->>KB: store views (memory)
API-->>FE: result (full JSON)
FE-->>U: Scores, actions, metrics, reports, sources
flowchart LR
subgraph IN["Ingestion"]
up["📄 Uploads<br/>PDF · TXT · MD · HTML · CSV · JSON"]
web["🌐 Specialist web results"]
runs["🎯 Final recommendations"]
end
split["Chunk<br/>1200 chars, 150 overlap"]
emb["Embed locally<br/>all-MiniLM-L6-v2 (ONNX)"]
subgraph STORE["Chroma · persistent"]
docs[("documents<br/>type, tickers, page")]
res[("research<br/>url, query, fetched_at, tickers")]
memc[("memory<br/>ticker, action, score, run_at")]
end
subgraph OUT["Retrieval"]
r1["knowledge node:<br/>question passages, policy, prior views"]
r2["specialists:<br/>brief-based retrieval +<br/>search_knowledge_base tool"]
r3["compliance + recommendation:<br/>policy enforcement"]
end
up --> split --> emb --> docs
web --> split
emb --> res
runs --> memc
docs & res --> r1 & r2
docs --> r3
memc --> r1
| Collection | Contents | Used by |
|---|---|---|
documents |
Your uploads, typed as research_report, filing, news, note or policy, optionally tagged with tickers |
Specialists (evidence), compliance and recommendation (policy) |
research |
Web results found by specialists, tagged with the portfolio tickers they mention. Upserted by URL, so re-fetching refreshes them | Later runs, while younger than RESEARCH_TTL_DAYS (7 by default) |
memory |
One entry per ticker per run: action, score, rationale | Planner, risk analyser, recommendation ("was Hold → now Reduce") |
Retrieval runs an unfiltered semantic query plus a ticker-filtered one and merges the results. Documents tagged with your tickers rank higher without hiding untagged ones. Internal sources are cited as kb:<file>#p<page> or kb:<file>#c<chunk>.
All scores are 0–100, where higher means riskier:
| Score | 0–20 | 21–40 | 41–60 | 61–80 | 81–100 |
|---|---|---|---|---|---|
| Level | Low | Moderate | Elevated | High | Severe |
flowchart LR
ev["Specialist evidence"] --> an["Analyst score<br/>(risk analyser → recommendation)"]
px["1y price history"] --> q["Quant score<br/>50% volatility · 30% drawdown · 20% beta"]
an -- "× 0.7" --> f(("Final score"))
q -- "× 0.3" --> f
- Quant score anchors: annual volatility 10% → 0 and 70% → 100; drawdown 0% → 0 and −60% → 100; beta 0 → 0 and 2.5 → 100.
- The blend weight is
QUANT_SCORE_WEIGHT; set it to0to use the analyst score only. The UI shows both components (A 70 · Q 30). - Action weighs risk against opportunity, so a high-risk name with strong upside can still be Hold.
- Confidence (0–1) reflects source quality, agreement between specialists and the compliance outcome.
| Layer | Technology |
|---|---|
| Agent orchestration | LangGraph, LangChain init_chat_model |
| LLM | Claude (anthropic:claude-sonnet-5-5 by default); any LangChain provider works |
| Live search | MCP via langchain-mcp-adapters; duckduckgo-mcp-server by default |
| RAG | Chroma with built-in ONNX embeddings, pypdf, langchain-text-splitters |
| Market data | yfinance, pandas, numpy |
| API | FastAPI, Server-Sent Events, Pydantic v2 |
| Frontend | Next.js 15 (App Router), React 19, TypeScript, plain CSS with light and dark themes |
- Python 3.11+
- Node.js 20+
- An Anthropic API key (or another LangChain-supported provider; see Configuration)
uv (which provides uvx, used to launch the DuckDuckGo MCP server) is installed from requirements.txt. On first use, Chroma downloads its ~80 MB embedding model once.
git clone https://github.com/<your-username>/intelligent-risk-analysis.git
cd intelligent-risk-analysiscd backend
python -m venv .venv
.venv\Scripts\activate # macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
copy .env.example .env # macOS/Linux: cp .env.example .env
# edit .env and set ANTHROPIC_API_KEY
uvicorn app.main:app --reload --port 8000Check http://localhost:8000/api/health. It should list the search tools (search, fetch_content, …) and the knowledge-base counts.
cd frontend
npm install
copy .env.local.example .env.local # macOS/Linux: cp .env.local.example .env.local
npm run devOpen http://localhost:3000.
- Enter holdings as weights or quantities, or paste CSV, then type your question.
- (Optional) Upload research or your investment policy in the Knowledge base panel.
- Click Run risk analysis and watch the agent timeline.
- Review the blended scores and actions, the quant metrics, each specialist's findings with sources, and the compliance status. Use Export JSON to save the result.
All settings are environment variables in backend/.env (see backend/.env.example).
| Variable | Default | Purpose |
|---|---|---|
LLM_MODEL |
anthropic:claude-sonnet-5-5 |
Any init_chat_model string, e.g. anthropic:claude-opus-5-5 for deeper analysis, or openai:gpt-4.1 after pip install langchain-openai |
ANTHROPIC_API_KEY |
— | API key for the default provider |
LLM_MAX_TOKENS / LLM_MAX_RETRIES |
8192 / 3 |
Output limit and retries on transient errors |
MCP_CONFIG_PATH |
mcp_servers.json |
MCP servers to load search tools from |
SPECIALIST_MAX_STEPS |
6 |
Max tool-calling rounds per specialist |
SPECIALIST_TIMEOUT_S |
300 |
Per-specialist timeout |
MAX_COMPLIANCE_REVISIONS |
2 |
Max times compliance can send work back |
RAG_DIR |
data/chroma |
Knowledge base location (relative to backend/) |
RAG_TOP_K / RAG_MAX_DISTANCE |
6 / 0.8 |
Passages per query and cosine-distance cut-off |
RESEARCH_TTL_DAYS |
7 |
How long cached web research is reused |
MAX_UPLOAD_MB |
20 |
Upload size limit |
BENCHMARK / HISTORY_PERIOD |
SPY / 1y |
Benchmark and look-back for quant metrics |
QUANT_SCORE_WEIGHT |
0.3 |
Share of the final score from quant metrics |
FRONTEND_ORIGIN |
http://localhost:3000 |
Allowed CORS origin |
Frontend: NEXT_PUBLIC_API_URL in frontend/.env.local (default http://localhost:8000).
Search provider: copy entries from backend/mcp_servers.example.json into backend/mcp_servers.json and set TAVILY_API_KEY or BRAVE_API_KEY in .env. ${VAR} placeholders are filled from the environment. Those servers run through npx, so Node.js must be on PATH.
Tracing: set LANGSMITH_TRACING=true and LANGSMITH_API_KEY to trace every agent run in LangSmith.
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/analyze |
Run the full workflow and return the final JSON |
POST |
/api/analyze/stream |
Same, streamed as Server-Sent Events |
POST |
/api/documents |
Upload a document (multipart: file, doc_type, tickers) |
GET |
/api/documents |
List documents |
DELETE |
/api/documents/{doc_id} |
Delete a document |
GET |
/api/knowledge/search?q=&tickers=&k= |
Inspect retrieval results |
GET |
/api/health |
Model, loaded search tools, knowledge-base counts |
Interactive docs: http://localhost:8000/docs.
Analyze request
{
"portfolio": [{ "ticker": "AAPL", "weight": 60 }, { "ticker": "TSM", "weight": 40 }],
"prompt": "How exposed am I to Taiwan risk?",
"force_agents": ["geopolitical"]
}Use quantity instead of weight if you prefer; missing weights fall back to equal weighting. Duplicate tickers are merged and weights are normalised to 100%.
Stream events (each is a data: {...} line)
type |
Payload |
|---|---|
status |
node, status (running · done · skipped · error), optional detail (e.g. revision 1, follow-up research) |
update |
node, data: the node's state update |
result |
data: run_id, portfolio, market, knowledge, plan, analyses, risk_report, compliance, recommendation, warnings |
error |
message |
Upload a document
curl -X POST http://localhost:8000/api/documents -F "file=@investment_policy.pdf" -F "doc_type=policy" -F "tickers=".
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI: analysis (JSON + SSE), documents, knowledge search, health
│ │ ├── config.py # Settings from .env
│ │ ├── llm.py # LLM factory, structured output (json_schema), retries
│ │ ├── schemas.py # API models + structured-output models for every agent
│ │ ├── graph/
│ │ │ ├── state.py # Shared state with reducers for parallel fan-in
│ │ │ └── workflow.py # Nodes, edges, routing, compliance loop
│ │ ├── agents/
│ │ │ ├── context.py # market_data, knowledge, memory nodes + KB search tool
│ │ │ ├── planner.py
│ │ │ ├── specialist.py # macro / micro / geopolitical / sector
│ │ │ ├── risk_analyser.py
│ │ │ ├── compliance.py
│ │ │ ├── recommendation.py
│ │ │ ├── prompts.py # All system prompts
│ │ │ └── common.py # Shared prompt context builders
│ │ ├── rag/
│ │ │ ├── store.py # Chroma knowledge base: documents, research, memory
│ │ │ └── ingest.py # Web results → research cache entries
│ │ └── tools/
│ │ ├── mcp_search.py # Loads MCP tools from mcp_servers.json
│ │ └── market_data.py # Quant metrics + fundamentals tool
│ ├── tests/test_workflow.py # Workflow + RAG tests (stub LLM, no API key)
│ ├── mcp_servers.json # Active MCP servers (DuckDuckGo)
│ ├── mcp_servers.example.json # DuckDuckGo + Tavily + Brave examples
│ ├── requirements.txt
│ └── .env.example
├── frontend/
│ ├── app/ # layout, page, global styles
│ ├── components/ # PortfolioInput, KnowledgeBase, AgentTimeline, Results
│ ├── lib/ # API client (SSE over fetch) + types
│ └── package.json
├── docs/BRD.md # Business requirements document
└── README.md
The backend tests use a stub LLM and stub market data, so they need no API key or network. They cover:
- the parallel fan-out and fan-in;
- the compliance revision and targeted re-research loops;
- score blending;
- memory;
- policy and document retrieval reaching the right agents;
- the knowledge base itself.
cd backend
python -m pytest tests -qFrontend type-check and production build:
cd frontend
npm run typecheck
npm run build- Market data comes from Yahoo Finance via
yfinance, which is unofficial and best-effort. Missing tickers are reported inwarnings. - The quant score is a transparent heuristic for anchoring, not a risk model. There's no VaR back-testing or factor model.
- Web search quality depends on the MCP provider. DuckDuckGo is free but shallower than Tavily or Brave.
- No authentication or multi-tenancy. The knowledge base is shared by everyone using the backend, so run it locally or behind your own auth.
- LLM output can be wrong even after compliance review. Verify sources before acting.
- Business Requirements Document: objectives, functional and non-functional requirements, acceptance criteria (§14 covers RAG and the workflow upgrade).