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.
読む →ライブのカラット別グラム単価から、正確な10進数演算・キャッシュ・マージン・明確な評価上の限界を備えたゴールド・ジュエリー価格計算機を作る。
宝飾店、質店、そしてECサイトの販売者はみな、同じ小さなツールを必要としている。重さとカラットを入力すると、素材価値が返ってくるというものだ。この記事では、ライブのグラム単価からブラウザUIまで、そのツールをプレーンなJavaScriptでエンドツーエンドに構築する。
出力は素材価値、つまり現在のスポット価格において、そのピースに含まれる金の分だけの価値であり、他に何も加算されていない状態のものだ。これは鑑定でも、転売価格の保証でもなければ、買い手が実際に支払う金額でもない。
完全に除外されるもの:
この計算機が出す数値には、そのことをユーザーに伝える一文を必ず添えるべきだ。この免責事項はドキュメントだけでなく、UI自体に組み込むこと。
APIの /v1/carat エンドポイントは、APIキー不要で一回の呼び出しで、一般的な8種類のカラット純度についてグラム単価を返す:
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を往復させても正確な10進数の桁がそのまま保たれる。そして 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 だ。ただし両方のオペランドを正確な10進数演算のまま保つ必要がある。金額の浮動小数点乗算は誤差が蓄積するからだ:
console.log(0.1 + 0.2); // 0.30000000000000004
APIはすでに正確な10進数の文字列を返している。対処法は、それを 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そのものが各カラット価格をその通貨自身の小数点桁数に丸めているのと同じように、本番投入前に使用する通貨の最小単位に対する明示的な丸めルールを決めておくこと。
ステップ3の素材価値は、スポット価格から導かれた数値だ。それは店が顧客に提示すべき金額そのものではない。その上にさらに二つの入力が乗り、それらは別々の明細項目として扱うべきであり、スポット価格の数値に混ぜ込んではならない:
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);
});
});
UIが表示する結果にはすべて、隣にタイムスタンプと通貨を添え、その下に固定の免責文言を置く必要がある。これは鑑定ではなく素材価値の見積もりであり、宝石、加工賃、税金、状態を含んでいない、という一文だ。この一文はテンプレートに一度だけ組み込み、誰かが見落としかねないツールチップの中に置いてはならない。
サーバー境界での失敗には三つの形があり、上記のコードはすでにその三つすべてを処理している:
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経由で提供。