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.
Baca →Bangun kalkulator harga perhiasan emas dari harga per-gram karat live, dengan matematika desimal presisi, caching, margin, dan batasan valuasi yang jelas.
Toko emas, pegadaian, dan penjual ecommerce semuanya memerlukan alat kecil yang sama: masukkan berat dan karat, dapatkan nilai material sebagai hasilnya. Post ini membangun alat tersebut dari ujung ke ujung, dari harga per-gram live hingga UI di browser, menggunakan JavaScript murni.
Hasilnya adalah nilai material: berapa nilai kandungan emas dari sebuah perhiasan pada harga spot saat ini, sebelum ditambahkan hal lain. Ini bukan appraisal, bukan nilai jual kembali yang dijamin, dan bukan angka yang benar-benar akan dibayarkan pembeli.
Yang sepenuhnya tidak dimasukkan:
Setiap angka yang dihasilkan kalkulator ini harus disertai baris yang memberi tahu pengguna hal itu. Bangun disclaimer ke dalam UI, bukan hanya di dokumentasi.
Endpoint /v1/carat milik API mengembalikan harga per-gram untuk delapan kemurnian karat umum dalam satu panggilan, tanpa API key yang diperlukan:
GET https://api.goldprice.dev/v1/carat?currency=USD
Respons sungguhan terlihat seperti ini:
{
"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"
}
Dua detail penting untuk kode di bawah. Setiap field harga adalah string, bukan angka, sehingga digit desimal yang tepat bertahan melewati round trip JSON. Dan currency menerima salah satu dari 31 mata uang yang didukung API, sehingga endpoint yang sama mencakup USD, EUR, INR, atau IDR hanya dengan mengubah satu query parameter.
Data upstream itu sendiri memperbarui pada kadensi yang serupa, jadi melakukan polling di setiap page load tidak memberi keuntungan apa pun dan menambah latensi. Server Node kecil dengan cache in-memory ditempatkan di depannya:
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);
Ini node:http murni, tidak perlu framework untuk satu endpoint saja. Penanganan kegagalan sama pentingnya dengan happy path: respons upstream non-200 atau body yang malformed akan throw, dan getCarat jatuh kembali ke nilai cache terakhir jika ada dan masih dalam jendela TTL. Hanya ketika sama sekali tidak ada cache barulah error tersebut sampai ke client, sebagai 502.
Jangan memanggil api.goldprice.dev langsung dari JavaScript browser. API ini secara sengaja tidak memiliki permukaan CORS browser, by design, sehingga fetch dari kode sisi client akan gagal. Arahkan setiap request melalui endpoint server Anda sendiri, yang juga membuat cache dibagikan ke semua pengunjung alih-alih satu per tab browser.
Rumus intinya adalah satu perkalian: weight_grams × per_gram_karat_price. Persoalannya, kedua operand harus tetap dalam aritmetika desimal presisi, karena perkalian floating-point pada nilai uang bergeser (drift):
console.log(0.1 + 0.2); // 0.30000000000000004
API sudah memberi Anda string desimal presisi. Perbaikannya adalah menyimpannya sebagai integer dalam minor unit (sen) alih-alih mengonversinya ke Number lalu kembali:
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);
}
Tidak ada dependency big-number di sini, hanya BigInt, yang sudah dibawa native oleh setiap runtime JS saat ini. Berat masuk sebagai float biasa (timbangan toko emas membaca hingga 0.01g atau 0.001g), jadi dikonversi ke integer miligram sebelum perkalian, dan pembagian kembali ke bawah terjadi terakhir, setelah perkalian, sehingga nilai antara tetap presisi. Perhatikan bahwa pembagian integer memotong (truncate) alih-alih membulatkan; pilih aturan pembulatan yang eksplisit untuk minor unit mata uang Anda sebelum ini masuk ke produksi, dengan cara yang sama seperti API itu sendiri membulatkan setiap harga karat ke langkah desimal mata uang tersebut.
Nilai material dari langkah 3 adalah angka turunan-spot. Itu bukan yang seharusnya dikutip sebuah toko kepada pelanggan. Dua input lagi termasuk di atasnya, dan itu harus berupa item baris terpisah, jangan pernah dilipat ke dalam angka spot itu sendiri:
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,
};
}
Menjaga ketiganya sebagai tiga field terpisah alih-alih satu total campuran adalah yang membuat kalkulator ini bisa diaudit. Seorang pelanggan, atau regulator, harus bisa melihat nilai material turunan-spot pada barisnya sendiri, terpisah dari apa yang dipilih toko untuk ditambahkan.
Sisi browser memanggil server Anda, tidak pernah API upstream:
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);
});
});
Setiap hasil yang ditampilkan UI perlu disertai timestamp dan mata uang di sampingnya, dan satu baris teks disclaimer tetap di bawahnya: ini adalah estimasi nilai material, bukan appraisal, dan tidak termasuk batu, ongkos pembuatan, pajak, dan kondisi. Taruh kalimat itu di template sekali saja, bukan di tooltip yang bisa terlewat seseorang.
Kegagalan memiliki tiga bentuk di batas server, dan kode di atas sudah menangani ketiganya:
fetchCarat, jatuh kembali ke cachetypeof data.price_gram_24k !== "string" menangkap body yang malformed sebelum di-cache atau disajikanKetika ada entri cache yang segar, tidak ada satu pun dari ini yang sampai ke pengguna; mereka mendapatkan nilai baik terakhir dengan timestamp sungguhannya. Ketika tidak ada cache yang bisa dipakai, client mendapatkan state error yang jelas alih-alih kalkulasi yang rusak atau nol yang diam-diam.
Endpoint anonim yang digunakan sepanjang post ini cukup untuk pengembangan dan pengujian. Penggunaan komersial atas data API mentah (dalam produk yang Anda kenakan biaya, atau yang mendorong pendapatan) memerlukan lisensi paket Pro atau lebih tinggi. Jika yang Anda perlukan hanya harga live yang ditampilkan di sebuah halaman, bukan data mentah untuk dihitung, widget embeddable resmi gratis untuk penggunaan tampilan komersial dan tidak memerlukan API key.
panduan terkait
Build a reproducible local-currency gold backtest from settled XAU/USD bars and dated FX observations, without inserting future rates into historical rows.
Baca →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).
Baca →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.
Baca →goldprice.dev
Harga emas real-time, OHLC historis, dan agregasi multi-sumber — tersedia via REST dan SSE.