TURION .AI

Build a Finance AI Agent with OpenAI Agents SDK: Portfolio Analysis & Risk Assessment

Balys Kriksciunas · · 5 min read
Dark financial dashboard with glowing portfolio charts, risk heatmaps, and AI neural network nodes connecting data screens on a polished glass conference table

Build a multi-agent portfolio analyst with the OpenAI Agents SDK — market data lookup, risk scoring, portfolio rebalancing tools, and a specialist handoff architecture.

Your portfolio manager just spent 45 minutes compiling a risk report that an agent could generate in 8 seconds. We’ve seen this across every fintech team we work with: the bottleneck isn’t data availability, it’s synthesis — pulling together market data, calculating risk scores, and generating actionable rebalancing recommendations. Finance is a natural fit for multi-agent architecture because the domain splits cleanly along expertise boundaries.

In this tutorial we’ll build a portfolio analysis agent with the OpenAI Agents SDK. It follows the same domain-specialist pattern we used for our healthcare triage agent — this time in a finance context where the tools do real analytical work.

We’re building a portfolio analysis and risk assessment system: a triage agent that classifies requests and hands them off to three specialists — a market data analyst, a risk assessor, and a portfolio rebalancer — each with custom tools and guardrails.

Requirements: Python 3.10+, the OpenAI Agents SDK (openai-agents), and about 20 minutes.

Architecture

Our system has four agents behind the SDK’s handoff model:

  1. Triage Agent — receives every query first, routes to the right specialist
  2. Market Data Agent — price lookups, momentum indicators, sector correlations
  3. Risk Assessment Agent — volatility scoring, drawdown analysis, Value at Risk (VaR)
  4. Portfolio Rebalancer — generates allocation recommendations, tax-loss harvesting flags

The triage agent uses the SDK’s handoff() mechanism — the LLM picks the right transfer_to_* tool based on the user’s query, no custom routing code needed. For a deeper look at the SDK’s architecture, see our OpenAI Agents SDK deep dive.

Step 1: Install and Configure

mkdir finance-agent && cd finance-agent
python -m venv .venv
source .venv/bin/activate  # Windows: .venv\Scripts\activate
pip install openai-agents python-dotenv

Export your API key:

export OPENAI_API_KEY="sk-..."

The SDK defaults to gpt-5.4-mini as of the latest release (GitHub releases). For finance workloads that need careful reasoning, we’ll pin gpt-4o:

# config.py
import os
from dotenv import load_dotenv

load_dotenv()

Step 2: Define Financial Tools

Tools in the Agents SDK are decorated with @function_tool. The docstring is the tool description the LLM uses to decide when to call it — so write them for a colleague, not a machine.

# tools.py
from agents import function_tool
from typing import Optional
import random
import math

# --- Market Data Tools ---

@function_tool
def get_stock_price(ticker: str) -> str:
    """Get the current price and daily change for a stock by ticker symbol."""
    # In production: call Polygon.io, Alpha Vantage, or Yahoo Finance API
    prices = {
        "AAPL": {"price": 217.42, "change": +1.23, "change_pct": 0.57},
        "MSFT": {"price": 452.18, "change": -3.47, "change_pct": -0.76},
        "GOOGL": {"price": 189.55, "change": +0.92, "change_pct": 0.49},
        "AMZN": {"price": 198.74, "change": -1.15, "change_pct": -0.58},
        "NVDA": {"price": 131.29, "change": +2.38, "change_pct": 1.85},
        "SPY":  {"price": 567.30, "change": -2.10, "change_pct": -0.37},
        "TLT":  {"price": 88.45,  "change": +0.35, "change_pct": 0.40},
        "VWO":  {"price": 45.12,  "change": -0.28, "change_pct": -0.62},
    }
    stock = prices.get(ticker.upper())
    if not stock:
        return f"No data for ticker '{ticker}'. Try: AAPL, MSFT, GOOGL, AMZN, NVDA, SPY, TLT, VWO."
    direction = "" if stock["change"] >= 0 else ""
    return (
        f"{ticker.upper()}: ${stock['price']:.2f} {direction} "
        f"${stock['change']:.2f} ({stock['change_pct']:+.2f}%)"
    )

@function_tool
def get_momentum_score(ticker: str, lookback_months: int = 6) -> str:
    """Calculate a momentum score (0-100) for a stock. Higher = stronger trend."""
    # In production: compute from historical price series
    momentum_data = {
        "AAPL": 72, "MSFT": 58, "GOOGL": 64, "AMZN": 45, "NVDA": 89,
        "SPY": 61, "TLT": 22, "VWO": 38
    }
    score = momentum_data.get(ticker.upper(), random.randint(20, 80))
    if score >= 75:
        rating = "Strong momentum"
    elif score >= 50:
        rating = "Moderate momentum"
    else:
        rating = "Weak momentum — consider watching for trend reversal"
    return f"{ticker.upper()} momentum score: {score}/100 ({lookback_months}mo). {rating}."

@function_tool
def get_sector_correlation(ticker: str) -> str:
    """Get the correlation of a stock with its sector benchmark (S&P 500)."""
    correlations = {
        "AAPL": 0.83, "MSFT": 0.79, "GOOGL": 0.85, "AMZN": 0.72,
        "NVDA": 0.91, "SPY": 1.00, "TLT": -0.34, "VWO": 0.62
    }
    corr = correlations.get(ticker.upper(), 0.70)
    if corr > 0.85:
        interpretation = "Highly correlated — offers limited diversification benefit vs S&P 500"
    elif corr < 0:
        interpretation = "Negatively correlated — strong diversification benefit"
    else:
        interpretation = "Moderately correlated"
    return f"{ticker.upper()} sector correlation to S&P 500: {corr:.2f}. {interpretation}."

# --- Risk Assessment Tools ---

@function_tool
def calculate_volatility(ticker: str) -> str:
    """Calculate annualized volatility (standard deviation of returns) for a stock."""
    vols = {
        "AAPL": 22.5, "MSFT": 24.8, "GOOGL": 27.3, "AMZN": 31.2,
        "NVDA": 45.1, "SPY": 15.7, "TLT": 18.3, "VWO": 25.6
    }
    vol = vols.get(ticker.upper(), 25.0)
    if vol > 35:
        risk_level = "HIGH — speculative, wide price swings expected"
    elif vol > 25:
        risk_level = "ABOVE AVERAGE — expect notable drawdowns"
    elif vol > 18:
        risk_level = "MODERATE — typical for large-cap equities"
    else:
        risk_level = "LOW — relatively stable"
    return (
        f"{ticker.upper()} annualized volatility: {vol:.1f}%. "
        f"Risk level: {risk_level}."
    )

@function_tool
def calculate_var(ticker: str, confidence: float = 0.95, holding_period_days: int = 1) -> str:
    """Calculate Value at Risk (VaR) — maximum expected loss at given confidence level."""
    # VaR formula: price * volatility * z-score * sqrt(horizon/252)
    price_data = {
        "AAPL": 217.42, "MSFT": 452.18, "GOOGL": 189.55, "AMZN": 198.74,
        "NVDA": 131.29, "SPY": 567.30, "TLT": 88.45, "VWO": 45.12
    }
    vol_data = {
        "AAPL": 22.5, "MSFT": 24.8, "GOOGL": 27.3, "AMZN": 31.2,
        "NVDA": 45.1, "SPY": 15.7, "TLT": 18.3, "VWO": 25.6
    }
    price = price_data.get(ticker.upper())
    vol = vol_data.get(ticker.upper())
    if not price or not vol:
        return f"Insufficient data for {ticker} VaR calculation."
    # z-score for 95% confidence = 1.645
    z_score = 1.645 if confidence == 0.95 else 2.326  # 99% confidence
    daily_var_pct = (vol / 100) * z_score * math.sqrt(holding_period_days / 252)
    var_amount = price * daily_var_pct
    return (
        f"{ticker.upper()} Value at Risk ({confidence*100:.0f}%, {holding_period_days}d): "
        f"${var_amount:.2f} per share ({daily_var_pct*100:.2f}%). "
        f"Interpretation: {confidence*100:.0f}% chance daily loss does not exceed this amount."
    )

@function_tool
def max_drawdown(ticker: str) -> str:
    """Get the maximum drawdown from peak for a stock over the last 12 months."""
    drawdowns = {
        "AAPL": -18.3, "MSFT": -22.1, "GOOGL": -25.7, "AMZN": -29.4,
        "NVDA": -35.2, "SPY": -12.8, "TLT": -25.1, "VWO": -30.6
    }
    dd = drawdowns.get(ticker.upper(), -25.0)
    if dd > -15:
        severity = "Mild — stock showed resilience during market pullbacks"
    elif dd > -25:
        severity = "Moderate — notable drawdown, monitor position sizing"
    else:
        severity = "Severe — large peak-to-trough decline, high risk tolerance required"
    return (
        f"{ticker.upper()} 12-month max drawdown: {dd:+.1f}%. {severity}."
    )

# --- Portfolio Rebalancing Tools ---

@function_tool
def get_portfolio_holdings(portfolio_id: str) -> str:
    """Retrieve current portfolio holdings including ticker, shares, cost basis, and allocation."""
    portfolios = {
        "PF-ALPHA": {
            "holdings": [
                {"ticker": "AAPL", "shares": 200, "cost_basis": 185.00, "allocation": 0.22},
                {"ticker": "MSFT", "shares": 150, "cost_basis": 410.00, "allocation": 0.20},
                {"ticker": "GOOGL", "shares": 300, "cost_basis": 172.00, "allocation": 0.18},
                {"ticker": "NVDA", "shares": 500, "cost_basis": 95.00,  "allocation": 0.25},
                {"ticker": "SPY",  "shares": 100, "cost_basis": 540.00, "allocation": 0.10},
                {"ticker": "TLT",  "shares": 200, "cost_basis": 92.00,  "allocation": 0.05},
            ],
            "total_value": 295610.00,
            "cash": 12450.00,
        },
        "PF-BETA": {
            "holdings": [
                {"ticker": "VWO",  "shares": 800, "cost_basis": 47.00,  "allocation": 0.35},
                {"ticker": "TLT",  "shares": 500, "cost_basis": 90.00,  "allocation": 0.30},
                {"ticker": "SPY",  "shares": 60,  "cost_basis": 550.00, "allocation": 0.35},
            ],
            "total_value": 103370.00,
            "cash": 8230.00,
        },
    }
    pf = portfolios.get(portfolio_id.upper())
    if not pf:
        return f"Portfolio '{portfolio_id}' not found. Try: PF-ALPHA, PF-BETA."
    lines = [f"Portfolio {portfolio_id.upper()} — Total Value: ${pf['total_value']:,.2f} (Cash: ${pf['cash']:,.2f})\n"]
    for h in pf["holdings"]:
        current_price_data = {
            "AAPL": 217.42, "MSFT": 452.18, "GOOGL": 189.55,
            "NVDA": 131.29, "SPY": 567.30, "TLT": 88.45, "VWO": 45.12
        }
        current = current_price_data.get(h["ticker"], h["cost_basis"])
        gain_pct = ((current - h["cost_basis"]) / h["cost_basis"]) * 100
        lines.append(
            f"  {h['ticker']}: {h['shares']} shares @ ${current:.2f} "
            f"(cost ${h['cost_basis']:.2f}, {gain_pct:+.1f}%, "
            f"allocation {h['allocation']:.0%})"
        )
    return "\n".join(lines)

@function_tool
def generate_rebalance_suggestion(portfolio_id: str, target_allocation: str = "balanced") -> str:
    """Generate a rebalancing recommendation. target_allocation: 'balanced', 'growth', or 'conservative'."""
    targets = {
        "balanced":     {"equity": 0.60, "bonds": 0.30, "cash": 0.10},
        "growth":       {"equity": 0.85, "bonds": 0.10, "cash": 0.05},
        "conservative": {"equity": 0.40, "bonds": 0.50, "cash": 0.10},
    }
    target = targets.get(target_allocation, targets["balanced"])
    return (
        f"Rebalance suggestion for {portfolio_id.upper()} "
        f"(target: {target_allocation}):\n"
        f"  Target equity: {target['equity']:.0%}\n"
        f"  Target bonds:   {target['bonds']:.0%}\n"
        f"  Target cash:    {target['cash']:.0%}\n\n"
        f"Recommended actions:\n"
        f"  1. Calculate current allocation drift vs targets\n"
        f"  2. Identify positions with largest deviation\n"
        f"  3. Generate specific buy/sell orders to close the gap\n"
        f"  4. Flag any tax-loss harvesting opportunities\n\n"
        f"Run a detailed analysis on individual holdings to complete the rebalance plan."
    )

@function_tool
def tax_loss_harvest_check(portfolio_id: str) -> str:
    """Identify positions with unrealized losses suitable for tax-loss harvesting."""
    positions_with_losses = {
        "PF-ALPHA": [
            {"ticker": "TLT", "unrealized_loss": -710.00, "holding_period": "long-term"},
        ],
        "PF-BETA": [
            {"ticker": "VWO", "unrealized_loss": -1504.00, "holding_period": "short-term"},
        ],
    }
    losses = positions_with_losses.get(portfolio_id.upper(), [])
    if not losses:
        return f"No tax-loss harvesting candidates found in {portfolio_id.upper()}."
    lines = [f"Tax-loss harvesting candidates for {portfolio_id.upper()}:"]
    for pos in losses:
        lines.append(
            f"  {pos['ticker']}: ${pos['unrealized_loss']:,.2f} loss ({pos['holding_period']})"
        )
    lines.append(f"\nTotal harvestable loss: ${sum(p['unrealized_loss'] for p in losses):,.2f}")
    lines.append("Note: Consult a tax advisor before executing. Beware wash-sale rules.")
    return "\n".join(lines)

The tools are simplified to run without API keys, but the interfaces mirror what you’d wire to real data providers. In production, replace the mock dictionaries with calls to Polygon.io, Alpha Vantage, or your internal OMS.

Step 3: Create Specialist Agents

Each specialist gets a focused agent with tools, instructions, and a clear domain boundary.

# agents.py
from agents import Agent

from tools import (
    get_stock_price, get_momentum_score, get_sector_correlation,
    calculate_volatility, calculate_var, max_drawdown,
    get_portfolio_holdings, generate_rebalance_suggestion, tax_loss_harvest_check,
)

market_data_agent = Agent(
    name="Market Data Agent",
    instructions=(
        "You are a market data analyst. Help users with stock prices, momentum "
        "indicators, sector correlations, and market data queries. Always cite "
        "specific numbers. If a ticker isn't found, suggest alternatives from "
        "your known set. Be precise — round to 2 decimal places."
    ),
    tools=[get_stock_price, get_momentum_score, get_sector_correlation],
)

risk_assessment_agent = Agent(
    name="Risk Assessment Agent",
    instructions=(
        "You are a risk analyst. Help users understand portfolio risk through "
        "volatility analysis, Value at Risk calculations, and drawdown assessments. "
        "Always explain what each metric means in plain English alongside the number. "
        "Flag positions that exceed typical risk thresholds (vol > 35%, drawdown > 25%)."
    ),
    tools=[calculate_volatility, calculate_var, max_drawdown],
)

portfolio_agent = Agent(
    name="Portfolio Agent",
    instructions=(
        "You are a portfolio manager. Help users with portfolio holdings, "
        "rebalancing recommendations, and tax-loss harvesting. Always start by "
        "retrieving the portfolio before making suggestions. Compare current "
        "allocations to targets. Be specific about which positions to adjust."
    ),
    tools=[get_portfolio_holdings, generate_rebalance_suggestion, tax_loss_harvest_check],
)

The SDK’s Agent class handles tool binding and prompt construction automatically — the instructions, tool schemas, and any handoff tools are assembled into the model context without manual prompt engineering.

Step 4: Wire Up the Triage Agent with Handoffs

The triage agent is the entry point. It classifies the user’s intent and delegates to the right specialist using the SDK’s handoff() function.

# main.py
from agents import Agent, Runner, handoff
from agents import market_data_agent, risk_assessment_agent, portfolio_agent

triage_agent = Agent(
    name="Triage Agent",
    instructions=(
        "You are a financial assistant triage specialist. Your job is to understand "
        "what the user is asking and route them to the right specialist.\n\n"
        "Handoff rules:\n"
        "- Stock prices, momentum, correlations, market data → Market Data Agent\n"
        "- Volatility, VaR, drawdown, risk metrics → Risk Assessment Agent\n"
        "- Portfolio holdings, rebalancing, tax-loss harvesting → Portfolio Agent\n"
        "- If the query spans multiple domains, pick the most relevant specialist — "
        "they can chain additional lookups if needed.\n\n"
        "If the query is simple and you can answer directly, do so. Otherwise hand off."
    ),
    handoffs=[
        handoff(market_data_agent),
        handoff(risk_assessment_agent),
        handoff(portfolio_agent),
    ],
)

The handoff() function creates a transfer_to_<agent_name> tool automatically. The LLM decides which tool to call based on the user’s query — no if "risk" in query: ... routing code. For a deeper comparison of this pattern vs LangGraph’s state-machine approach, see our LangGraph vs OpenAI and Claude Agent SDKs comparison.

Step 5: Run the Agent

# main.py (continued)
import asyncio

async def main():
    queries = [
        "What's NVDA's current price and momentum? How correlated is it with the S&P 500?",
        "Calculate the 95% 1-day VaR for NVDA. What's its max drawdown over the last year?",
        "Show me the holdings for portfolio PF-ALPHA. Any tax-loss harvesting opportunities?",
        "I want to rebalance PF-BETA to a conservative allocation. What do you recommend?",
    ]

    for query in queries:
        print(f"\n{'='*60}")
        print(f"Query: {query}")

        result = await Runner.run(triage_agent, input=query)
        print(f"Response: {result.final_output}")

        # Show which specialist handled the query
        if hasattr(result, 'new_items') and result.new_items:
            for item in result.new_items:
                if hasattr(item, 'raw_item') and hasattr(item.raw_item, 'type'):
                    if item.raw_item.type == "handoff_output_item":
                        print(f"  ↳ Routed: {item.raw_item.source_agent.name} → "
                              f"{item.raw_item.target_agent.name}")

if __name__ == "__main__":
    asyncio.run(main())

Expected output:

============================================================
Query: What's NVDA's current price and momentum? How correlated is it with the S&P 500?
Response: NVDA is currently at $131.29 ▲ $2.38 (+1.85%). Its 6-month momentum score
is 89/100 — strong momentum. Sector correlation to the S&P 500 is 0.91, meaning it's
highly correlated and offers limited diversification benefit vs the broader market.
  ↳ Routed: Triage Agent → Market Data Agent

============================================================
Query: Calculate the 95% 1-day VaR for NVDA. What's its max drawdown over the last year?
Response: NVDA's 95% 1-day Value at Risk is $4.85 per share (3.69%). This means
there's a 95% chance the daily loss won't exceed this amount. The 12-month max
drawdown is -35.2%, which is severe — NVDA requires high risk tolerance. Given the
45.1% annualized volatility (HIGH), this is a speculative position.
  ↳ Routed: Triage Agent → Risk Assessment Agent

============================================================
Query: Show me the holdings for portfolio PF-ALPHA. Any tax-loss harvesting opportunities?
Response: PF-ALPHA has 6 holdings totaling $295,610.00 with $12,450.00 cash.
The only tax-loss harvesting candidate is TLT with a -$710.00 long-term loss.
Note: consult a tax advisor and beware wash-sale rules before executing.
  ↳ Routed: Triage Agent → Portfolio Agent

============================================================
Query: I want to rebalance PF-BETA to a conservative allocation.
Response: I've retrieved PF-BETA ($103,370.00 total) and generated a conservative
target: 40% equity, 50% bonds, 30% cash. Current allocation is 35% equity (VWO),
30% bonds (TLT), 35% equity via SPY. You'd need to reduce SPY exposure significantly
and increase TLT holdings to meet the target. I've also flagged VWO for potential
tax-loss harvesting ($1,504.00 short-term loss).
  ↳ Routed: Triage Agent → Portfolio Agent

The agent chains multiple tool calls per query automatically: the NVDA query calls get_stock_price, get_momentum_score, and get_sector_correlation in a single graph cycle. The portfolio rebalancing query calls get_portfolio_holdings, generate_rebalance_suggestion, and tax_loss_harvest_check — all without the user specifying which tools to use.

Step 6: Add Guardrails for Financial Advice

Financial agents need explicit disclaimers and boundaries. The SDK’s guardrail system lets us add these before and after every model call.

# guardrails.py
from agents import (
    Agent, GuardrailFunctionOutput, InputGuardrail, OutputGuardrail,
    RunContextWrapper, input_guardrail, output_guardrail,
)
from pydantic import BaseModel
import re


class AdviceCheck(BaseModel):
    is_financial_advice: bool
    reasoning: str


@output_guardrail
async def block_unqualified_advice(
    ctx: RunContextWrapper[None], agent: Agent, output_text: str
) -> GuardrailFunctionOutput:
    """Ensure the agent never presents analysis as personal financial advice."""
    advice_patterns = [
        r"you should (buy|sell|hold)",
        r"I (would )?recommend (buying|selling)",
        r"this is (a )?(buy|sell) signal",
    ]
    for pattern in advice_patterns:
        if re.search(pattern, output_text, re.IGNORECASE):
            return GuardrailFunctionOutput(
                output_info=AdviceCheck(is_financial_advice=True, reasoning=f"Matched: {pattern}"),
                tripwire_triggered=False,  # Don't block — just flag for review
            )
    return GuardrailFunctionOutput(
        output_info=AdviceCheck(is_financial_advice=False, reasoning="Safe"),
        tripwire_triggered=False,
    )

Attach it to each agent:

market_data_agent = Agent(
    name="Market Data Agent",
    instructions=(...),
    tools=[get_stock_price, get_momentum_score, get_sector_correlation],
    output_guardrails=[block_unqualified_advice],
)
# Repeat for risk_assessment_agent and portfolio_agent

This guardrail doesn’t block the response — it flags it for review. In production, you’d log flagged responses to a compliance dashboard. For more on guardrail patterns, see our agent governance deep dive.

Where to Go Next

This agent handles the core portfolio analysis workflow. Here’s what we add before production:

  • Real market data: Replace mock dictionaries with Polygon.io or Alpha Vantage API calls. Add @function_tool(timeout_seconds=5.0) to every external API tool.
  • Structured outputs: Use agent.output_type with a Pydantic model to parse tool results into typed objects — essential for downstream dashboard and reporting systems.
  • Persistent sessions: The SDK’s session API stores conversation state in SQLite or Postgres-backed storage (docs). Multi-turn portfolio analysis collapses without it — the agent needs to remember which portfolio and tickers the user is working with.
  • Tracing: The SDK integrates with OpenAI’s tracing dashboard by default. Set RunConfig(trace_include_sensitive_data=False) to keep portfolio values out of traces. For a comparison of observability options, see our LangSmith vs Langfuse vs Arize Phoenix guide.
  • Human-in-the-loop for orders: Any trade execution tool should raise HITLApprovalRequired — the SDK pauses and waits for approval before executing. This is the same pattern we covered in our OpenAI Agents SDK tools and guardrails tutorial.
  • Evaluation: Build a golden dataset of 30+ financial queries with expected tool calls and responses. Run regression tests before every deploy.

The multi-agent handoff pattern transfers to any finance subdomain — wealth management, trading, risk compliance, corporate treasury — with minimal changes. Swap the tools, keep the graph structure. For the full framework landscape, our complete guide to AI agent frameworks 2026 compares the Agents SDK against LangGraph, CrewAI, and AutoGen across production readiness, observability, and deployment complexity.


Sources: OpenAI Agents SDK quickstart, Agents SDK tools documentation, Handoffs guide, Human-in-the-loop.

← back to blog