agents/management/ — Risk & Portfolio Management
Signal aggregation, risk assessment, and final trading decisions
The risk manager evaluates aggregate risk from all analyst signals and sets position limits. The portfolio manager makes final trading decisions (buy/sell/hold) with specific quantities, synthesizing all upstream analysis into executable orders.
📚 What is this & why does it exist?
These two agents are the "decision makers" in the fan-in phase of the pipeline. After 19 analyst agents independently produce signals, someone needs to synthesize them. The risk manager acts as a safety check — evaluating overall exposure, correlation risk, and position sizing. The portfolio manager then makes final decisions considering available capital, existing positions, and the risk manager's limits. This two-stage decision process (risk first, then portfolio) prevents the system from taking on excessive risk even when all analysts are bullish.
💡 Real-world analogy
Think of a restaurant kitchen. The risk manager is the health inspector — they don't cook, but they set limits (temperature requirements, freshness standards, capacity limits). The portfolio manager is the head chef who creates the final menu (trading decisions) working within the inspector's constraints. The health inspector reviews conditions first and says "you can serve at most 50 covers tonight given your refrigeration capacity." Then the head chef decides which dishes to prepare, how many of each, considering both the inspector's limits and the available ingredients (capital). Neither can do the other's job, and the order matters: risk limits must be set before trading decisions are made.
🤔 Why Is It Designed This Way?
The two-stage architecture (risk manager then portfolio manager) reflects institutional trading practice where risk management is independent of trading decisions. The risk manager is purely algorithmic — it computes volatility-adjusted position limits using price data, correlation matrices, and portfolio exposure without any LLM calls. This ensures risk limits are deterministic and not subject to LLM hallucination. The portfolio manager, by contrast, uses an LLM to synthesize the qualitative reasoning from all analyst signals into coherent trading decisions. This hybrid approach gives the system the creativity of LLM reasoning for decision-making while keeping risk controls mathematically rigorous. The compute_allowed_actions function adds a second deterministic layer: even if the LLM suggests buying 1000 shares, the function clamps it to the risk-manager-approved maximum.
| Design Choice | What Was Chosen | What Was Rejected | Why |
|---|---|---|---|
| Risk computation | Algorithmic (no LLM) | LLM-based risk assessment | Risk limits must be deterministic and reproducible; LLM hallucination on position sizes could be catastrophic |
| Portfolio decisions | LLM with structured output | Rule-based signal aggregation | Synthesizing 19 qualitative signals with reasoning requires the nuance that only an LLM provides |
| Two-stage pipeline | Risk first, then portfolio | Single combined agent | Separation of concerns: risk manager cannot be overridden by bullish consensus; mirrors institutional practice |
| Position clamping | Deterministic compute_allowed_actions | Trust LLM output directly | Defense-in-depth: even if LLM suggests excessive quantity, hard math clamps it to risk limits |
Contents
Architecture
The management agents sit at the fan-in convergence point. Risk management runs first to set limits, then the portfolio manager uses those limits along with all analyst signals to produce final trading decisions.
flowchart TD
subgraph FanOut["Fan-Out: 19 Analyst Agents (parallel)"]
P1["Persona Agents x13"]
Q1["Quantitative Agents x5"]
NS["News Sentiment"]
end
FanOut --> AS["analyst_signals dict"]
AS --> RM["Risk Manager"]
subgraph RM_inner["Risk Manager Pipeline"]
R1["Fetch prices for all tickers"]
R2["Calculate volatility metrics"]
R3["Build correlation matrix"]
R4["Compute portfolio exposure"]
R5["Set position limits per ticker"]
R1 --> R2 --> R3 --> R4 --> R5
end
RM --> RM_inner
RM_inner --> RO["risk limits + current prices"]
RO --> PM["Portfolio Manager"]
subgraph PM_inner["Portfolio Manager Pipeline"]
P2["Compress analyst signals"]
P3["Compute allowed actions"]
P4["LLM: synthesize decisions"]
P5["Emit buy/sell/hold orders"]
P2 --> P3 --> P4 --> P5
end
PM --> PM_inner
PM_inner --> Orders["Final Trading Decisions"]
Key Classes & Functions
| Class / Function | File | Purpose |
|---|---|---|
risk_management_agent | risk_manager.py | Main entry: fetches prices, calculates volatility, builds correlation matrix, sets position limits per ticker |
portfolio_management_agent | portfolio_manager.py | Main entry: compresses analyst signals, computes allowed actions, calls LLM for final trading decisions |
PortfolioDecision | portfolio_manager.py | Pydantic model: action (buy/sell/short/cover/hold), quantity (int), confidence (0-100), reasoning |
PortfolioManagerOutput | portfolio_manager.py | Pydantic model: wraps dict[str, PortfolioDecision] mapping tickers to decisions |
compute_allowed_actions | portfolio_manager.py | Deterministic position clamping: calculates max buy/sell/short/cover quantities from risk limits, cash, and existing positions |
calculate_volatility_metrics | risk_manager.py | Computes daily and annualized volatility, volatility percentile from price DataFrame |
generate_trading_decision | portfolio_manager.py | Builds LLM prompt with compressed signals, position limits, and portfolio state; returns PortfolioManagerOutput |
show_agent_reasoning | (imported from state) | Displays formatted reasoning to user when show_reasoning metadata flag is true |
get_api_key_from_state | (imported from utils) | Extracts API key from state to allow multi-tenant operation |
call_llm | (imported from utils) | Sends prompt to configured LLM with structured output parsing via Pydantic model |
Data Structures
| Type | Name | Description |
|---|---|---|
| Pydantic | PortfolioDecision | Single ticker decision: action (Literal["buy","sell","short","cover","hold"]), quantity (int), confidence (0-100), reasoning (str) |
| Pydantic | PortfolioManagerOutput | Container: decisions: dict[str, PortfolioDecision] mapping each ticker to its trading decision |
| dict | risk_analysis | Per-ticker risk data: remaining_position_limit, current_price, reasoning with volatility details |
| dict | volatility_data | Per-ticker: daily_volatility, annualized_volatility, volatility_percentile, data_points |
| DataFrame | correlation_matrix | Pandas correlation matrix of daily returns across all active tickers; used for portfolio-level risk |
| dict | signals_by_ticker | Compressed analyst signals: {agent_name: {sig: "bullish", conf: 85}} per ticker, excluding risk manager |
| dict | allowed | Per-ticker allowed actions: max quantities for buy, sell, short, cover based on cash, positions, and risk limits |
Interactions
| Direction | Partner | Interface | Description |
|---|---|---|---|
| ← | All 19 Analysts | state["data"]["analyst_signals"] | Risk manager and portfolio manager read all upstream signals from shared state |
| → | Price API | get_prices() | Risk manager fetches prices for all tickers (current + existing positions) for volatility calculation |
| → | LLM Service | call_llm() | Portfolio manager sends compressed signals + constraints to LLM for decision synthesis |
| → | Portfolio State | state["data"]["portfolio"] | Both agents read current positions, cash, equity, margin from portfolio state |
| → | LangGraph State | state["data"]["current_prices"] | Portfolio manager writes current prices to state for downstream consumption |
| → | Execution Layer | PortfolioManagerOutput | Final decisions returned as structured message for order execution |
| → | Progress Tracker | progress.update_status() | Both agents report step-by-step progress to UI |
Configuration
| Knob / Parameter | Default | Description |
|---|---|---|
default daily_volatility | 0.05 (5%) | Fallback volatility when no price data available; conservative high-risk assumption |
volatility_percentile fallback | 100 | When no data, assume worst-case volatility percentile to minimize position sizing |
margin_requirement | 0.5 (50%) | Portfolio margin requirement for short positions; from portfolio state |
min correlation data points | 5 rows, 2 tickers | Minimum data needed to compute a meaningful correlation matrix |
risk_manager_id pattern | "risk_management_agent" or "_suffix" | Portfolio manager dynamically finds its paired risk manager by matching ID suffix |
Directory Map
Source tree for management agents
src/agents/
├── risk_manager.py ← Volatility-adjusted position sizing and risk limits
└── portfolio_manager.py ← Final trading decisions via LLM synthesis
📑 Annotated Code Walkthrough
The portfolio manager's decision-making logic is the final stage of the entire pipeline. It synthesizes all upstream analysis into concrete buy/sell/hold orders.
portfolio_management_agent — Decision Synthesis
def portfolio_management_agent(state: AgentState, agent_id: str = "portfolio_manager"):
"""Makes final trading decisions and generates orders for multiple tickers"""
portfolio = state["data"]["portfolio"]
analyst_signals = state["data"]["analyst_signals"]
tickers = state["data"]["tickers"]
position_limits = {}
current_prices = {}
max_shares = {}
signals_by_ticker = {}
for ticker in tickers:
# Find the paired risk manager by matching ID suffix
if agent_id.startswith("portfolio_manager_"):
suffix = agent_id.split('_')[-1]
risk_manager_id = f"risk_management_agent_{suffix}"
else:
risk_manager_id = "risk_management_agent"
# Extract risk limits set by the risk manager
risk_data = analyst_signals.get(risk_manager_id, {}).get(ticker, {})
position_limits[ticker] = risk_data.get("remaining_position_limit", 0.0)
current_prices[ticker] = float(risk_data.get("current_price", 0.0))
# Calculate maximum shares allowed
if current_prices[ticker] > 0:
max_shares[ticker] = int(position_limits[ticker] // current_prices[ticker])
else:
max_shares[ticker] = 0
# Compress analyst signals to {sig, conf} for LLM prompt efficiency
ticker_signals = {}
for agent, signals in analyst_signals.items():
if not agent.startswith("risk_management_agent") and ticker in signals:
sig = signals[ticker].get("signal")
conf = signals[ticker].get("confidence")
if sig is not None and conf is not None:
ticker_signals[agent] = {"sig": sig, "conf": conf}
signals_by_ticker[ticker] = ticker_signals
# Generate final trading decisions via LLM
result = generate_trading_decision(
tickers=tickers,
signals_by_ticker=signals_by_ticker,
current_prices=current_prices,
max_shares=max_shares,
portfolio=portfolio,
agent_id=agent_id,
state=state,
)
Line-by-line:
- Lines 3-5: The portfolio manager reads three things from state: the current portfolio (cash, positions, margin), all analyst signals (the output of 19 agents), and the tickers to trade. It's the only agent that reads all other agents' output.
- Lines 13-18: Dynamic risk manager pairing — supports multi-portfolio mode where
portfolio_manager_1pairs withrisk_management_agent_1. This allows running multiple independent portfolios in the same LangGraph execution. - Lines 21-23: Risk data extraction — the risk manager's output is consumed as just another signal in
analyst_signals, but it contains special fields (remaining_position_limit,current_price) that other agents don't have. - Lines 26-29:
max_sharesis a hard ceiling computed by integer division of the dollar limit by the share price. Theint()cast floors the result, preventing fractional share orders. - Lines 32-38: Signal compression is crucial for LLM prompt efficiency. Instead of sending full reasoning from all 19 agents (which would exceed context limits), each signal is compressed to just
{sig, conf}. The risk manager is explicitly excluded because its output is used for limits, not as a trading signal. - Lines 41-49: The LLM call receives everything needed for a decision: signals from all analysts, current prices, hard limits from risk management, and current portfolio state. The LLM synthesizes this into
PortfolioManagerOutput.
Risk Manager — Volatility-Adjusted Limits
Correlation-Aware Risk Computation
# Build correlation matrix across all active tickers
correlation_matrix = None
if len(returns_by_ticker) >= 2:
try:
returns_df = pd.DataFrame(returns_by_ticker).dropna(how="any")
if returns_df.shape[1] >= 2 and returns_df.shape[0] >= 5:
correlation_matrix = returns_df.corr()
except Exception:
correlation_matrix = None
# Calculate total portfolio value (Net Liquidation Value)
total_portfolio_value = portfolio.get("cash", 0.0)
for ticker, position in portfolio.get("positions", {}).items():
if ticker in current_prices:
total_portfolio_value += position.get("long", 0) * current_prices[ticker]
total_portfolio_value -= position.get("short", 0) * current_prices[ticker]
The correlation matrix is key to portfolio-level risk: if two tickers are highly correlated (e.g., AAPL and MSFT), the risk manager reduces position limits for both to avoid concentrated sector exposure. The net liquidation value calculation accounts for both long and short positions at current market prices.
Deterministic Action Clamping
compute_allowed_actions — Defense in Depth
def compute_allowed_actions(
tickers: list[str],
current_prices: dict[str, float],
max_shares: dict[str, int],
portfolio: dict[str, float],
) -> dict[str, dict[str, int]]:
"""Compute allowed actions and max quantities for each ticker deterministically."""
allowed = {}
cash = float(portfolio.get("cash", 0.0))
positions = portfolio.get("positions", {}) or {}
margin_requirement = float(portfolio.get("margin_requirement", 0.5))
margin_used = float(portfolio.get("margin_used", 0.0))
equity = float(portfolio.get("equity", cash))
for ticker in tickers:
price = float(current_prices.get(ticker, 0.0))
pos = positions.get(ticker, {"long": 0, "short": 0})
long_shares = int(pos.get("long", 0) or 0)
short_shares = int(pos.get("short", 0) or 0)
max_qty = int(max_shares.get(ticker, 0) or 0)
# ... clamp buy/sell/short/cover to max_qty and available resources
This function provides the critical safety net. Even if the LLM hallucinates a quantity of 10,000 shares, compute_allowed_actions clamps it to max_qty (from risk manager) and available cash. It also ensures you can't sell more shares than you own, or cover more shorts than you have open.
Code Examples
Example: Multi-Portfolio Risk Manager Pairing
# When running multiple portfolios in parallel, each portfolio manager
# dynamically finds its paired risk manager by matching the ID suffix.
# portfolio_manager_aggressive -> risk_management_agent_aggressive
# portfolio_manager_conservative -> risk_management_agent_conservative
if agent_id.startswith("portfolio_manager_"):
suffix = agent_id.split('_')[-1]
risk_manager_id = f"risk_management_agent_{suffix}"
else:
risk_manager_id = "risk_management_agent" # Default for CLI mode
Data Flow
The complete fan-in flow from analyst signals through risk management to final trading decisions:
sequenceDiagram
participant AS as analyst_signals (19 agents)
participant RM as Risk Manager
participant API as Price API
participant PM as Portfolio Manager
participant LLM as LLM Service
participant Out as Trading Orders
AS->>RM: All analyst signals
RM->>API: get_prices() for all tickers
API-->>RM: OHLCV price data
RM->>RM: Calculate volatility per ticker
RM->>RM: Build correlation matrix
RM->>RM: Compute Net Liquidation Value
RM->>RM: Set position limits
RM->>AS: Write risk limits to analyst_signals
AS->>PM: All signals + risk limits
PM->>PM: Extract risk limits per ticker
PM->>PM: Compress signals to {sig, conf}
PM->>PM: compute_allowed_actions()
PM->>LLM: Prompt with signals + limits + portfolio
LLM-->>PM: PortfolioManagerOutput
PM->>Out: {ticker: {action, quantity, confidence, reasoning}}
⚠️ Edge Cases & Boundaries
| Edge Case | What Happens | Where in Code | Risk Level |
|---|---|---|---|
| No price data for a ticker | Risk manager sets remaining_position_limit = 0 and default high volatility (5% daily), effectively blocking trading | risk_manager.py:39-44, 109-118 | 🔴 High |
| Current price is zero | max_shares set to 0; portfolio manager cannot buy/sell that ticker regardless of signals | portfolio_manager.py:51-54 | 🔴 High |
| Risk manager not found in signals | analyst_signals.get(risk_manager_id, {}).get(ticker, {}) returns empty dict; position limit defaults to 0.0 | portfolio_manager.py:46-47 | 🔴 High |
| Fewer than 2 tickers for correlation | Correlation matrix is None; risk manager falls back to per-ticker volatility only, no cross-ticker adjustment | risk_manager.py:79-85 | 🟡 Medium |
| LLM suggests quantity exceeding limit | compute_allowed_actions clamps to max_qty; the LLM's suggestion is overridden deterministically | portfolio_manager.py:96-120 | 🟢 Low (mitigated) |
| All analysts signal bullish | Risk limits still apply; even unanimous bullishness can't override volatility-based position caps | risk_manager.py (position limit calc) | 🟢 Low (by design) |