Backtest local-currency gold without FX look-ahead bias
Build a reproducible local-currency gold backtest from settled XAU/USD bars and dated FX observations, without inserting future rates into historical rows.
Read →Build a gold jewelry price calculator from live per-gram karat prices, with exact decimal math, caching, margins, and clear valuation limits.
Jewelers, pawn shops, and ecommerce sellers all need the same small tool: type in a weight and a karat, get back a material value. This post builds that tool end to end, from the live per-gram price to a browser UI, using plain JavaScript.
The output is a material value: what the gold content of a piece is worth at the current spot price, before anything else gets added. It is not an appraisal, not a guaranteed resale value, and not what a buyer will actually pay.
Left out entirely:
Every number this calculator produces should carry a line telling the user that. Build the disclaimer into the UI, not just the docs.
The API's /v1/carat endpoint returns per-gram prices for eight common karat purities in one call, no API key required:
GET https://api.goldprice.dev/v1/carat?currency=USD
A real response looks like this:
{
"currency": "USD",
"timestamp": "2026-07-16T18:12:06.394451Z",
"price_gram_24k": "128.24",
"price_gram_22k": "117.55",
"price_gram_21k": "112.21",
"price_gram_20k": "106.86",
"price_gram_18k": "96.18",
"price_gram_16k": "85.49",
"price_gram_14k": "74.80",
"price_gram_10k": "53.43"
}
Two details matter for the code below. Every price field is a string, not a number, so the exact decimal digits survive the JSON round trip. And currency accepts any of the 31 currencies the API supports, so the same endpoint covers USD, EUR, INR, or IDR by changing one query parameter.
The upstream data itself updates on a similar cadence, so polling it on every page load buys nothing and adds latency. A small Node server with an in-memory cache sits in front:
import { createServer } from "node:http";
const UPSTREAM = "https://api.goldprice.dev/v1/carat";
const TTL_MS = 60_000;
const cache = new Map(); // currency -> { data, fetchedAt }
async function fetchCarat(currency) {
const res = await fetch(`${UPSTREAM}?currency=${encodeURIComponent(currency)}`);
if (!res.ok) {
throw new Error(`upstream ${res.status}`);
}
const data = await res.json();
if (typeof data.price_gram_24k !== "string") {
throw new Error("malformed upstream response");
}
return data;
}
async function getCarat(currency) {
const cached = cache.get(currency);
const now = Date.now();
if (cached && now - cached.fetchedAt < TTL_MS) {
return cached.data;
}
try {
const data = await fetchCarat(currency);
cache.set(currency, { data, fetchedAt: now });
return data;
} catch (err) {
if (cached) {
return cached.data; // stale-if-error: serve last known value
}
throw err;
}
}
const server = createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
if (url.pathname !== "/api/carat") {
res.writeHead(404).end("not found");
return;
}
const currency = (url.searchParams.get("currency") || "USD").toUpperCase();
try {
const data = await getCarat(currency);
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(data));
} catch (err) {
res.writeHead(502, { "content-type": "application/json" }).end(
JSON.stringify({ error: "carat_unavailable" })
);
}
});
server.listen(3001);
This is plain node:http, no framework needed for a single endpoint. The failure handling matters as much as the happy path: a non-200 upstream response or a malformed body throws, and getCarat falls back to the last cached value if one exists and is still within the TTL window. Only when there is no cache at all does the error reach the client, as a 502.
Do not call api.goldprice.dev directly from browser JavaScript. The API deliberately has no browser CORS surface, by design, so a fetch from client-side code will fail. Route every request through your own server endpoint, which also keeps the cache shared across all visitors instead of one per browser tab.
The core formula is one multiplication: weight_grams × per_gram_karat_price. The catch is that both operands need to stay in exact decimal arithmetic, because floating-point multiplication of money values drifts:
console.log(0.1 + 0.2); // 0.30000000000000004
The API already gives you exact decimal strings. The fix is to keep them as integers in minor units (cents) instead of converting to Number and back:
function toMinorUnits(decimalString, decimals = 2) {
const [whole, frac = ""] = decimalString.split(".");
const paddedFrac = (frac + "0".repeat(decimals)).slice(0, decimals);
const sign = whole.startsWith("-") ? -1n : 1n;
const wholeAbs = whole.replace("-", "");
return sign * (BigInt(wholeAbs) * 10n ** BigInt(decimals) + BigInt(paddedFrac || "0"));
}
function fromMinorUnits(minorUnits, decimals = 2) {
const negative = minorUnits < 0n;
const abs = negative ? -minorUnits : minorUnits;
const divisor = 10n ** BigInt(decimals);
const whole = abs / divisor;
const frac = (abs % divisor).toString().padStart(decimals, "0");
return `${negative ? "-" : ""}${whole}.${frac}`;
}
function materialValue(weightGrams, perGramPriceString) {
const priceMinor = toMinorUnits(perGramPriceString, 2); // cents
const weightMilligrams = BigInt(Math.round(weightGrams * 1000)); // 3dp precision
const totalMinor = (priceMinor * weightMilligrams) / 1000n;
return fromMinorUnits(totalMinor, 2);
}
No big-number dependency here, just BigInt, which every current JS runtime ships natively. Weight comes in as a plain float (a jeweler's scale reads to 0.01g or 0.001g), so it gets converted to integer milligrams before the multiplication, and the division back down happens last, after the multiply, so intermediate values stay exact. Note that integer division truncates rather than rounds; pick an explicit rounding rule for your currency's minor unit before this ships to production, the same way the API itself rounds each karat price to the currency's own decimal step.
The material value from step 3 is a spot-derived number. It is not what a shop should quote a customer. Two more inputs belong on top of it, and they belong as separate line items, never folded into the spot number itself:
function quoteBreakdown(materialValueMinor, marginPercent, makingChargeMinor) {
const marginMinor = (materialValueMinor * BigInt(Math.round(marginPercent * 100))) / 10000n;
return {
material: materialValueMinor,
margin: marginMinor,
makingCharge: makingChargeMinor,
total: materialValueMinor + marginMinor + makingChargeMinor,
};
}
Keeping these as three fields instead of one blended total is what makes the calculator auditable. A customer, or a regulator, should be able to see the spot-derived material value on its own line, separate from what the shop is choosing to add.
The browser side calls your server, never the upstream API:
async function loadCarat(currency) {
const res = await fetch(`/api/carat?currency=${encodeURIComponent(currency)}`);
if (!res.ok) {
throw new Error(`carat request failed: ${res.status}`);
}
return res.json();
}
function estimateValue(weightGrams, karat) {
const karatKey = `price_gram_${karat}k`;
return loadCarat("USD").then((data) => {
const perGram = data[karatKey];
if (!perGram) {
throw new Error(`unsupported karat: ${karat}`);
}
return {
material: materialValue(weightGrams, perGram),
timestamp: data.timestamp,
currency: data.currency,
};
});
}
document.getElementById("calc-btn").addEventListener("click", () => {
const grams = Number(document.getElementById("weight").value);
const karat = Number(document.getElementById("karat").value);
estimateValue(grams, karat)
.then((result) => {
document.getElementById("result").textContent =
`${result.material} ${result.currency} as of ${result.timestamp}`;
})
.catch((err) => {
document.getElementById("result").textContent =
"Price unavailable right now. Try again shortly.";
console.error(err);
});
});
Every result the UI shows needs the timestamp and currency next to it, and a fixed line of disclaimer text below it: this is a material-value estimate, not an appraisal, and it excludes stones, workmanship, taxes, and condition. Put that sentence in the template once, not in a tooltip someone can miss.
Failure has three shapes at the server boundary, and the code above already handles all three:
fetchCarat, falls back to cachetypeof data.price_gram_24k !== "string" check catches a malformed body before it gets cached or servedWhen a fresh cache entry exists, none of this reaches the user; they get the last good value with its real timestamp. When there is no usable cache, the client gets a clear error state instead of a broken calculation or a silent zero.
The anonymous endpoint used throughout this post is fine for development and testing. Commercial use of the raw API data (in a product you charge for, or that drives revenue) requires Pro plan licensing or higher. If all you need is a live price displayed on a page, not raw data to compute against, the official embeddable widget is free for commercial display use and needs no API key.
related guides
Build a reproducible local-currency gold backtest from settled XAU/USD bars and dated FX observations, without inserting future rates into historical rows.
Read →The real-time gold streaming questions that come up on Reddit, answered: whether a gold WebSocket exists, how the goldprice.dev XAU and XAG tick stream works end to end, and which other APIs actually offer one (most metals APIs just poll).
Read →The gold-price-API errors developers hit on Stack Overflow, fixed: 401 auth, 400 invalid_symbol, 429 rate limits, CORS from the browser, reading the response, and pulling historical data.
Read →goldprice.dev
Live gold prices, historical OHLC, and multi-source aggregation — available via REST and SSE.