MetalAPI
XAU3,997.89−1.54%XAG53.936−1.51%XPT1,554.47−0.83%XPD1,047.03−0.44%XCU6.5080+2.29%WTI103.27+3.82%BRENT111.70−1.30%EUR1.120400−0.01%GBP1.337500−0.01%JPY0.0063761+0.14%CHF1.213445−0.25%TRY0.020125−0.12%BTC80,329.75−0.55%ETH2,329.36−1.13%
すべての記事
チュートリアルECTypeScriptPython

リアルタイム金価格でジュエリー価格計算ツールを作る

グラムあたりのリアルタイム金価格、カラット純度、加工賃、VAT、クォータを守るキャッシュを組み合わせたサーバーサイドのジュエリー価格計算ツールを、TypeScriptとPythonで構築します。

執筆:MetalAPI Team··10分で読めます

計算ツールでできること

ジュエリーの価格は通常、製品の重量、金の純度、現在の金価格、職人の加工賃という4つの要素から決まり、その上に税金がかかります。すべての商品ページや見積もり依頼でこの計算を行うには、ショップの通貨建てで1グラムあたりの金価格が必要です。しかも、その価格をすばやく、APIのクォータ内で、APIキーをブラウザに公開せずに取得しなければなりません。

このチュートリアルでは、TypeScript(Node.js)とPythonで小さなサーバーサイドサービスを構築します。ブラウザまたはショップのバックエンドは、重量とカラットを自社のエンドポイントに送信します。サーバーはそれをMetalAPIからキャッシュした金価格と組み合わせ、価格の内訳をすべて返します。計算式は次のとおりです:

価格の計算式
地金の価値 = 重量(g)× 1グラムあたりの金価格(24K)× 純度
加工賃     = 1グラムあたりの金額、地金の価値に対する割合、または定額
税抜価格   = 地金の価値 + 加工賃
VAT        = 税抜価格 × VAT税率
合計       = 税抜価格 + VAT

金のスポット価格は卸売の参照価格です。小売価格にはデザイン、石、加工ロス、利益も反映され、買取価格は通常スポット価格を下回ります。この計算ツールは価格のうち地金に基づく部分と考え、その上に自社のビジネスルールを加えてください。

お使いの通貨で1グラムあたりの金価格を取得する

/latestエンドポイントは、任意の基準通貨で価格を返し、金属については任意の重量単位にも対応しています。1回のリクエストで、純金1グラムのユーロ建て価格を取得できます:

bash
curl "https://metalapi.com/api/v1/latest?base=EUR&currencies=XAU&unit=gram" \
  -H "X-API-Key: YOUR_API_KEY"
レスポンス(数値は例示)
{
  "success": true,
  "timestamp": 1792834892,
  "date": "2026-10-24",
  "base": "EUR",
  "unit": "gram",
  "rates": {
    "XAU": 0.0090909091,
    "EURXAU": 110
  },
  "rate_limit": { "limit": 10000, "remaining": 9874, "...": "..." }
}

rates.EURXAUは、XAU 1単位(ここでは1グラム)の基準通貨建て価格です。計算ツールに必要なのはこの数値です。rates.XAUはその逆数で、1ユーロあたりの金のグラム数です。デフォルトのトロイオンス以外の単位を指定した場合、レスポンスには必ずunitが含まれます。timestampは価格データの時刻なので、すべての見積もりに併記してください。

基準通貨と単位の指定はFreeを含むすべてのプランで利用でき、銀やプラチナの製品も販売している場合は、同じリクエストにXAG、XPT、XPDを追加できます。有料プランではcaratエンドポイントも利用でき、XAU22Kのようなカラット別価格を直接、またはcarat=allですべてのカラットを一度に取得できます。このチュートリアルでは、どのプランでも動作し、刻印の品位の値も使えるように、純度の計算を自前で行います。これが次のトピックです。

純度、加工賃、VAT

カラットは金の含有量を24分率で表すため、純度係数はkarat / 24になります。/caratエンドポイントもこの方式を採用しています。一方、多くの市場では千分率の品位で金に刻印しており、カラットによっては両者がわずかに異なります:

カラットkarat / 24一般的な刻印
24K1.0000999
22K0.9167916
21K0.8750875
18K0.7500750
14K0.5833585
9K0.3750375

1グラムあたり€110(例示)で5.2 gの14Kリングを計算すると、14/24では地金代が€333.67、0.585では€334.62になります。どちらも誤りではありません。自社の刻印や請求書で使っている方式を使ってください。そのため、以下のコードはカラットではなく純度係数を受け取ります。背景についてはカラット別価格の解説で詳しく説明しています。

加工賃は通常、次の3つの形式のいずれかで、TypeScript版はそのすべてに対応しています:

  • グラム単位:製品1グラムごとの固定額。例:€12/g。
  • 割合:地金代に対する割合。例:8%。金価格に連動して上下します。
  • 定額:1点ごとの固定料金。小さな商品やオーダーメイドでよく使われます。

VATや売上税は最後に計算します。税率と課税対象は国によって異なります。価格全体に課税する国もあれば、投資用の金をジュエリーとは区別して扱う国、加工賃にのみ課税する国もあります。税率と課税対象額は設定値として管理して税理士に確認し、請求書の各行の合計が総額と一致するように、各行をセント単位で丸めてください。

クォータ内に収めるためのキャッシュ

各プランでは価格が一定の間隔で更新され、更新は00:00 UTCから数えてその間隔の倍数のタイミングで行われます。Standardプランでは:00、:10、:20…、Freeでは毎日UTCの午前0時に1回です。2つの更新タイミングの間のリクエストはすべて同じ価格を返すため、理想的なキャッシュは更新タイミングごとに1回だけ更新します。31日の月に24時間すべての更新タイミングで更新した場合のリクエスト数は次のとおりです:

プラン(月間クォータ)更新間隔毎回更新した場合のリクエスト数クォータ内か
Free(100)毎日31はい
Lite(1,000)30分1,488いいえ:代わりに1時間ごとに更新(744)
Standard(10,000)10分4,464はい
Advanced(50,000)1分44,640はい(他の呼び出しに使える余裕は少なめ)

さらに2つのルールでキャッシュを堅牢にします。1つはシングルフライトです。負荷がかかった状態でキャッシュが期限切れになったとき、最初のリクエストだけが新しい価格を取得し、同時に届いた他のリクエストはそれぞれAPIを呼び出すのではなく、その結果を待ちます。もう1つはエラー時は古い価格を返すことです。APIに到達できない場合は、すべての決済を失敗させるのではなく、最後に取得できた価格で数時間見積もりを続け、1分ごとに再試行します。サーバーエラーで失敗したリクエストは課金されませんが、障害中にリクエストを大量に送る理由はありません。

以下のキャッシュはプロセスのメモリ上にあり、単一サーバーには最適です。サーバーレス環境や複数インスタンスの構成では、インスタンスごとに別々のコピーを持つことになります。その場合は価格をRedisなどの共有ストアに置くか、フレームワークのデータキャッシュを同じ有効期限で使用してください。

TypeScriptでの実装

以下のモジュールはNode.js 18以降で動作し、依存関係はありません。1グラムあたりのユーロ建て価格を取得し、プランの次の更新タイミングまでキャッシュして、重量、純度係数、加工賃から価格の内訳を算出します:

gold-quote.ts
// gold-quote.ts — Node 18以降。APIキーはサーバー上にとどまります。
const API_KEY = process.env.METALAPI_KEY!;
const CURRENCY = "EUR";
const PLAN_INTERVAL_S = 600; // Standardプラン:価格は10分ごとに更新
const MIN_TTL_S = 0;         // Liteプラン:月1,000リクエスト以内に収めるには3600に設定

interface GoldPrice {
  perGram: number;   // 純金(24K)、1グラムあたりのCURRENCY建て価格
  timestamp: number; // 価格データの時刻(Unix秒)
  fetchedAt: number;
  expiresAt: number;
}

let cached: GoldPrice | null = null;
let inflight: Promise<GoldPrice> | null = null;

// 価格はプランの更新間隔の倍数(00:00 UTCから起算)でのみ変わります。
function nextBoundary(ms: number): number {
  const step = PLAN_INTERVAL_S * 1000;
  return Math.floor(ms / step) * step + step;
}

async function fetchGold(): Promise<GoldPrice> {
  const url = `https://metalapi.com/api/v1/latest?base=${CURRENCY}&currencies=XAU&unit=gram`;
  const res = await fetch(url, { headers: { "X-API-Key": API_KEY }, signal: AbortSignal.timeout(10_000) });
  const body = await res.json();
  if (!body.success) throw new Error(`MetalAPI ${body.error.code}: ${body.error.info}`);
  const now = Date.now();
  return {
    perGram: body.rates[`${CURRENCY}XAU`],
    timestamp: body.timestamp,
    fetchedAt: now,
    expiresAt: nextBoundary(now + MIN_TTL_S * 1000) + 5_000, // 数秒の余裕を持たせる
  };
}

export function goldPerGram(): Promise<GoldPrice> {
  if (cached && Date.now() < cached.expiresAt) return Promise.resolve(cached);
  // シングルフライト:同時リクエストで1回のAPI呼び出しを共有します。
  inflight ??= fetchGold()
    .then((fresh) => (cached = fresh))
    .catch((err) => {
      // 最後に取得できた価格を最大6時間使い続け、1分後に再試行します。
      if (cached && Date.now() - cached.fetchedAt < 6 * 3600_000) {
        cached.expiresAt = Date.now() + 60_000;
        return cached;
      }
      throw err;
    })
    .finally(() => (inflight = null));
  return inflight;
}

export type MakingCharge =
  | { type: "per_gram"; amount: number }
  | { type: "percent"; rate: number } // 0.08 = 地金代の8%
  | { type: "flat"; amount: number };

const money = (n: number) => Math.round(n * 100 + Number.EPSILON) / 100;

export async function quote(grams: number, purity: number, making: MakingCharge, vatRate: number) {
  const gold = await goldPerGram();
  const metal = money(grams * gold.perGram * purity);
  const labour = money(
    making.type === "per_gram" ? grams * making.amount
      : making.type === "percent" ? metal * making.rate
      : making.amount,
  );
  const net = money(metal + labour);
  const vat = money(net * vatRate);
  return {
    currency: CURRENCY,
    grams,
    purity,
    gold_per_gram_24k: gold.perGram,
    price_time: new Date(gold.timestamp * 1000).toISOString(),
    metal,
    making: labour,
    net,
    vat,
    total: money(net + vat),
  };
}

いくつかの行を詳しく見てみましょう。nextBoundary()はプランの更新間隔の次の倍数に切り上げるため、Standardプランで10:03に取得した価格は10:13ではなく、次の更新の数秒後である10:10:05に期限切れになります。LiteプランでMIN_TTL_Sを3600に設定すると、次の更新は少なくとも1時間先の最初の更新タイミングまで待つため、使用量は1日24リクエストに抑えられます。.catchの分岐は「エラー時は古い価格を返す」ルールです。最後に取得できた価格を返し、次の試行を1分後に予約するため、障害時の失敗リクエストは訪問者ごとに1回ではなく、最大でも1分に1回で済みます。

これを小さなHTTPエンドポイントとして公開します。入力は厳密に検証してください。販売している純度と妥当な重量範囲のみを受け付けます。加工賃とVAT率はサーバー側の設定値であり、クライアントから受け取ってはいけません。

server.ts
// server.ts — npm install expressの後、METALAPI_KEY=mk_live_... npx tsx server.ts で実行
import express from "express";
import { quote } from "./gold-quote";

// 製品に刻印されている純度係数(上のカラット表を参照)。
const PURITY: Record<string, number> = { "24": 0.999, "22": 0.916, "21": 0.875, "18": 0.75, "14": 0.585, "9": 0.375 };
const app = express();

app.get("/api/jewelry-quote", async (req, res) => {
  const grams = Number(req.query.grams);
  const purity = PURITY[String(req.query.karat)];
  if (!purity || !(grams > 0 && grams <= 2000)) {
    res.status(400).json({ error: "Use karat 24, 22, 21, 18, 14 or 9 and grams between 0 and 2000." });
    return;
  }
  try {
    // 加工賃とVATはサーバー側の設定値で、クライアントの入力は受け付けません。
    res.json(await quote(grams, purity, { type: "per_gram", amount: 12 }, 0.2));
  } catch (err) {
    console.error(err);
    res.status(503).json({ error: "Gold price temporarily unavailable." });
  }
});

app.listen(3000, () => console.log("Try http://localhost:3000/api/jewelry-quote?karat=18&grams=5.2"));

/api/jewelry-quote?karat=18&grams=5.2にリクエストすると、次のような内訳が返されます(金価格は例示):

json
{
  "currency": "EUR",
  "grams": 5.2,
  "purity": 0.75,
  "gold_per_gram_24k": 110,
  "price_time": "2026-10-24T09:41:32.000Z",
  "metal": 429,
  "making": 62.4,
  "net": 491.4,
  "vat": 98.28,
  "total": 589.68
}

Pythonでの実装

Python版も同じ設計です。金額にはDecimalを使うため、2進浮動小数点の誤差が入り込むことはありません。また、ロックを使って、マルチスレッドのサーバーで同時に届いたリクエストが1回のAPI呼び出しを共有するようにしています。コードを短くするため、加工賃はグラム単位のみに対応しています:

gold_quote.py
# gold_quote.py — Python 3.9以降、pip install requests
import math
import os
import threading
import time
from decimal import ROUND_HALF_UP, Decimal

import requests

API_KEY = os.environ["METALAPI_KEY"]
CURRENCY = "EUR"
PLAN_INTERVAL_S = 600  # Standardプラン:価格は10分ごとに更新
MIN_TTL_S = 0          # Liteプラン:月1,000リクエスト以内に収めるには3600に設定

_lock = threading.Lock()
_cache = {"per_gram": None, "timestamp": 0, "fetched": 0.0, "expires": 0.0}


def _next_boundary(t: float) -> float:
    return (math.floor(t / PLAN_INTERVAL_S) + 1) * PLAN_INTERVAL_S


def gold_per_gram() -> tuple[Decimal, int]:
    """純金(24K)の1グラムあたりのCURRENCY建て価格と、その価格のUnix時刻。"""
    with _lock:  # API呼び出しは一度に1回。待機中のスレッドはその結果を再利用します
        now = time.time()
        if _cache["per_gram"] is not None and now < _cache["expires"]:
            return _cache["per_gram"], _cache["timestamp"]
        try:
            r = requests.get(
                "https://metalapi.com/api/v1/latest",
                params={"base": CURRENCY, "currencies": "XAU", "unit": "gram"},
                headers={"X-API-Key": API_KEY},
                timeout=10,
            )
            body = r.json()
            if not body.get("success"):
                raise RuntimeError(f"MetalAPI {body['error']['code']}: {body['error']['info']}")
        except Exception:
            if _cache["per_gram"] is not None and now - _cache["fetched"] < 6 * 3600:
                _cache["expires"] = now + 60  # 最後に取得できた価格を返し、1分後に再試行
                return _cache["per_gram"], _cache["timestamp"]
            raise
        _cache.update(
            per_gram=Decimal(str(body["rates"][f"{CURRENCY}XAU"])),
            timestamp=body["timestamp"],
            fetched=now,
            expires=_next_boundary(now + MIN_TTL_S) + 5,
        )
        return _cache["per_gram"], _cache["timestamp"]


def money(x: Decimal) -> Decimal:
    return x.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)


def quote(grams: Decimal, purity: Decimal, making_per_gram: Decimal, vat_rate: Decimal) -> dict:
    per_gram, ts = gold_per_gram()
    metal = money(grams * per_gram * purity)
    making = money(grams * making_per_gram)
    net = metal + making
    vat = money(net * vat_rate)
    return {
        "currency": CURRENCY,
        "gold_per_gram_24k": str(per_gram),
        "price_time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts)),
        "metal": str(metal),
        "making": str(making),
        "net": str(net),
        "vat": str(vat),
        "total": str(net + vat),
    }

どのWebフレームワークからでも利用できます。FastAPIでは、通常のdefで定義したエンドポイントはスレッドプールで実行されます。ロックはまさにこのためのものです:

app.py
# app.py — pip install fastapi uvicorn requestsの後、METALAPI_KEY=mk_live_... uvicorn app:app で実行
from decimal import Decimal

from fastapi import FastAPI, HTTPException, Query

from gold_quote import quote

# 製品に刻印されている純度係数。
PURITY = {24: Decimal("0.999"), 22: Decimal("0.916"), 21: Decimal("0.875"),
          18: Decimal("0.750"), 14: Decimal("0.585"), 9: Decimal("0.375")}
app = FastAPI()


@app.get("/api/jewelry-quote")
def jewelry_quote(karat: int, grams: Decimal = Query(gt=0, le=2000)):
    if karat not in PURITY:
        raise HTTPException(400, "Use karat 24, 22, 21, 18, 14 or 9.")
    try:
        return quote(grams, PURITY[karat], making_per_gram=Decimal("12.00"), vat_rate=Decimal("0.20"))
    except Exception:
        raise HTTPException(503, "Gold price temporarily unavailable.")

Python版は金額を文字列で、TypeScript版は数値で返します。見積もりを受注システムに渡す場合は、金額を浮動小数点数ではなく、10進数の文字列または整数のセント単位で保存してください。

計算例

18Kリングの価格を順を追って計算します。金価格は1グラムあたり€110.00(例示)、加工賃は1グラムあたり€12、VATは20%とします:

ステップ計算金額
金(24K)1グラムあたりrates.EURXAU€110.00
純度18K、刻印7500.75
地金代5.2 g × €110.00 × 0.75€429.00
加工賃5.2 g × €12.00€62.40
税抜価格€429.00 + €62.40€491.40
VAT€491.40 × 20%€98.28
合計€589.68

次に、同じ金価格で、加工賃8%の10 gの22Kチェーンを計算します。純度22/24では、地金代は€1,008.33、加工賃はその8%(€80.67)、税抜価格は€1,089.00、VAT込みの合計は€1,306.80になります。刻印916の係数を使うと、地金代は€1,007.60になります。

金価格に連動するのは地金代だけなので、合計の変動幅は金価格自体の変動幅より小さくなります。金価格が1%上昇して1グラムあたり€111.10になると、18Kリングの地金代は€433.29、合計は€594.83となり、増加額は€5.15、つまり0.87%です。加工賃を割合で設定すると、合計は金価格により近い動きをします。

本番環境のチェックリスト

  • APIキーはサーバー上の環境変数に保管してください。計算ツールのエンドポイントが返すのは価格であり、キーではありません。
  • すべての見積もりにprice_timeを表示し、顧客やスタッフが金価格の鮮度を確認できるようにしてください。
  • 注文時に価格を確定してください。後から再計算するのではなく、金価格、そのタイムスタンプ、純度、各行の金額を注文と一緒に保存します。
  • 金市場が閉まる週末は、価格が動かないのが正常です。キャッシュが古くなっているわけではありません。
  • クォータにカウントされないusageエンドポイントで使用量を追跡し、月末を迎える前に自分宛てに警告を出すようにしてください。
  • エラー105(月間クォータの上限到達)は、キャッシュした価格を返してチームに通知することで対処してください。クォータは毎月1日の00:00 UTCにリセットされ、アップグレードは即時に反映されます。
  • カタログの価格改定など、大きな価格変動に対応したい場合は、ポーリング頻度を上げるのではなく、Webhookによる価格アラートを使用してください。

プランとクォータは料金ページで比較できます。無料アカウントを作成してキーを取得すれば、実際の価格で計算ツールを試せます。

ご自身のデータで試してみましょう

無料アカウントを作成すれば、2分以内に最初のリクエストを送信できます。月100リクエスト、クレジットカード不要。

関連記事