Build a currency converter with Python
Build a Flask form that converts an amount with a dated reference rate and displays the published date beside the result.
Working example
ParseAPISet up ParseAPI
Install the SDK in your application and set PARSEAPI_KEY in your server environment.
This Python web app uses Flask for its form and request handler.
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install parseapi==1.12.0
python3 -m pip install Flask==3.1.3
export PARSEAPI_KEY='YOUR_SECRET_API_KEY'Create a client and reuse it for the calls below:
from parseapi import ParseAPI
parse = ParseAPI()Close the client when your workflow ends, or use with ParseAPI() as parse: to close it automatically.
Put currency conversion in a web form
Build a Python page where someone enters an amount, selects two currencies and chooses a date. The server fetches the reference rate, calculates with Decimal, and shows both the converted amount and the rate's actual publication date.
The complete app uses Flask for the form and template. The secret ParseAPI key stays on the server.
1. Read the amount, pair and date
Keep the amount as decimal text until it becomes a Decimal. The example accepts up to nine whole digits and four decimal places, including zero and negative amounts. It offers a short list of supported currencies and rejects malformed or future dates before making a request.
Flask renders the current value into the amount field:
<label for="amount">Amount</label>
<input id="amount" name="amount" inputmode="decimal" value="{{ values.amount }}"
maxlength="15" aria-describedby="amount-error"
aria-invalid="{{ 'true' if errors.amount else 'false' }}" required>When the form posts to /, the handler reads these named inputs:
values = {
"amount": request.form.get("amount", ""),
"base": request.form.get("base", ""),
"quote": request.form.get("quote", ""),
"day": request.form.get("day", ""),
}Re-render the submitted values when a field needs correction. The app also checks a session-bound CSRF token before requesting a rate.
2. Fetch the dated reference rate
Use the existing SDK operation for the selected pair:
exchange = parse.currency.rate(base, quote, date=day.isoformat())The rate can be dated earlier than the requested day. Weekends and holidays use the most recent published rate on or before it; missing history remains an error rather than falling back to today's rate.
Ask Currency for the target's usual decimal precision:
currency = parse.currency(quote)This second lookup supplies a fact needed to round the displayed amount. Zero digits is valid, as with JPY. Missing precision leaves conversion unavailable instead of guessing two places.
3. Calculate with the returned facts
Read the multiplier, applied date and precision:
rate = Decimal(str(exchange["rate"]))
rate_date = exchange["date"]
digits = currency["digits"]Decimal(str(...)) takes the rate supplied by the SDK into decimal arithmetic. The original form amount also becomes a Decimal, so multiplication does not use binary floating-point amounts.
Round once, at the target currency's precision:
converted = (amount * rate).quantize(
Decimal(1).scaleb(-digits), rounding=ROUND_HALF_EVEN
)ROUND_HALF_EVEN resolves an exact tie to the nearest even final digit. Choose the rounding policy your application needs. The complete page formats that value and displays its currency and rate_date beside it.
4. Keep the result attached to its inputs
The result names the original amount and currency, the target currency, the multiplier and the published rate date. A zero result remains visible. Invalid input, unavailable history, request errors and malformed responses produce an error state with the person's entries still in the form.
The handler passes result into the page template, where the converted value and applied rate date stay together:
<p class="amount">{{ result.converted }} {{ result.quote }}</p>
<p>Rate: {{ result.rate }} · Published {{ result.rate_date }}</p>These are reference estimates. Card charges, fees and settlement rates can differ. See the Currency reference for rate coverage.
The complete app reuses one SDK client per submitted conversion, with a ten-second timeout and no automatic retries. A failed first request prevents the second lookup.
The example generates a session secret when it starts. Use a stable APP_SECRET when adapting it to your own deployment. Currency selection here belongs to this conversion; the currency picker teaches an app-wide preference.
Complete example
Use the complete implementation as a reference when adding this feature to your app. It includes the validation and failure handling around the calls above.
View and copy the complete source
"""A local web form for dated reference-currency conversions."""
from datetime import date, datetime, timezone
from decimal import Decimal, InvalidOperation, ROUND_HALF_EVEN, localcontext
import hmac
import math
import os
import re
import secrets
import httpx
from flask import Flask, render_template_string, request, session
from parseapi import ParseAPI, ParseAPIError
app = Flask(__name__)
app.secret_key = os.environ.get("APP_SECRET") or secrets.token_hex(32)
app.config.update(MAX_CONTENT_LENGTH=8192, SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax", TRUSTED_HOSTS=["127.0.0.1", "localhost"])
CURRENCIES = ("USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "INR")
PAGE = """<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="data:,"><title>Currency converter</title><style>
:root{color-scheme:light dark;--bg:#f7f8fa;--panel:#fff;--text:#17191d;--muted:#595e68;--line:#d5d9e0;--accent:#2459d3;--error:#a51d2d}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
main{width:min(100% - 40px,640px);margin:72px auto}h1{font-size:clamp(2rem,6vw,2.8rem);line-height:1.12;letter-spacing:-.045em;margin:0 0 18px}h2{font-size:1rem;margin:0 0 12px}
p{margin:0 0 20px}.intro,.note{color:var(--muted)}form,.result{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:28px;margin-top:28px}.row{display:grid;grid-template-columns:1fr 1fr;gap:18px}
label{display:block;font-size:.9rem;font-weight:600;margin-bottom:8px}input,select,button{font:inherit;min-height:46px;border-radius:9px}input,select{width:100%;min-width:0;color:var(--text);background:var(--panel);border:1px solid var(--line);padding:10px 12px}
select{appearance:none;padding-right:36px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='none' stroke='%23777' stroke-width='1.5'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E");background-position:right 12px center;background-repeat:no-repeat}
.field{margin-bottom:20px}button{background:var(--accent);color:#fff;border:0;padding:11px 22px;font-weight:600;cursor:pointer}button:hover{filter:brightness(.93)}:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
.error{color:var(--error);font-size:.9rem;margin:8px 0 0}[aria-invalid=true]{border-color:var(--error)}.alert{padding:16px;border:1px solid var(--error);border-radius:10px;margin-top:24px;color:var(--error)}.amount{font-size:clamp(1.8rem,6vw,2.7rem);font-weight:650;letter-spacing:-.035em;overflow-wrap:anywhere;margin-bottom:8px}.note{font-size:.9rem;margin:18px 0 0}
@media(max-width:440px){main{margin:40px auto}.row{grid-template-columns:1fr;gap:0}form,.result{padding:22px}}
@media(prefers-color-scheme:dark){:root{--bg:#111316;--panel:#1b1e23;--text:#f3f4f6;--muted:#b3b8c3;--line:#444a55;--accent:#759cff;--error:#ff9ca8}button{color:#111316}}
</style></head><body><main><h1>Currency converter</h1><p class="intro">Convert an amount using the reference rate for your chosen date.</p>
{% if error %}<p class="alert" role="alert">{{ error }}</p>{% endif %}
<form method="post" action="/">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div class="field">
<!-- example:form -->
<label for="amount">Amount</label>
<input id="amount" name="amount" inputmode="decimal" value="{{ values.amount }}"
maxlength="15" aria-describedby="amount-error"
aria-invalid="{{ 'true' if errors.amount else 'false' }}" required>
<!-- /example:form -->
<p class="error" id="amount-error">{{ errors.get('amount', '') }}</p></div>
<div class="row">{% for field,label in [('base','From'),('quote','To')] %}<div class="field"><label for="{{ field }}">{{ label }}</label><select id="{{ field }}" name="{{ field }}"{% if errors[field] %} aria-invalid="true" aria-describedby="{{ field }}-error"{% endif %}>{% for code in currencies %}<option value="{{ code }}"{% if values[field]==code %} selected{% endif %}>{{ code }}</option>{% endfor %}</select>{% if errors[field] %}<p class="error" id="{{ field }}-error">{{ errors[field] }}</p>{% endif %}</div>{% endfor %}</div>
<div class="field"><label for="day">Rate date</label><input id="day" name="day" type="date" value="{{ values.day }}" max="{{ today }}" required{% if errors.day %} aria-invalid="true" aria-describedby="day-error"{% endif %}>{% if errors.day %}<p class="error" id="day-error">{{ errors.day }}</p>{% endif %}</div>
<button type="submit">Convert amount</button></form>
{% if result %}<section class="result" role="status" aria-live="polite"><h2>{{ result.amount }} {{ result.base }} equals</h2>
<!-- example:result -->
<p class="amount">{{ result.converted }} {{ result.quote }}</p>
<p>Rate: {{ result.rate }} · Published {{ result.rate_date }}</p>
<!-- /example:result -->
<p class="note">This is a reference estimate. Card charges, fees and settlement rates can differ.</p></section>{% endif %}
<p class="note">Weekends and holidays can use the last published rate on or before your chosen date.</p>
</main></body></html>"""
@app.after_request
def private_response(response):
response.headers["Cache-Control"] = "no-store"
response.headers["X-Content-Type-Options"] = "nosniff"
return response
@app.errorhandler(413)
def too_large(_error):
return "<h1>Request too large</h1><p>Go back and enter a shorter amount.</p>", 413
@app.route("/", methods=["GET", "POST"])
def index():
today = datetime.now(timezone.utc).date().isoformat()
values = {"amount": "12.50", "base": "EUR", "quote": "USD", "day": today}
errors, error, result, status = {}, None, None, 200
token = session.setdefault("csrf", secrets.token_urlsafe(32))
if request.method == "POST":
# example:inputs
values = {
"amount": request.form.get("amount", ""),
"base": request.form.get("base", ""),
"quote": request.form.get("quote", ""),
"day": request.form.get("day", ""),
}
# /example:inputs
submitted = request.form.get("csrf", "")
if not submitted.isascii() or not hmac.compare_digest(submitted, token):
error, status = "This form expired. Refresh the page and try again.", 403
else:
amount_text = values["amount"].strip()
if not re.fullmatch(r"-?(?:0|[1-9][0-9]{0,8})(?:\.[0-9]{1,4})?", amount_text):
errors["amount"] = "Enter an amount with up to nine whole digits and four decimal places."
for field in ("base", "quote"):
if values[field] not in CURRENCIES:
errors[field] = "Choose one of the listed currencies."
try:
day = date.fromisoformat(values["day"])
if day.isoformat() != values["day"] or values["day"] > today:
raise ValueError()
except ValueError:
errors["day"] = "Choose a real date on or before today."
if errors:
status = 422
else:
key = os.environ.get("PARSEAPI_KEY", "").strip()
if not key or key.startswith(("parse_public_", "parse_app_")):
error, status = "The converter is unavailable. Please try again later.", 503
else:
base, quote = values["base"], values["quote"]
try:
# One interactive attempt, with one reused official SDK client.
with ParseAPI(api_key=key, timeout=10.0, retries=0) as parse:
# example:request
exchange = parse.currency.rate(base, quote, date=day.isoformat())
# /example:request
if (not isinstance(exchange, dict) or exchange.get("base") != base
or exchange.get("quote") != quote
or type(exchange.get("rate")) not in (int, float)
or not math.isfinite(exchange["rate"]) or exchange["rate"] <= 0):
raise ValueError("Unexpected rate response.")
applied = date.fromisoformat(exchange.get("date", ""))
if applied.isoformat() != exchange["date"] or applied > day:
raise ValueError("Unexpected rate date.")
# example:precision
currency = parse.currency(quote)
# /example:precision
if (not isinstance(currency, dict) or currency.get("currency") != quote
or type(currency.get("digits")) is not int or not 0 <= currency["digits"] <= 6):
raise ValueError("Currency precision is unavailable.")
# example:fields
rate = Decimal(str(exchange["rate"]))
rate_date = exchange["date"]
digits = currency["digits"]
# /example:fields
amount = Decimal(amount_text)
with localcontext() as context:
context.prec = 50
# example:convert
converted = (amount * rate).quantize(
Decimal(1).scaleb(-digits), rounding=ROUND_HALF_EVEN
)
# /example:convert
if converted == 0:
converted = abs(converted)
result = {"amount": format(amount, "f"), "base": base, "quote": quote,
"converted": format(converted, "f"), "rate": format(rate, "f"),
"rate_date": rate_date}
except ParseAPIError as failure:
if failure.status == 404:
error, status = "No reference rate is available for this pair and date. Try another date.", 422
else:
error, status = "The rate could not be checked. Please try again later.", 503
except httpx.RequestError:
error, status = "The rate could not be checked. Please try again later.", 503
except (ValueError, InvalidOperation, OverflowError, TypeError):
error, status = "The rate response could not be used. Please try again later.", 503
return render_template_string(PAGE, values=values, errors=errors, error=error, result=result,
currencies=CURRENCIES, today=today, csrf=token), status
if __name__ == "__main__":
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "8000")), debug=False, use_reloader=False)
Try the complete example on your computer
After completing the setup above, save the source in your application folder and start the app:
python3 python-currency-converter.python.pyOpen 127.0.0.1:8000 to use the form. The server keeps your secret key out of the page.
For every field and parameter, see Currency API reference.