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.
阅读 →从实时的每克成色价格出发构建一个黄金首饰价格计算器,涵盖精确的十进制运算、缓存、利润率,以及清晰的估值边界。
珠宝商、当铺和电商卖家都需要同一个小工具:输入重量和成色,得到材料价值。本文用纯JavaScript从零到一构建这个工具,从实时的每克价格一直到浏览器界面。
它输出的是材料价值:这件首饰所含黄金按当前现货价格值多少钱,尚未加上任何其他成本。它不是鉴定评估,不是有保证的回收价,也不是买家实际会支付的金额。
完全不包含以下内容:
计算器给出的每一个数字都应该配上一行提示用户上述事实的说明。把这条免责声明做进界面本身,而不是只写在文档里。
API的 /v1/carat 端点会在一次调用中返回八种常见成色纯度的每克价格,无需API密钥:
GET https://api.goldprice.dev/v1/carat?currency=USD
真实响应看起来是这样:
{
"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"
}
对下面的代码来说,有两个细节很关键。每个价格字段都是字符串而非数字,这样精确的十进制数字才能在JSON往返中保持不变。而 currency 可以接受API支持的31种货币中的任意一种,因此只需改变一个查询参数,同一个端点就能覆盖USD、EUR、INR或IDR。
上游数据本身以类似的节奏更新,因此每次页面加载都去轮询它没有任何好处,反而增加延迟。用一个带内存缓存的小型Node服务器放在前面:
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);
这里只是纯粹的 node:http,单个端点无需任何框架。失败处理和成功路径同样重要:上游返回非200响应或响应体格式不正确都会抛出异常,而 getCarat 会在缓存存在且仍处于TTL窗口内时回退到该缓存值。只有在完全没有缓存的情况下,错误才会以502的形式传递给客户端。
不要从浏览器JavaScript直接调用 api.goldprice.dev。该API按设计没有浏览器CORS支持,因此客户端代码发起的 fetch 会失败。请让每个请求都经过你自己的服务器端点,这样做还能让缓存在所有访客之间共享,而不是每个浏览器标签页各自一份。
核心公式只是一次乘法:weight_grams × per_gram_karat_price。问题在于两个操作数都必须保持精确的十进制运算,因为金额的浮点数乘法会产生漂移:
console.log(0.1 + 0.2); // 0.30000000000000004
API已经给出了精确的十进制字符串。解决办法是把它们保持为最小货币单位(分)的整数,而不是转换为 Number 再转回来:
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);
}
这里不需要任何大数依赖库,只用现在每个JS运行时原生支持的 BigInt。重量以普通浮点数形式输入(珠宝秤精确到0.01g或0.001g),因此在相乘之前会先转换为整数毫克,而除回原单位的操作放在乘法之后最后进行,这样中间值才能保持精确。注意整数除法是截断而非四舍五入;在这段代码上线生产环境前,请为你所在货币的最小单位选定一条明确的舍入规则,就像API本身会将每个成色价格四舍五入到该货币自身的小数步长一样。
第三步得到的材料价值是一个由现货价格推导出的数字。它不是商铺该向顾客报出的价格。还有两项输入需要叠加在它上面,而且它们应作为独立的行项目存在,绝不能混入现货数字本身:
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,
};
}
把这些保持为三个独立字段而不是一个混合总价,正是让计算器可审计的关键。顾客或监管者都应该能单独看到由现货价格推导出的材料价值那一行,与商铺选择加上去的部分区分开来。
浏览器端只调用你自己的服务器,绝不直接调用上游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);
});
});
界面显示的每一个结果都需要在旁边附上时间戳和货币,以及下方一行固定的免责声明文字:这是材料价值估算,不是鉴定评估,且不包括宝石、工艺、税费和品相状态。把这句话写进模板一次即可,不要放进一个容易被忽略的提示气泡里。
在服务器边界,失败有三种形态,上面的代码已经全部处理:
fetchCarat 中被捕获,回退到缓存typeof data.price_gram_24k !== "string" 这一检查会在错误的响应体被缓存或提供给客户端之前将其拦下当存在一个新鲜的缓存条目时,以上情况都不会波及用户;他们会得到最后一个有效值及其真实时间戳。当完全没有可用缓存时,客户端会得到一个明确的错误状态,而不是一次损坏的计算结果或一个悄无声息的零值。
本文全程使用的匿名端点适用于开发和测试。将原始API数据用于商业用途(在你收费的产品中,或用于产生收入),需要Pro套餐或更高层级的授权。如果你只需要在页面上展示一个实时价格,而不是用于计算的原始数据,官方可嵌入小组件可免费用于商业展示,且无需API密钥。
相关指南
Build a reproducible local-currency gold backtest from settled XAU/USD bars and dated FX observations, without inserting future rates into historical rows.
阅读 →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).
阅读 →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.
阅读 →goldprice.dev
实时黄金价格、历史 OHLC 数据与多源聚合 — 通过 REST 和 SSE 接口提供。