Convert an expense CSV using dated exchange rates in Python
Convert expense amounts into one currency and record the dated rate used for each row.
On this page
Run the example
Download the complete source below. In a macOS or Linux terminal, install the SDK, set your secret API key, then run the file.
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install parseapiexport PARSEAPI_KEY='YOUR_SECRET_API_KEY'python3 expense-currency-csv.python.pyConvert the expense column
Convert a CSV's amounts into one currency using the rate for each expense date. Keep the original amount and record the rate used beside the converted estimate.
Use Python 3.9 or later. Install the SDK and set your secret PARSEAPI_KEY with the setup above.
1. Run your expense file
Save this as expenses.csv:
id,amount,currency,date
001,12.50,EUR,2026-08-28
002,5.25,EUR,2026-08-28
003,1200,JPY,2026-08-29
004,-8.00,EUR,2026-08-28Run the downloaded script:
python3 expense-currency-csv.python.py expenses.csv --quote USD --output expenses-usd.csvUse decimal-point amounts, three-letter currency codes, and real YYYY-MM-DD dates through today. Negative amounts work for refunds. Currency symbols, grouping separators, and ambiguous dates need cleanup first; the script never guesses their meaning.
2. Fetch the dated rate
if digits is None:
digits = quote_digits(parse.currency(quote), quote)
# The amount stays in Decimal locally. The API supplies a reusable rate.
data = parse.currency.rate(base, quote, date=day)
answer = rate_answer(data, base, quote, day)The Currency API supplies target precision and a rate on or before the requested date. Expenses with the same currency pair and date reuse one lookup. Amounts and descriptions stay in the script.
Keep parse_rate_date: a weekend expense can use an earlier published rate. A missing historical rate stays unavailable; the script never substitutes today's rate.
3. Multiply and round once
def converted(amount, base, quote, rate, rate_date, digits):
# Keep decimal amounts exact, multiply once, then round the row once.
try:
with localcontext() as context:
context.prec = 50
rounded = (amount * rate).quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_EVEN)
except InvalidOperation:
return outcome("error", "unexpected_response")
if rounded == 0:
rounded = abs(rounded)
return outcome("converted", http_status=200, parse_base=base, parse_quote=quote,
parse_rate=format(rate, "f"), parse_rate_date=rate_date,
parse_converted=format(rounded, "f"), parse_digits=digits)The script uses Decimal, then rounds each row to the target currency's decimal places with ROUND_HALF_EVEN. An exact tie rounds to the nearest even final digit. Adapt that policy if your application requires another rounding rule. Python Decimal documentation.
The output includes parse_converted, base and target currencies, the applied rate, its date, decimal precision, and status/error columns. converted means an estimate was written. Missing inputs, unsupported formats, unavailable rates, and failed requests retain their original rows with a review status and empty estimate.
These are reference estimates; actual card charges, fees, and required reporting rates can differ.
The complete script preserves original CSV cells and order. Required headers are amount, currency, and date; use 1-200 rows within 64 KiB, distinct nonempty headers, and no existing output columns. Calls use a ten-second timeout without retries. Account/rate errors stop new lookups.
The output filename must be new. Omit the input filename to use the included sample. Without --output, CSV goes to standard output; never redirect onto the input. Exit 0 means all rows converted, 2 means the output includes review rows, and 1 means a startup or file error.
Complete example
The complete Python source. The script reads your API key from PARSEAPI_KEY.
View and copy the complete source
import argparse
import csv
import io
import re
import sys
from pathlib import Path
from parseapi import ParseAPI, ParseAPIError
from datetime import date as CalendarDate, datetime, timezone
from decimal import Decimal, InvalidOperation, ROUND_HALF_EVEN, localcontext
DESCRIPTION = "Convert an expense CSV using dated reference exchange rates."
REQUIRED = ["amount", "currency", "date"]
COLUMNS = ["parse_base", "parse_quote", "parse_rate", "parse_rate_date", "parse_converted",
"parse_digits", "parse_status", "parse_error", "parse_http_status"]
SAMPLE = """id,amount,currency,date,note
001,12.50,EUR,2026-08-28,Lunch
002,5.25,eur,2026-08-28,Same pair and day
003,1200,JPY,2026-08-29,Weekend expense
004,-8.00,EUR,2026-08-28,Refund
005,,EUR,2026-08-28,Missing amount
006,10.00,EUR,03/04/2026,Date needs interpretation
"""
# example:read
def read_rows(stream):
reader = csv.reader(stream, strict=True)
fields = next(reader, None)
if (not fields or not set(REQUIRED).issubset(fields)
or any(not field.strip() for field in fields)
or len(fields) != len(set(fields))):
raise ValueError("Use distinct, nonempty headers including " + ", ".join(REQUIRED) + ".")
if set(fields) & set(COLUMNS):
raise ValueError("The input already contains output columns. Use the original CSV.")
rows = []
for cells in reader:
if len(cells) != len(fields):
raise ValueError("Every row must have the same number of cells as the header.")
rows.append(dict(zip(fields, cells)))
if len(rows) > 200:
raise ValueError("Use 1 to 200 data rows for this example.")
if not rows:
raise ValueError("The CSV needs at least one data row.")
return fields, rows
# /example:read
def outcome(status, error="", http_status="", **values):
return {**dict.fromkeys(COLUMNS, ""), "parse_status": status,
"parse_error": error, "parse_http_status": http_status, **values}
def write_rows(stream, fields, rows):
writer = csv.DictWriter(stream, fieldnames=fields + COLUMNS)
writer.writeheader()
writer.writerows(rows)
def iso_day(value):
if not isinstance(value, str) or not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value):
raise ValueError("Use an ISO calendar date.")
return CalendarDate.fromisoformat(value)
# example:amount
def expense(row):
amount, base, day = row["amount"].strip(), row["currency"].strip().upper(), row["date"].strip()
if not amount or not base or not day:
return None, "missing_input"
# Decimal point only, with up to 15 whole digits and 6 decimal places.
if not re.fullmatch(r"[+-]?[0-9]{1,15}(?:\.[0-9]{1,6})?", amount):
return None, "review_amount"
if not re.fullmatch(r"[A-Z]{3}", base):
return None, "review_currency"
try:
date = iso_day(day)
except ValueError:
return None, "review_date"
if date > datetime.now(timezone.utc).date():
return None, "future_date"
return (Decimal(amount), base, day), None
# /example:amount
def quote_digits(data, quote):
if (not isinstance(data, dict) or data.get("currency") != quote
or type(data.get("digits")) is not int or not 0 <= data["digits"] <= 4):
raise ValueError("unexpected_currency_response")
return data["digits"]
def rate_answer(data, base, quote, day):
if (not isinstance(data, dict) or data.get("base") != base or data.get("quote") != quote
or type(data.get("rate")) not in (int, float)):
raise ValueError("unexpected_rate_response")
rate = Decimal(str(data["rate"]))
if not rate.is_finite() or rate <= 0 or iso_day(data.get("date")) > iso_day(day):
raise ValueError("unexpected_rate_response")
return rate, data["date"]
# example:result
def converted(amount, base, quote, rate, rate_date, digits):
# Keep decimal amounts exact, multiply once, then round the row once.
try:
with localcontext() as context:
context.prec = 50
rounded = (amount * rate).quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_EVEN)
except InvalidOperation:
return outcome("error", "unexpected_response")
if rounded == 0:
rounded = abs(rounded)
return outcome("converted", http_status=200, parse_base=base, parse_quote=quote,
parse_rate=format(rate, "f"), parse_rate_date=rate_date,
parse_converted=format(rounded, "f"), parse_digits=digits)
# /example:result
def enrich(rows, parse, quote="USD"):
quote = quote.strip().upper()
if not re.fullmatch(r"[A-Z]{3}", quote):
raise ValueError("Use a three-letter quote currency.")
results, cache, stopped = [], {}, False
digits, metadata_error = None, None
for row in rows:
values, issue = expense(row)
if issue:
results.append({**row, **outcome(issue)})
continue
amount, base, day = values
identity = (base, quote, day)
if identity in cache:
answer = cache[identity]
elif stopped:
answer = outcome("not_attempted")
elif metadata_error is not None:
answer = metadata_error
else:
try:
# example:request
if digits is None:
digits = quote_digits(parse.currency(quote), quote)
# The amount stays in Decimal locally. The API supplies a reusable rate.
data = parse.currency.rate(base, quote, date=day)
answer = rate_answer(data, base, quote, day)
# /example:request
except ParseAPIError as error:
status = "rate_unavailable" if error.status == 404 else "error"
answer = outcome(status, error.code, error.status)
stopped = error.status in (401, 403, 429)
except (ValueError, InvalidOperation):
answer = outcome("error", "unexpected_response")
except Exception:
answer = outcome("error", "request_failed")
if digits is None:
metadata_error = answer
cache[identity] = answer
if isinstance(answer, tuple):
rate, rate_date = answer
result = converted(amount, base, quote, rate, rate_date, digits)
else:
result = answer
results.append({**row, **result})
return results
def main():
parser = argparse.ArgumentParser(description=DESCRIPTION)
parser.add_argument("input", nargs="?", help="UTF-8 CSV filename, or omit to run the sample")
parser.add_argument("--output", help="New CSV filename, or omit for standard output")
parser.add_argument("--quote", default="USD", help="Three-letter target currency, default USD")
args = parser.parse_args()
try:
if not re.fullmatch(r"[A-Za-z]{3}", args.quote.strip()):
raise ValueError("Use a three-letter quote currency.")
if args.output and (Path(args.output).exists() or Path(args.output).is_symlink()):
raise ValueError("The output path already exists. Choose a new filename.")
if args.input:
path = Path(args.input)
if path.stat().st_size > 65536:
raise ValueError("Use a CSV no larger than 64 KiB.")
with path.open(encoding="utf-8-sig", newline="") as stream:
fields, rows = read_rows(stream)
else:
fields, rows = read_rows(io.StringIO(SAMPLE))
# Reads PARSEAPI_KEY. Reuse one client without automatic retries.
with ParseAPI(timeout=10.0, retries=0) as parse:
results = enrich(rows, parse, args.quote)
if args.output:
# Exclusive creation protects against a file appearing during the lookups.
with Path(args.output).open("x", encoding="utf-8", newline="") as stream:
write_rows(stream, fields, results)
else:
write_rows(sys.stdout, fields, results)
except (ValueError, OSError, csv.Error) as error:
print(f"Could not complete: {error}", file=sys.stderr)
return 1
review = sum(row["parse_status"] != "converted" for row in results)
print(f"{len(results)} rows written, {review} need review.", file=sys.stderr)
return 2 if review else 0
if __name__ == "__main__":
sys.exit(main())
Need a key? Create a secret API key. For every field and parameter, see Currency API reference.