Skip to content

openrate

openrate

Open, ZAR-anchored exchange rates — the open way.

Docs · API · Configuration · Go library · Graph model · Accuracy · Changelog · Roadmap

Current release: v0.2.0

MIT OR Apache-2.0 · Go 1.25+ · no API key · self-hostable


openrate is an open-source exchange-rate engine. It ingests rates "the open way" — from central-bank reference files and free public venue feeds, not by reselling a paid API — models every currency as a graph rather than picking a single canonical base, and serves an all-pairs JSON API plus a single hand-written, dependency-free embedded UI from a single Go binary.

The openrate converter: 100 USD converted to ZAR, with a B grade seal and the six quality facets — confidence, freshness, directness, source class and corroboration — read out beneath the number.

Most rate APIs hand you a number and ask you to trust it. openrate hands you the walk it took through the currency graph, the individual source quotes behind every hop, how far apart those sources are, and a grade for the lot:

The openrate rates board against a ZAR base, sorted by path length so the directly-quoted pairs float to the top: a grade, hop count and age per currency, with the EUR row expanded to reveal its graph path and the two sources quoting it, 9.6 basis points apart.

Both screenshots are real captures of the running engine against live source data. More on the site.

Why a graph, not a base

Most rate APIs pick one base currency (usually EUR/USD) and derive everything through it. openrate keeps each source's quotes in their native base (ECB in EUR, SARB in ZAR, …) as edges in a currency graph. Any pair is the product of rates along the shortest path between them, so:

  • ZAR is the anchor for free — it's just the default presentation base, a view over the same graph (?base=ZAR, or any other).
  • Directly quoted pairs win — BFS reaches a pair by the fewest hops first, so a direct quote always beats a triangulated cross.
  • No single point of contamination — a bad edge only affects paths through it, not every pair.
  • Provenance on every number — each rate carries hops, as_of, and age, so consumers see exactly how stale it is (it matters: fiat is frozen on weekends).
  • The working is checkable — a pair's rate is the product of its legs exactly, bit for bit, so you can recompute it from the response. That holds at full precision only: round the legs and the rate for display and they stop agreeing in the last place, which is why the UI prints the residual rather than implying there isn't one. See the graph model.

Run

go run ./cmd/openrate            # serves :8080, base ZAR, hourly refresh
# or
go build -o openrate ./cmd/openrate && ./openrate -addr :8080 -base ZAR -refresh 1h

Config via flags or env: OPENRATE_ADDR, OPENRATE_BASE, OPENRATE_REFRESH, OPENRATE_SOURCES, OPENRATE_RATELIMIT. Full reference: docs/configuration.md.

With Docker:

docker build -t openrate . && docker run -p 8080:8080 openrate

Verify a release before you run it

Releases publish a source archive plus a SHA256SUMS manifest covering every published asset, and a sigstore build-provenance attestation minted from the release workflow's OIDC identity (no long-lived signing key exists, so there is none to leak or rotate). scripts/verify.sh is what you run against them:

curl -fsSLO https://raw.githubusercontent.com/vul-os/openrate/v0.2.0/scripts/verify.sh
bash verify.sh --tag v0.2.0 --attest openrate_0.2.0_source.zip

It fetches the manifest, looks up the exact entry for the asset (names are matched as strings, not as regexes) and compares digests. It has two outcomes: verified, or non-zero with a diagnostic naming what was wrong — a missing or malformed manifest, a missing entry, a truncated download, a digest mismatch, an HTML error page served where bytes were expected. There is no --skip-verify, and a SHA256SUMS that 404s is a failure, never "nothing to check". --attest additionally verifies the provenance (needs the gh CLI); leave it off and the script says out loud that provenance was not checked, so a pass never implies more than it checked.

bash scripts/verify.sh --selftest runs 24 synthetic-origin cases asserting that each refusal still fires; CI runs it on every push.

Embed as a Go library

Instead of running the binary, import the root package and run the engine in-process — no subprocess, same store/sources/API/hardening as cmd/openrate:

import "github.com/vul-os/openrate"

local, err := openrate.Start(openrate.Options{}) // ZAR base, hourly refresh, ephemeral port
if err != nil {
	log.Fatal(err)
}
defer local.Close()

resp, _ := http.Get(local.APIBaseURL() + "/rates") // or local.BaseURL + "/healthz"

Options mirrors the binary's flags (Addr, Base, Refresh, Sources, RateLimit, ServeUI). Start returns once /healthz is serving. The engine's building blocks stay under internal/; this package is the supported public API.

Deployment modes

openrate ships as sovereign, self-contained infrastructure — run it two ways, both fully open, keyless, and free:

Shape How
Self-hosted binary go run ./cmd/openrate — keyless, all sources, hourly refresh
Embedded Go library openrate.Start(...) in-process — the same engine, no subprocess

API

Endpoint Description
GET /api/v1/rates?base=ZAR All currencies vs base; rate reads "1 base = rate CCY"
GET /api/v1/convert?from=USD&to=ZAR&amount=100 Convert, with rate provenance
GET /api/v1/meta Sources, freshness, currency list
GET /healthz Liveness

Every rate includes hops, as_of, age_sec, the path and sources, plus a quality block (grade A–D + confidence) — see below. Full request/response shapes: docs/api.md.

Interest rates (optional engine)

A separate, flat time-series engine (no currency graph) for central-bank policy and reference rates worldwide. Enable with -interest-sources (binary) or Options{Interest: true} (library). Served alongside the FX API:

Endpoint Description
GET /api/v1/interest/rates?area=US&type=policy Latest value per series + confidence grade
GET /api/v1/interest/series?id=us.policy One series with full history (timeseries)
GET /api/v1/interest/meta Areas covered, series catalogue, source status

Out of the box (bis,sarbrates, no keys) this covers 48 central banks' policy rates with daily history plus the South African ZARONIA family; set OPENRATE_FRED_API_KEY to auto-enable US benchmark series. Each series carries an interest-tuned quality grade. See docs/interest-rates.md.

Accuracy

Every price carries a quality assessment so you know how much to trust it:

"quality": {
  "grade": "B", "confidence": 0.89,
  "freshness": "realtime", "directness": "direct", "source_class": "exchange",
  "corroboration": { "sources": 4, "spread_bps": 29, "agree": true }
}

The grade combines freshness (edge age), directness (hop count), source authority (official > exchange > aggregator > unofficial), cross-source agreement (spread in bps), and per-currency caveats (e.g. NGN/EGP/CNY official-vs-parallel-rate flags). Full model: ACCURACY.md. The web UI shows the grade badge in the converter and, per row, a "show the working" panel with the path, sources and spread that produced it — there is no separate in-app Accuracy page.

Sources

Selectable with -sources (or OPENRATE_SOURCES). Default: ecb,coinbase,luno,sarb.

Source Default Cadence Notes
ECB daily file daily EUR-base, ~30 currencies, ~16:00 CET
Coinbase real-time free/no-auth fiat (incl. ZAR) + crypto — best open intraday source
Luno real-time SA exchange, live BTC/ETH/USDT vs ZAR; bridges to fiat via BTC
SARB daily authoritative ZAR (per USD/GBP/EUR/JPY); slow host → bounded dialer + retries
Frankfurter opt-in daily clean JSON ECB mirror
open.er-api opt-in daily incl. weekends fills the ECB Fri→Mon gap
fawazahmed0 opt-in daily ~400 currencies, dual-CDN, no limits
Bank of Canada opt-in daily Valet REST, independent cross-check
Yahoo Finance opt-in ~1 min unofficial, ToS-prohibited, rate-limited — last resort

Four further sources are key-gated and auto-enable when their API key is present: Open Exchange Rates, Twelve Data, Polygon.io and TraderMade. They are not open data, so they are off unless you bring a key — see .env.example and SOURCES.md.

Because the graph prefers the freshest direct edge, USD→ZAR resolves to the live Coinbase quote (~seconds old) while EUR/GBP/JPY→ZAR resolve to SARB's authoritative direct quotes — each chosen automatically, no special-casing. Add a source by implementing sources.Source and registering it in internal/sources/registry.go. Full catalog + freshness notes: SOURCES.md.

Web UI

The interface is a single hand-written HTML document (web/ui.html) embedded in the binary via go:embed — inline CSS and JS, vanilla, no build step, no npm, no bundler. It has the converter (with the live grade badge and a "show the working" panel: graph path, hops, sources, spread, and — on a triangulated pair — the displayed legs multiplied out against the displayed rate, residual included) and a sortable, filterable board of every pair with its grade, in a dark theme and a light one that follow the system preference or a manual toggle. There is no in-binary docs viewer and no policy/interest-rate UI — /api/v1/interest/* still works, it just has no page of its own right now.

go run ./cmd/openrate    # then open http://localhost:8080 — the UI is served at "/"

Editing the UI means editing web/ui.html directly; there is nothing to regenerate. See docs/web-ui.md.

Layout

openrate.go       public package: embed the engine in-process (Start/Close)
cmd/openrate      entrypoint: wires sources -> store -> api + UI
internal/graph    currency graph, BFS all-pairs materialization
internal/sources  pluggable FX sources (ecb, coinbase, luno, sarb, … all live)
internal/store    ingest loop + snapshot store
internal/quality  the grade/confidence model attached to every rate
internal/api      JSON read endpoints
internal/ratesapi policy-rate endpoints, /api/v1/interest/*
web               web/ui.html — the embedded UI (plain HTML/CSS/JS, no build step, embedded via go:embed)
site              the static site: landing, docs viewer, generated site/docs
site/gen          regenerates site/docs from the canonical docs (CI-gated), and
                  re-derives the landing's cross-rate arithmetic from its own digits
scripts           release verifier + the landing's screenshot gate (both self-testing)

Gates

Every check below fails closed and each has a mode that proves it still refuses, because a guard that has quietly stopped failing looks exactly like a healthy build:

Gate Protects Selftest
bash scripts/verify.sh --selftest a release artifact is the published bytes 24 synthetic-origin failures
go test ./site/gen site/docs is generated and link-clean; the landing's §02 arithmetic is what its own printed digits produce, residual included coverage floors on every scan
go test ./internal/graph a pair's rate is the product of its legs bit-for-bit, and the displayed legs agree only within display rounding fails if the fixture drifts to values where rounding happens to land
node scripts/check-shots.mjs --selftest every capture the landing displays is its display box's shape, and at least 2x sampled 5 deliberate breakages (crop, blur, 404, stale attrs, empty selector)

Documentation

Full documentation lives in docs/.

Guide What's inside
API reference Every endpoint, params, and full response shapes
Configuration Flags, env vars, and the source spec
Go library Embed the engine in-process with Start/Close
Graph model Why currencies are a graph, not a base
Accuracy & quality The grade/confidence model behind every rate
Sources Full source catalog, cadence, and provenance
Web UI The embedded, dependency-free HTML UI (converter + rates board)

Brand

The mark in brand/ is the source of truth. Every icon this repo ships — favicon, PWA and app icons, the mark in the README and on the site — is rendered from brand/logo.svg rather than redrawn, so there is one approved drawing and no second copy to drift.

Copy it outward, never edit a derived copy, and never edit brand/ to match something downstream.

License

MIT OR Apache-2.0 — © VulOS. openrate is a VulOS project; source and issues at github.com/vul-os/openrate.

Third-party notices

openrate redistributes third-party software: the Go standard library and any Go modules compiled into the binary, plus the browser assets vendored — as committed files, not npm packages — into the marketing site: the Geist Sans and Geist Mono webfonts under site/assets/fonts/ (whose OFL-1.1 licence must travel with the shipped .woff2 files) and the highlight.js/marked bundles under site/assets/vendor/. The embedded UI (web/ui.html) ships zero npm-derived code and no webfonts — system font stacks only — so there is nothing of its own to attribute. Their licences (MIT, BSD, Apache-2.0, OFL-1.1) require the copyright notice and licence text to accompany every copy.

  • THIRD-PARTY-NOTICES.txt — name, version, licence and full text for every component. Generated from the real dependency graph by scripts/gen-notices.sh (Go: go-licence-detector; fonts and vendored JS: read directly off the committed files), never hand-edited.
  • Both the binary and the marketing site serve it at /licenses.txt: the binary embeds a physical copy at web/THIRD-PARTY-NOTICES.txt (web.Licenses, linked from the UI topbar; kept byte-identical to the root file by a test), and the site serves its own copy the same way (linked from its footer).
  • Vendored site bundles carry their upstream licence next to them, e.g. site/assets/vendor/marked.umd.js.LICENSE.

vulos
vulos — open by design

About

Open, ZAR-anchored exchange-rate engine — a currency graph over central-bank and free public feeds, served from a single Go binary.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages