Financial Tools and Structured Outputs

Language models are strong at reasoning, but without tools, they can only talk - not act. For financial applications requiring precise calculations, real-time data access, or system integrations, tool-augmented agents are essential. Combined with structured outputs using Pydantic, we can build reliable financial systems that guarantee data format compliance.

From Talking to Acting: Tool-Augmented Agents

Consider a financial advisor agent. Without tools, it might attempt a rough ROI estimate based on training data. With tools, it can call a precise calculation function and return exact figures. The difference is critical in finance where accuracy matters.

flowchart TB
    subgraph WithTools["With Tools"]
        direction LR
        Q2[Calculate ROI?] --> T[Call: calculate_roi
inv=500000, profit=125000]
        T --> E[Execute Python]
        E --> R[25.00% exactly]
        R --> A2[Professional analysis
with exact figures]
    end

    subgraph NoTools["Without Tools"]
        direction LR
        Q1[Calculate ROI?] --> G1[Guess from
training data]
        G1 --> A1["~25% maybe?"]
    end

    classDef pinkClass fill:#E74C3C,stroke:#333,stroke-width:2px,color:#fff
    classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff

    class NoTools pinkClass
    class WithTools greenClass

Common Tool Types for Finance

Tool Type Use Case Example
Math Functions Precise calculations ROI, compound interest, risk metrics
APIs & Webhooks External integrations Market data, CRM, trading systems
Databases Data retrieval Position history, customer records
Web Search Real-time information News, regulatory updates
Code Execution Custom transformations Data processing, report generation

Function Calling: The Foundation

Early approaches used prompt-based cues like “Calling calculator with X and Y” followed by parsing. This was fragile. Modern function calling provides a formal mechanism where:

  1. Tools are defined with JSON schemas describing name, purpose, and parameters
  2. Models are trained to recognize when tools should be used
  3. Structured calls are returned with function name and arguments
  4. Backend executes the function and returns results to the model

Building a Financial Tool Agent

Let’s build an agent that can perform ROI and tax calculations:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import json
from openai import OpenAI

client = OpenAI()

# System prompt for financial advisor persona
SYSTEM = """You are a helpful corporate financial advisor.
Provide clear, professional financial analysis and recommendations."""

# Tool 1: ROI Calculator
def calculate_roi(investment: float, profit: float, years: int) -> dict:
"""
Calculate Return on Investment for corporate investments.
Returns total and annualized ROI.
"""
if investment <= 0:
raise ValueError("Investment amount must be positive")

total_roi = (profit / investment) * 100
annual_roi = total_roi / years if years > 0 else 0

return {
"initial_investment": round(investment, 2),
"profit": round(profit, 2),
"total_roi_percent": round(total_roi, 2),
"annualized_roi_percent": round(annual_roi, 2),
"investment_period_years": years
}

# Tool 2: Tax Deduction Calculator
def calculate_expense_deduction(
gross_expenses: float,
department: str,
tax_rate: float = 0.21
) -> dict:
"""
Calculate corporate tax deductions for departmental expenses.
Different departments have different deduction rates.
"""
DEDUCTION_RATES = {
"operations": 0.95,
"marketing": 0.80,
"research": 0.90,
"facilities": 0.75,
"training": 0.85
}

dept_lower = department.lower()
if dept_lower not in DEDUCTION_RATES:
raise ValueError(f"Unknown department: {department}")

deduction_rate = DEDUCTION_RATES[dept_lower]
deductible_amount = gross_expenses * deduction_rate
tax_savings = deductible_amount * tax_rate

return {
"gross_expenses": round(gross_expenses, 2),
"department": department,
"deduction_rate_percent": round(deduction_rate * 100, 1),
"deductible_amount": round(deductible_amount, 2),
"estimated_tax_savings": round(tax_savings, 2)
}

Defining Tool Schemas

Tools must be described in JSON schema format for the model to use them:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
TOOLS = [
{
"type": "function",
"function": {
"name": "calculate_roi",
"description": "Calculate Return on Investment for corporate investments.",
"parameters": {
"type": "object",
"properties": {
"investment": {
"type": "number",
"description": "Initial investment amount in dollars"
},
"profit": {
"type": "number",
"description": "Total profit earned in dollars"
},
"years": {
"type": "integer",
"description": "Investment period in years"
}
},
"required": ["investment", "profit", "years"]
}
}
},
{
"type": "function",
"function": {
"name": "calculate_expense_deduction",
"description": "Calculate corporate tax deductions for department expenses.",
"parameters": {
"type": "object",
"properties": {
"gross_expenses": {
"type": "number",
"description": "Total gross expenses in dollars"
},
"department": {
"type": "string",
"description": "Department name (operations, marketing, research, etc.)"
},
"tax_rate": {
"type": "number",
"description": "Corporate tax rate (default 0.21)"
}
},
"required": ["gross_expenses", "department"]
}
}
}
]

# Map tool names to Python callables
FUNCTIONS = {
"calculate_roi": calculate_roi,
"calculate_expense_deduction": calculate_expense_deduction
}

The Tool Execution Flow

The complete flow involves three steps:

flowchart TD
    U[User: Analyze my $500K investment
with $125K profit over 3 years] --> M1[Model Call 1]
    M1 --> |tool_calls| TC[Tool Call: calculate_roi
investment=500000
profit=125000
years=3]
    TC --> EX[Execute Python Function]
    EX --> R[Result: total_roi=25%
annualized=8.33%]
    R --> M2[Model Call 2
with tool result]
    M2 --> A[Final Analysis:
Professional investment report
with exact figures]

    classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff
    classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff

    class TC blueClass
    class EX orangeClass
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
def agentic_tool_call(tool_name: str, user_content: str, tool_args: dict = None):
"""Execute a tool call with agentic response generation."""

# Step 1: Ask model to call the specified tool
first = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": user_content}
],
tools=TOOLS,
tool_choice={"type": "function", "function": {"name": tool_name}},
temperature=0.0
)

msg = first.choices[0].message
tool_call = msg.tool_calls[0] if msg.tool_calls else None

if tool_call is None:
raise RuntimeError("Model did not issue the expected tool call")

# Step 2: Execute the Python function
name = tool_call.function.name
model_args = json.loads(tool_call.function.arguments or "{}")
call_args = {**model_args, **(tool_args or {})}
result = FUNCTIONS[name](**call_args)

# Step 3: Send result back for final analysis
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": user_content},
{"role": "assistant", "content": msg.content or "",
"tool_calls": [tool_call.model_dump()]},
{"role": "tool", "tool_call_id": tool_call.id,
"name": name, "content": json.dumps(result)}
]

final = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
temperature=0.2
)

return final.choices[0].message.content

Example Output

When called with an investment analysis request:

1
2
3
4
result = agentic_tool_call(
tool_name="calculate_roi",
user_content="Analyze my company's $500,000 investment that earned $125,000 over 3 years."
)

The output provides exact figures with professional analysis:

1
2
3
4
5
6
7
8
9
10
11
Your company invested $500,000 and earned a profit of $125,000 over 3 years.

**ROI Analysis:**
1. **Total ROI**: 25.00% - Your investment has grown by a quarter
of its original value over the period.
2. **Annualized ROI**: 8.33% - Average return per year, indicating
steady growth.

**Recommendation**: A total ROI of 25% over 3 years is a positive
outcome. The annualized ROI of 8.33% is competitive compared to
traditional savings. Consider reinvesting profits to compound growth.

Structured Outputs with Pydantic

Tools return data, but free-form LLM responses can be inconsistent. Structured outputs ensure machine-readable, validated data that downstream systems can rely on.

The Problem with Naive JSON

Simply prompting “Return JSON” often fails:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def naive_json_extraction():
"""Demonstrate problems with naive JSON parsing."""

prompt = """
Create an employee profile in JSON format with:
- name: John Smith
- employee_id: E123456
Return only the JSON, no additional text.
"""

response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}]
)

raw = response.choices[0].message.content
# Model often returns: ```json\n{...}\n```
# This breaks json.loads()

try:
return json.loads(raw) # Fails!
except json.JSONDecodeError as e:
print(f"Parse failed: {e}")

The model might return markdown-wrapped JSON that breaks parsing.

Pydantic to the Rescue

Pydantic provides type-safe data structures with validation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from pydantic import BaseModel, Field, ValidationError
from enum import Enum
from typing import List, Literal
from datetime import datetime

class EmployeeProfile(BaseModel):
"""Validated employee profile with type enforcement."""
name: str = Field(..., min_length=1, max_length=100)
employee_id: str = Field(..., pattern=r'^E\d{6}$')
department: str = Field(..., min_length=1, max_length=50)
email: str = Field(..., pattern=r'^[^@]+@[^@]+\.[^@]+$')
tenure_years: int = Field(..., ge=0, le=60)

class ClaimType(str, Enum):
MEDICAL = "medical"
DENTAL = "dental"
VISION = "vision"
PREVENTIVE = "preventive"

class HealthClaim(BaseModel):
"""Individual health insurance claim."""
claim_id: str = Field(..., pattern=r'^C\d{8}$')
claim_type: ClaimType
amount: float = Field(..., gt=0, le=1000000)
description: str = Field(..., min_length=10, max_length=500)
date_submitted: datetime
status: Literal["pending", "approved", "denied"]

class ClaimsSummary(BaseModel):
"""Complete claims summary with nested validation."""
employee: EmployeeProfile
claims: List[HealthClaim]
total_claims_amount: float
approved_claims_count: int
pending_claims_count: int
average_claim_value: float

Structured Output with OpenAI

Use response_format={"type": "json_object"} for guaranteed JSON:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def generate_employee_profile_structured() -> EmployeeProfile:
"""Generate a validated employee profile."""

response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Generate employee data as JSON."},
{"role": "user", "content": "Create profile for a Finance employee"}
],
response_format={"type": "json_object"},
temperature=0.7
)

try:
raw_json = response.choices[0].message.content
profile_data = json.loads(raw_json)

# Pydantic validates the data
return EmployeeProfile(**profile_data)

except ValidationError as e:
print(f"Validation error: {e}")
raise

Catching Invalid Data

Pydantic prevents bad data from entering the system:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def demonstrate_validation():
"""Show Pydantic catching invalid data."""

invalid_data = {
"name": "Alex Turner",
"employee_id": "INVALID123", # Wrong format
"department": "Operations",
"email": "not-an-email", # Invalid email
"tenure_years": 150 # Exceeds max of 60
}

try:
employee = EmployeeProfile(**invalid_data)
except ValidationError as e:
for error in e.errors():
print(f"Field: {error['loc'][0]}")
print(f"Error: {error['msg']}")
print(f"Value: {error['input']}\n")

Output shows all validation failures:

1
2
3
4
5
6
7
8
9
10
11
Field: employee_id
Error: String should match pattern '^E\d{6}$'
Value: INVALID123

Field: email
Error: String should match pattern '^[^@]+@[^@]+\.[^@]+$'
Value: not-an-email

Field: tenure_years
Error: Input should be less than or equal to 60
Value: 150

Financial Data Models

Let’s build practical Pydantic models for financial applications:

Trade Order Model

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from decimal import Decimal
from typing import Optional

class TradeOrder(BaseModel):
"""Validated trade order structure."""
order_id: str = Field(..., pattern=r'^ORD-\d{12}$')
symbol: str = Field(..., min_length=1, max_length=10)
side: Literal["buy", "sell"]
order_type: Literal["market", "limit", "stop", "stop_limit"]
quantity: int = Field(..., gt=0, le=1000000)
price: Optional[float] = Field(None, gt=0)
stop_price: Optional[float] = Field(None, gt=0)
time_in_force: Literal["day", "gtc", "ioc", "fok"] = "day"

@validator('price')
def limit_orders_need_price(cls, v, values):
if values.get('order_type') in ['limit', 'stop_limit'] and v is None:
raise ValueError('Limit orders require a price')
return v

@validator('stop_price')
def stop_orders_need_stop_price(cls, v, values):
if values.get('order_type') in ['stop', 'stop_limit'] and v is None:
raise ValueError('Stop orders require a stop price')
return v

Risk Assessment Model

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class RiskAssessment(BaseModel):
"""Risk analysis output structure."""
transaction_id: str
risk_score: float = Field(..., ge=0, le=100)
risk_level: Literal["low", "medium", "high", "critical"]
factors: List[str]
recommendation: Literal["approve", "review", "reject"]
confidence: float = Field(..., ge=0, le=1)

@validator('recommendation')
def recommendation_matches_risk(cls, v, values):
risk_level = values.get('risk_level')
if risk_level == 'critical' and v == 'approve':
raise ValueError('Cannot approve critical risk transactions')
return v

SWIFT Message Model

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class SWIFTMessage(BaseModel):
"""Validated SWIFT message structure."""
message_type: Literal["MT103", "MT202", "MT940", "MT950"]
sender_bic: str = Field(..., pattern=r'^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$')
receiver_bic: str = Field(..., pattern=r'^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$')
reference: str = Field(..., max_length=16)
amount: float = Field(..., gt=0, le=999999999.99)
currency: str = Field(..., pattern=r'^[A-Z]{3}$')
value_date: datetime
ordering_customer: Optional[str] = None
beneficiary: Optional[str] = None

def get_sender_country(self) -> str:
"""Extract country code from sender BIC."""
return self.sender_bic[4:6]

def get_receiver_country(self) -> str:
"""Extract country code from receiver BIC."""
return self.receiver_bic[4:6]

Combining Tools and Structured Outputs

The most powerful pattern combines tool calling with Pydantic validation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class AnalysisResult(BaseModel):
"""Structured analysis result."""
investment: float
profit: float
years: int
total_roi_percent: float
annualized_roi_percent: float
recommendation: Literal["strong_buy", "buy", "hold", "sell", "strong_sell"]
confidence: float = Field(..., ge=0, le=1)
reasoning: str

def analyze_investment_structured(
investment: float,
profit: float,
years: int
) -> AnalysisResult:
"""Perform analysis with validated structured output."""

# Step 1: Execute calculation tool
calc_result = calculate_roi(investment, profit, years)

# Step 2: Get LLM analysis with structured output
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"""
Analyze this ROI calculation and provide recommendation:
{json.dumps(calc_result)}

Return JSON with: recommendation, confidence, reasoning
"""}
],
response_format={"type": "json_object"},
temperature=0.2
)

# Step 3: Parse and validate
analysis_data = json.loads(response.choices[0].message.content)

return AnalysisResult(
**calc_result,
recommendation=analysis_data["recommendation"],
confidence=analysis_data["confidence"],
reasoning=analysis_data["reasoning"]
)

Takeaways

  1. Tools transform agents from passive responders into active problem-solvers that can perform precise calculations and access real systems

  2. Function calling provides a formal mechanism for LLMs to invoke external functions with proper argument handling

  3. Three-step tool flow: Model requests tool → Backend executes → Model incorporates result into response

  4. Structured outputs with response_format={"type": "json_object"} guarantee valid JSON responses

  5. Pydantic validation catches invalid data before it enters your system, with detailed error reporting

  6. Financial models benefit from strict typing - trade orders, risk assessments, and SWIFT messages all require precise data formats

  7. Combine both patterns for maximum reliability: tools for precise execution, Pydantic for validated outputs


This is the eighth post in my Applied Agentic AI for Finance series. Next: State and Memory for Trading Agents where we’ll explore how agents maintain context across trading sessions.

Summary: Google's Agent Tools & MCP Interoperability Summary: Google's Context Engineering - Sessions & Memory

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×