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.

2
Agent Files
5
Actions
~1.8k
Lines
19
Upstream Signals

📚 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 ChoiceWhat Was ChosenWhat Was RejectedWhy
Risk computationAlgorithmic (no LLM)LLM-based risk assessmentRisk limits must be deterministic and reproducible; LLM hallucination on position sizes could be catastrophic
Portfolio decisionsLLM with structured outputRule-based signal aggregationSynthesizing 19 qualitative signals with reasoning requires the nuance that only an LLM provides
Two-stage pipelineRisk first, then portfolioSingle combined agentSeparation of concerns: risk manager cannot be overridden by bullish consensus; mirrors institutional practice
Position clampingDeterministic compute_allowed_actionsTrust LLM output directlyDefense-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 / FunctionFilePurpose
risk_management_agentrisk_manager.pyMain entry: fetches prices, calculates volatility, builds correlation matrix, sets position limits per ticker
portfolio_management_agentportfolio_manager.pyMain entry: compresses analyst signals, computes allowed actions, calls LLM for final trading decisions
PortfolioDecisionportfolio_manager.pyPydantic model: action (buy/sell/short/cover/hold), quantity (int), confidence (0-100), reasoning
PortfolioManagerOutputportfolio_manager.pyPydantic model: wraps dict[str, PortfolioDecision] mapping tickers to decisions
compute_allowed_actionsportfolio_manager.pyDeterministic position clamping: calculates max buy/sell/short/cover quantities from risk limits, cash, and existing positions
calculate_volatility_metricsrisk_manager.pyComputes daily and annualized volatility, volatility percentile from price DataFrame
generate_trading_decisionportfolio_manager.pyBuilds 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

TypeNameDescription
PydanticPortfolioDecisionSingle ticker decision: action (Literal["buy","sell","short","cover","hold"]), quantity (int), confidence (0-100), reasoning (str)
PydanticPortfolioManagerOutputContainer: decisions: dict[str, PortfolioDecision] mapping each ticker to its trading decision
dictrisk_analysisPer-ticker risk data: remaining_position_limit, current_price, reasoning with volatility details
dictvolatility_dataPer-ticker: daily_volatility, annualized_volatility, volatility_percentile, data_points
DataFramecorrelation_matrixPandas correlation matrix of daily returns across all active tickers; used for portfolio-level risk
dictsignals_by_tickerCompressed analyst signals: {agent_name: {sig: "bullish", conf: 85}} per ticker, excluding risk manager
dictallowedPer-ticker allowed actions: max quantities for buy, sell, short, cover based on cash, positions, and risk limits

Interactions

DirectionPartnerInterfaceDescription
←All 19 Analystsstate["data"]["analyst_signals"]Risk manager and portfolio manager read all upstream signals from shared state
→Price APIget_prices()Risk manager fetches prices for all tickers (current + existing positions) for volatility calculation
→LLM Servicecall_llm()Portfolio manager sends compressed signals + constraints to LLM for decision synthesis
→Portfolio Statestate["data"]["portfolio"]Both agents read current positions, cash, equity, margin from portfolio state
→LangGraph Statestate["data"]["current_prices"]Portfolio manager writes current prices to state for downstream consumption
→Execution LayerPortfolioManagerOutputFinal decisions returned as structured message for order execution
→Progress Trackerprogress.update_status()Both agents report step-by-step progress to UI

Configuration

Knob / ParameterDefaultDescription
default daily_volatility0.05 (5%)Fallback volatility when no price data available; conservative high-risk assumption
volatility_percentile fallback100When no data, assume worst-case volatility percentile to minimize position sizing
margin_requirement0.5 (50%)Portfolio margin requirement for short positions; from portfolio state
min correlation data points5 rows, 2 tickersMinimum 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_1 pairs with risk_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_shares is a hard ceiling computed by integer division of the dollar limit by the share price. The int() 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 CaseWhat HappensWhere in CodeRisk Level
No price data for a tickerRisk manager sets remaining_position_limit = 0 and default high volatility (5% daily), effectively blocking tradingrisk_manager.py:39-44, 109-118🔴 High
Current price is zeromax_shares set to 0; portfolio manager cannot buy/sell that ticker regardless of signalsportfolio_manager.py:51-54🔴 High
Risk manager not found in signalsanalyst_signals.get(risk_manager_id, {}).get(ticker, {}) returns empty dict; position limit defaults to 0.0portfolio_manager.py:46-47🔴 High
Fewer than 2 tickers for correlationCorrelation matrix is None; risk manager falls back to per-ticker volatility only, no cross-ticker adjustmentrisk_manager.py:79-85🟡 Medium
LLM suggests quantity exceeding limitcompute_allowed_actions clamps to max_qty; the LLM's suggestion is overridden deterministicallyportfolio_manager.py:96-120🟢 Low (mitigated)
All analysts signal bullishRisk limits still apply; even unanimous bullishness can't override volatility-based position capsrisk_manager.py (position limit calc)🟢 Low (by design)