ブログ/Developer

JavaScriptでゴールド・ジュエリー価格計算機を作る

ライブのカラット別グラム単価から、正確な10進数演算・キャッシュ・マージン・明確な評価上の限界を備えたゴールド・ジュエリー価格計算機を作る。

Developer

宝飾店、質店、そしてECサイトの販売者はみな、同じ小さなツールを必要としている。重さとカラットを入力すると、素材価値が返ってくるというものだ。この記事では、ライブのグラム単価からブラウザUIまで、そのツールをプレーンなJavaScriptでエンドツーエンドに構築する。

この計算機が見積もるもの、見積もらないもの

出力は素材価値、つまり現在のスポット価格において、そのピースに含まれる金の分だけの価値であり、他に何も加算されていない状態のものだ。これは鑑定でも、転売価格の保証でもなければ、買い手が実際に支払う金額でもない。

完全に除外されるもの:

  • 宝石、エナメル、ピースに含まれる金以外の素材
  • 加工賃とデザイン(手仕上げのリングは、型抜きのものより製造コストが高い)
  • 税金や関税(国や取引の種類によって異なる)
  • ディーラー・スプレッド:ショップが買い取る金額とスポット価格が示す金額の差
  • 状態:傷、石の欠落、彫刻の摩耗
  • ショップが利益を守るために適用する買取ディスカウント

この計算機が出す数値には、そのことをユーザーに伝える一文を必ず添えるべきだ。この免責事項はドキュメントだけでなく、UI自体に組み込むこと。

ステップ1: 8種類のグラム単価を取得する

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のいずれもカバーする。

ステップ2: サーバー側で60秒キャッシュする

上流のデータ自体もほぼ同じ間隔でしか更新されないため、ページを読み込むたびにポーリングしても何も得られず、レイテンシが増えるだけだ。インメモリ・キャッシュを備えた小さな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 は失敗する。すべてのリクエストは自前のサーバーエンドポイント経由にすること。そうすればキャッシュもブラウザタブごとではなく、全訪問者で共有される。

ステップ3: 素材価値: 重さ × グラム単価

コアとなる計算式は掛け算ひとつ、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そのものが各カラット価格をその通貨自身の小数点桁数に丸めているのと同じように、本番投入前に使用する通貨の最小単位に対する明示的な丸めルールを決めておくこと。

ステップ4: マージンと加工賃を別立てで加える

ステップ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,
  };
}

これらを一つに混ぜた合計ではなく三つのフィールドとして保持することが、この計算機を監査可能にする。顧客や規制当局は、スポット価格から導かれた素材価値をそれ単独の明細として確認でき、店が上乗せすることを選んだ部分とは切り分けて見られるべきだ。

ステップ5: タイムスタンプ、通貨、そして限界を表示する

ブラウザ側は上流の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が表示する結果にはすべて、隣にタイムスタンプと通貨を添え、その下に固定の免責文言を置く必要がある。これは鑑定ではなく素材価値の見積もりであり、宝石、加工賃、税金、状態を含んでいない、という一文だ。この一文はテンプレートに一度だけ組み込み、誰かが見落としかねないツールチップの中に置いてはならない。

ステップ6: フェッチの失敗と不正なデータを処理する

サーバー境界での失敗には三つの形があり、上記のコードはすでにその三つすべてを処理している:

  • 上流へのリクエスト自体が失敗する(ネットワークエラー、タイムアウト): fetchCarat 内で捕捉され、キャッシュにフォールバックする
  • レスポンスが非200ステータスである: 明示的にチェックされ、ネットワーク障害と同様に扱われる
  • レスポンスボディの形式がおかしい(フィールドの欠落、型の不一致): typeof data.price_gram_24k !== "string" のチェックが、キャッシュされたり提供されたりする前に不正な形式のボディを検知する

新しいキャッシュ・エントリが存在する場合、これらはいずれもユーザーには届かず、実際のタイムスタンプ付きの最後の正常な値が返される。使えるキャッシュがまったくない場合、クライアントは壊れた計算結果やサイレントなゼロではなく、明確なエラー状態を受け取る。

ライセンス

この記事全体で使われている匿名エンドポイントは、開発・テスト用途には問題ない。生のAPIデータを商用利用する場合(課金対象の製品や、収益を生む用途)は、Proプラン以上のライセンスが必要になる。必要なのが計算対象となる生データではなく、ページに表示するライブ価格だけであれば、公式の埋め込みウィジェットは商用表示用途であれば無料で、APIキーも不要だ。

関連記事

関連ガイド

Developer

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.

読む →
Developer

Gold price WebSocket on Reddit: the streaming questions, answered

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).

読む →
Developer

Gold price API on Stack Overflow: developer troubleshooting, answered

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経由で提供。