Skip to content

Latest commit

 

History

231 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

openrate

openrate

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

Docs · Quickstart · API · Configuration · Go library · 15 languages · Graph model · Accuracy · Changelog · Roadmap

Current release: v0.1.8

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


openrate is an open-source exchange-rate engine, and a Go library first. Import it and it computes; add a Refresher and it fetches; add serve and it answers HTTP too, with an embedded, dependency-free UI. It ingests rates "the open way" — from central-bank reference files and free public venue feeds, not by reselling a paid API — and models every currency as a graph rather than picking a single canonical base. The self-hosted binary is one deployment of the library, not the only way to run it: import it. The server is optional. The UI is optional.

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.

Import it

import (
	"context"

	"github.com/vul-os/openrate"
	"github.com/vul-os/openrate/fxsource"
)

e := openrate.NewEngine(openrate.EngineOptions{}) // starts nothing, opens no socket
c, err := e.Convert("USD", "ZAR", 100)             // ErrUnknownPair until fed

r := openrate.NewRefresher(e, openrate.RefreshOptions{
	Sources: fxsource.Build("ecb,coinbase"),
})
go r.Run(context.Background()) // the ONLY line above that talks to the network

c, err = e.Convert("USD", "ZAR", 100) // now answers from what r fetched

NewEngine on its own is inert: no goroutine, no socket, no environment read, ever. A host can construct one behind a feature flag that is off and be certain the process is unchanged — see the Engine-without-a-Refresher example. That is not a claim, it is a counted number with a control — see docs/zero-network.md. Add a Refresher only when — and exactly when — the process should fetch, and serve.New (below) only when it should also answer HTTP. Full guide: docs/library.md.

Use it from fifteen languages

openrate ships a package for bun, C, C++, Deno, .NET, Elixir, Go, Java, Kotlin, Node, PHP, Python, Ruby, Rust and Swift — fifteen. All fifteen give you the sidecar; all but Elixir also give you an in-process path, behind the same API, so choosing again later is a constructor change rather than a rewrite.

What it is The right default for
Sidecar openrate runs as its own process on 127.0.0.1; the package starts it, waits until it is ready, and stops it for you .NET · bun · Deno · Elixir · Java · Kotlin · Node · PHP · Python
Direct the engine runs inside your process, over a C shared library — no port, no process, no socket C · C++ · Go
depends Ruby, where the answer turns on whether your server forks; Rust and Swift, whose packages present both as equally supported Ruby · Rust · Swift

Elixir is the one language with no direct mode, deliberately: a crash inside a NIF takes the BEAM down with it. Go is the one with no FFI at all — it imports the package.

Sidecar, in Python. The package spawns the process and returns only once it is ready, not merely alive:

import openrate
from openrate import Client

openrate.start(sources="ecb,coinbase", base_currency="ZAR")
with Client() as client:
    answer = client.convert("USD", "ZAR", 100)
    print(answer["result"], answer["rate"]["quality"]["grade"])

Direct, in C — the shape every other in-process binding wraps. Nothing here opens a socket, and nothing can be configured to:

char *err = NULL;
uint64_t eng = openrate_new("{\"base\":\"ZAR\"}", &err);   /* no thread, no socket */
openrate_free(openrate_call(eng, "load", edges_json, &err));
char *out = openrate_call(eng, "convert",
                          "{\"from\":\"USD\",\"to\":\"ZAR\",\"amount\":100}", &err);
puts(out);
openrate_free(out);
openrate_close(eng);

Both return the same JSON document /api/v1 publishes — one wire format for both modes, held by a test that asks one engine the same questions over each and requires the answers to be equal by value.

The split is enforced at the ABI, and that — not speed — is direct mode's argument. openrate_new builds an engine: no thread, no socket, no environment read. A refresher is a separate, explicit openrate_refresher_new, and an engine handle refuses "refresh" — checked in Go and again in C, through a really-dlopen'd library. "The feature is off, therefore nothing is sent" stops being a promise in a comment and becomes a property of the handle you hold — in all thirteen non-Go packages that offer a direct mode, because all thirteen call these same two constructors.

Direct mode is also faster — 3.7 µs per conversion against 33.5 µs over loopback, about 9× — but read that as a floor with an asterisk: if a conversion sits behind a database query or a model call, 30 µs is not an argument for anything. The costs are real and ffi/README.md leads with them: the Go runtime and its signal handlers move into your process, the library is not fork-safe (Python multiprocessing, uWSGI, Unicorn), building it needs cgo and a per-target C toolchain, and the artifact is 6–8 MB. Of the prebuilt targets only darwin/arm64 has ever been executed and no Windows DLL exists, so on Windows the sidecar is not a fallback — it is the mode. The sidecar needs only the openrate binary and has no such matrix.

Start at sdks/README.md — the index, with the reasoning behind each language's default. docs/quickstart.md has the one command that runs a working example in each of the fifteen; docs/c-abi.md is the ABI they are built on, and docs/deployment-modes.md puts both beside the other ways to run openrate.

There is deliberately no streaming API and no openrate_stream: a conversion is a graph lookup and a multiplication, complete the moment it is asked for. That is a design statement, not a gap.

Run it as a server instead

Importing the library is the primary way in; running the binary is the deployment option for when you want a standalone JSON API and web console instead of an in-process engine. Both are the same three pieces (Engine/Refresher/serve) — the binary just wires them together for you:

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.1.8/scripts/verify.sh
bash verify.sh --tag v0.1.8 --attest openrate_0.1.8_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.

Deployment modes

openrate ships as sovereign, self-contained infrastructure — run it however much of it you need, all fully open, keyless, and free:

Shape How
Compute only openrate.NewEngine(...) — no fetching, no serving; feed it with Load
Compute + fetch add openrate.NewRefresher(...) — still no HTTP, in-process only
Compute + fetch + HTTP add serve.New(...) — the JSON API, and the UI if Options.UI is set
Self-hosted binary go run ./cmd/openrate — all three, wired together, keyless, hourly refresh
Sidecar, from any language the same binary on 127.0.0.1, spawned and supervised by your language package
In-process, from any language libopenrate loaded over the C ABI — the first two rows, outside Go

The binary is the fourth row, not a different thing. There is also openrate.Start(...), the original all-in-one embedding call — it still works, but it is deprecated in favour of the three explicit pieces above; see docs/library.md for the migration.

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 — the process is up
GET /readyz Readiness — a conversion would succeed; 503 carries the source errors

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 fxsource.Source and registering it in fxsource/registry.go. Full catalog + freshness notes: SOURCES.md.

Web UI

The interface is a single hand-written HTML document (serve/web/ui.html) embedded via go:embed and mounted at / when Options.UI (binary) or serve.Options{UI: true} (library) is set — inline CSS and JS, vanilla, no build step, no npm, no bundler. A build tagged noui compiles the embed out of the binary entirely instead of merely not serving it; see docs/library.md for the measured size difference. 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 serve/web/ui.html directly; there is nothing to regenerate. See docs/web-ui.md.

Layout

openrate.go        public package: Engine (computes) + Refresher (fetches); Start (deprecated)
fx                  the pure core: currency graph, snapshot, accuracy model, Describe
fxsource            pluggable FX sources (ecb, coinbase, luno, sarb, … all live) + the only os.Getenv
cmd/openrate        entrypoint: wires Engine + Refresher + serve (+ UI) together
serve               the optional HTTP shell: JSON API, rate limiting, hardening
serve/web           serve/web/ui.html — the embedded UI; compiled out entirely under -tags noui
serve/interest      policy-rate endpoints, /api/v1/interest/*
serve/ratelimit     the rate limiter (buckets by network prefix: /64 v6, /32 v4)
ffi                 the C shared library other languages load (its own Go module,
                    named openrate-ffi so the internal/ wall applies to it too)
sdks                fifteen language packages (bun, c, cpp, deno, dotnet, elixir, go,
                    java, kotlin, node, php, python, ruby, rust, swift). All offer the
                    sidecar; all but elixir also offer direct. sdks/README.md is the index
embedtest           a second module that proves the library is embeddable from outside
internal/rates,     the interest-rate engine's own stack (rates, sources, store,
  ratesources,        quality) — serve-only, not part of the importable surface
  ratestore,
  ratequality
internal/redact     strips API keys that net/http echoes back into fetch errors
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 ./fx 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)
node scripts/check-contrast.mjs --selftest every muted text tier clears WCAG AA against all three page backgrounds in both themes, and every exception names a claim it still has to meet 6 mutations, including a stale exception and a scanner that stops matching
node scripts/check-contrast-rendered.mjs --selftest the same floor measured from composited pixels — the only gate that sees opacity, rgba() and alpha-resolving color-mix(), which the token reader above is blind to by construction 7 deliberate breakages, opening with an opacity fade that leaves every token untouched; plus a positive control, because a gate that refuses everything also refuses every mutation
bash scripts/check-ffi.sh --selftest the C ABI really loads, converts, and refuses a stale library; the shared library carries no console bytes 4 deliberate defects, each rebuilt via go build -overlay and required to be caught

Documentation

Full documentation lives in docs/.

Start here

Guide What's inside
Quickstart Five starting points: see it work, run it as a service, embed it in Go, call it from another language, or bring your own rates
Which mode should I choose Library, CLI, sidecar or C ABI — the decision, with measured cost and size for each
Troubleshooting Symptoms in the order they happen, and what each one actually means

Embedding

Guide What's inside
Go library Import Engine/Refresher directly — compute, fetch and serve as separate, opt-in steps
Language packages The index for all fifteen — which of the two modes each language should default to, and why
Use it from another language The C ABI those packages are built on, its honest costs, and what is actually prebuilt
Proving it sends nothing An Engine constructed with the feature off sends zero packets — counted, with a control, and held across the ABI too

Everything else

Guide What's inside
API reference Every endpoint, params, and full response shapes
Configuration Flags, env vars, and the source spec
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
Interest rates The optional policy/reference-rate engine — internal/ and serve-only
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 (serve/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 serve/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

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages