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.
Leer →Construye una calculadora de precio de joyería de oro a partir de precios en vivo por gramo y quilate, con aritmética decimal exacta, caché, márgenes y límites de valoración claros.
Los joyeros, las casas de empeño y los vendedores de ecommerce necesitan la misma herramienta pequeña: escribir un peso y un quilate, y obtener a cambio un valor del material. Este post construye esa herramienta de principio a fin, desde el precio en vivo por gramo hasta una interfaz en el navegador, usando JavaScript puro.
El resultado es un valor del material: cuánto vale el contenido de oro de una pieza al precio spot actual, antes de agregar cualquier otra cosa. No es una tasación, no es un valor de reventa garantizado, y no es lo que un comprador realmente pagará.
Quedan totalmente fuera:
Todo número que produzca esta calculadora debe llevar una línea que informe eso al usuario. Construye el descargo de responsabilidad en la interfaz, no solo en la documentación.
El endpoint /v1/carat de la API devuelve precios por gramo para ocho purezas de quilate comunes en una sola llamada, sin necesidad de clave de API:
GET https://api.goldprice.dev/v1/carat?currency=USD
Una respuesta real se ve así:
{
"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"
}
Dos detalles importan para el código de más abajo. Cada campo de precio es una cadena, no un número, para que los dígitos decimales exactos sobrevivan el ida y vuelta por JSON. Y currency acepta cualquiera de las 31 monedas que soporta la API, así que el mismo endpoint cubre USD, EUR, INR o IDR con solo cambiar un parámetro de consulta.
Los datos upstream se actualizan en una cadencia similar, así que consultarlos en cada carga de página no aporta nada y agrega latencia. Un pequeño servidor Node con una caché en memoria se coloca por delante:
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);
Esto es node:http puro, sin necesidad de un framework para un solo endpoint. El manejo de fallas importa tanto como el camino feliz: una respuesta upstream que no sea 200 o un cuerpo malformado lanza un error, y getCarat recurre al último valor cacheado si existe uno y sigue dentro de la ventana del TTL. Solo cuando no hay ninguna caché el error llega al cliente, como un 502.
No llames a api.goldprice.dev directamente desde JavaScript del navegador. La API deliberadamente no tiene superficie CORS para navegador, por diseño, así que un fetch desde código del lado del cliente fallará. Enruta cada solicitud a través de tu propio endpoint de servidor, lo que además mantiene la caché compartida entre todos los visitantes en lugar de una por pestaña del navegador.
La fórmula central es una sola multiplicación: weight_grams × per_gram_karat_price. El truco es que ambos operandos deben mantenerse en aritmética decimal exacta, porque la multiplicación de valores monetarios en punto flotante se desvía:
console.log(0.1 + 0.2); // 0.30000000000000004
La API ya te da cadenas decimales exactas. La solución es mantenerlas como enteros en unidades menores (centavos) en lugar de convertir a Number y de vuelta:
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);
}
Aquí no hay dependencia de números grandes, solo BigInt, que todo runtime de JS actual incluye de forma nativa. El peso llega como un float simple (una báscula de joyero lee hasta 0.01g o 0.001g), así que se convierte a miligramos enteros antes de la multiplicación, y la división de vuelta ocurre al final, después de multiplicar, para que los valores intermedios se mantengan exactos. Nota que la división entera trunca en lugar de redondear; elige una regla de redondeo explícita para la unidad menor de tu moneda antes de que esto pase a producción, de la misma forma en que la propia API redondea cada precio por quilate al paso decimal propio de la moneda.
El valor del material del paso 3 es un número derivado del spot. No es lo que una tienda debería cotizarle a un cliente. Dos entradas más van encima de él, y deben ir como partidas separadas, nunca mezcladas en el propio número del spot:
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,
};
}
Mantener estos como tres campos en lugar de un total mezclado es lo que hace que la calculadora sea auditable. Un cliente, o un regulador, debería poder ver el valor del material derivado del spot en su propia línea, separado de lo que la tienda decide agregar.
El lado del navegador llama a tu servidor, nunca a la 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);
});
});
Cada resultado que muestre la interfaz necesita la marca de tiempo y la moneda a su lado, y una línea fija de texto de descargo de responsabilidad debajo: esto es una estimación de valor del material, no una tasación, y excluye piedras, mano de obra, impuestos y condición. Coloca esa frase en la plantilla una sola vez, no en un tooltip que alguien pueda pasar por alto.
La falla tiene tres formas en el límite del servidor, y el código de arriba ya maneja las tres:
fetchCarat, recurre a la cachétypeof data.price_gram_24k !== "string" detecta un cuerpo malformado antes de que se cachee o se sirvaCuando existe una entrada de caché fresca, nada de esto llega al usuario; recibe el último valor bueno con su marca de tiempo real. Cuando no hay caché utilizable, el cliente obtiene un estado de error claro en lugar de un cálculo roto o un cero silencioso.
El endpoint anónimo usado a lo largo de este post está bien para desarrollo y pruebas. El uso comercial de los datos de la API sin procesar (en un producto por el que cobras, o que genera ingresos) requiere licenciamiento del plan Pro o superior. Si todo lo que necesitas es un precio en vivo mostrado en una página, no datos sin procesar para calcular sobre ellos, el widget embebible oficial es gratuito para uso de visualización comercial y no necesita clave de API.
guías relacionadas
Build a reproducible local-currency gold backtest from settled XAU/USD bars and dated FX observations, without inserting future rates into historical rows.
Leer →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).
Leer →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.
Leer →goldprice.dev
Precios del oro en tiempo real, OHLC histórico y agregación multi-fuente — disponible via REST y SSE.