Turn a brokerage statement into structured account data
Brokerage statements bury account numbers, types, and balances in dense, multi-account tables that look different at every firm. Reducto parses those tables cleanly, then returns every account as structured JSON with a citation on each value.
Brokerage and investment statements are some of the densest financial PDFs you will handle: multiple accounts, holdings grids, and summary tables, laid out a little differently by every provider. Pulling each account's number, type, and ending value by hand across dozens of pages is slow and easy to get wrong. With Reducto you parse the statement once so the tables come back clean, then extract every account as structured JSON with a source citation on each value. You describe the fields you need one time and the same pipeline carries over from one provider to the next.
What you'll build
Brokerage statement (PDF or scan)
- Reducto Parse with agentic table correction
- Clean, structured tables (HTML)
- Reducto Extract with your schema (+ citations)
- Every account as structured JSON, cited to its source
Setup
Grab an API key from studio.reducto.ai -> API Keys -> Create new API key, then set it and install the SDK:
bashexport REDUCTO_API_KEY="your-api-key-here" pip install reducto
Use any brokerage statement: a downloaded PDF or a scan. No template setup or per-provider tuning required.
Step 1: Parse with agentic table correction
Brokerage statements live and die by their tables: account summaries, holdings, and activity grids, often with merged cells and columns that do not line up. Turn on agentic table correction so a vision model rebuilds those tables, and return them as HTML to keep the structure intact. Chunking is disabled here because the citations in Step 2 need the full, unchunked parse.
pythonfrom pathlib import Path from reducto import Reducto client = Reducto() upload = client.upload(file=Path("brokerage_statement.pdf")) parse_result = client.parse.run( input=upload.file_id, enhance={ "agentic": [{"scope": "table"}], # vision model rebuilds dense, misaligned tables "summarize_figures": False, }, formatting={ "table_output_format": "html", # preserve complex table structure }, settings={ "extraction_mode": "hybrid", }, retrieval={ "chunking": {"chunk_mode": "disabled"}, # citations in Step 2 need the full parse }, ) print(f"Parsed {parse_result.usage.num_pages} pages. Review it in Studio: {parse_result.studio_link}")
Extract can only return what Parse sees, so open the job in Studio and confirm the account tables look right before you extract.
Step 2: Extract every account
Point Extract at the parse you just ran with a jobid:// reference so it reuses that result instead of parsing again. The schema names the fields you want, and the descriptions are the hints the model uses to find them, so be specific. The accountType description below tells the model to keep the specific sub-type and drop the broad category header. Citations are on, so every value comes back with the page it was read from.
pythonschema = { "type": "object", "properties": { "customerName": { "type": "string", "description": "Full name of the customer.", }, "portfolioValue": { "type": "number", "description": "Total value of the customer's portfolio.", }, "accounts": { "type": "array", "items": { "type": "object", "properties": { "accountNumber": { "type": "string", "description": "Unique identifier for the account.", }, "accountType": { "type": "string", "description": ( "Type of account. Extract only the specific account sub-type " "name (e.g., 'Individual - TOD', 'Traditional IRA', " "'Education Account'). Do not include the broader account " "category prefix (e.g., 'GENERAL INVESTMENTS', 'PERSONAL " "RETIREMENT', or 'EDUCATION (529) ACCOUNTS')." ), }, "endValue": { "type": "number", "description": "End value of the account.", }, }, "required": ["accountNumber", "accountType", "endValue"], }, "description": "List of customer accounts.", }, }, "required": ["customerName", "portfolioValue", "accounts"], } result = client.extract.run( input=f"jobid://{parse_result.job_id}", # reuse the Step 1 parse, no re-parsing instructions={ "schema": schema, "system_prompt": ( "This is a brokerage account statement. Extract the customer name, the " "total portfolio value, and every account in the account summary." ), }, settings={ "citations": {"enabled": True, "numerical_confidence": False}, }, ) print(f"Fields: {result.usage.num_fields}, pages: {result.usage.num_pages}, credits: {result.usage.credits}")
Step 3: Read the citation-backed output
With citations on, result.result is a dict and each value comes back wrapped as an object with a value and a citations list. The accounts array comes back as a list, one dict per account, with each field wrapped the same way. Read the data with field["value"] and its source with field["citations"].
pythondata = result.result print(f"Customer: {data['customerName']['value']}") print(f"Portfolio value: {data['portfolioValue']['value']}") print(f"{len(data['accounts'])} accounts:") for acct in data["accounts"]: number = acct["accountNumber"]["value"] kind = acct["accountType"]["value"] end_value = acct["endValue"]["value"] page = acct["accountNumber"]["citations"][0]["bbox"]["page"] if acct["accountNumber"]["citations"] else "unknown" print(f" {kind} ({number}): {end_value} [page {page}]")
A trimmed example of the structured result (illustrative). Each value is wrapped as {value, citations}, the accounts array is a list of those wrapped objects, and confidence comes back as "high" or "low" because numerical_confidence is off:
json{ "customerName": { "value": "John W. Doe", "citations": [ { "type": "Text", "content": "John W. Doe", "bbox": { "left": 0.08, "top": 0.18, "width": 0.20, "height": 0.02, "page": 1 }, "confidence": "high" } ] }, "portfolioValue": { "value": 274222.20, "citations": [ { "type": "Text", "content": "Your Portfolio Value: $274,222.20", "bbox": { "left": 0.62, "top": 0.20, "width": 0.30, "height": 0.03, "page": 1 }, "confidence": "high" } ] }, "accounts": [ { "accountNumber": { "value": "X12-345678", "citations": [ { "type": "Table", "content": "X12-345678", "bbox": { "left": 0.10, "top": 0.46, "width": 0.18, "height": 0.02, "page": 2 }, "confidence": "high" } ] }, "accountType": { "value": "Individual - TOD", "citations": [ { "type": "Table", "content": "Individual - TOD", "bbox": { "left": 0.10, "top": 0.44, "width": 0.25, "height": 0.02, "page": 2 }, "confidence": "high" } ] }, "endValue": { "value": 152340.11, "citations": [ { "type": "Table", "content": "$152,340.11", "bbox": { "left": 0.80, "top": 0.46, "width": 0.12, "height": 0.02, "page": 2 }, "confidence": "high" } ] } }, { "accountNumber": { "value": "X98-765432", "citations": [ { "type": "Table", "content": "X98-765432", "bbox": { "left": 0.10, "top": 0.52, "width": 0.18, "height": 0.02, "page": 2 }, "confidence": "high" } ] }, "accountType": { "value": "Traditional IRA", "citations": [ { "type": "Table", "content": "Traditional IRA", "bbox": { "left": 0.10, "top": 0.50, "width": 0.25, "height": 0.02, "page": 2 }, "confidence": "high" } ] }, "endValue": { "value": 121882.09, "citations": [ { "type": "Table", "content": "$121,882.09", "bbox": { "left": 0.80, "top": 0.52, "width": 0.12, "height": 0.02, "page": 2 }, "confidence": "high" } ] } } ] }
Flip numerical_confidence back on to also get a 0 to 1 score on every value alongside the categorical "high" or "low".
Prefer no code?
Do the same thing in Reducto Studio: upload a statement, add a Parse action with agentic table correction, chain an Extract action with your schema, and Run. Click any value to highlight its source on the page, then Deploy the pipeline as an API endpoint for the rest of your statements.
Where this goes next
Parse and Extract are two tasks on one platform. Chain the rest of the toolkit onto the same statement pipeline:
- Sort first with Classify: route statements from different providers to the right schema before extracting.
- Break apart bundles with Split: divide a multi-account or multi-statement PDF into separate documents.
- Reach for Deep Extract: when a statement runs long or a value has to be exact, its self-verifying mode re-extracts until the numbers hold.
- Add humans where it matters with Pipelines: hold any account that comes back low-confidence for review before it posts.