添加 Apps Script 后,在任意单元格输入 =GOLDPRICE(),即可获取 goldprice.dev 的 XAU/USD 实时金价,单位为每金衡盎司。免费价格延迟 15 分钟。IMPORTDATA 无法读取此 JSON 端点,因此需要自定义函数。GOLDHISTORY() 返回 30 天的每日收盘价;GOLDPRICEDELTA() 返回相对于已统一币种和单位的参考单元格的价格差及百分比差,保留正负号。脚本不保存 API 密钥。运行一次 installGoldRefresh(),即可绑定一个刷新单元格,并创建符合额度限制的四小时触发器。
开始使用,无需在表格中保存 API 密钥
XAU 现货端点和 30 天的每日历史数据均支持匿名访问,因此本教程不会在 Apps Script 中保存凭据。匿名访问遵循 Free 限额:每个 IP 每分钟 10 次请求、每小时 100 次、每月 5,000 次。每四小时刷新的工作簿可控制在此额度内。
多个 Sheets 用户可能共用 Google 的出口 IP,因此可用的匿名额度会有变化。如果共享或生产工作簿需要稳定的账户额度,请通过自己的服务端代理调用,并将 goldprice.dev 密钥保存在代理端。不要将 bearer token 粘贴到其他编辑者可查看的绑定脚本中。免费账户密钥同样提供每月 5,000 次调用,额度归属账户,不与其他用户共用 IP 额度。
将三个金价函数添加到 Apps Script
打开表格,点击扩展程序 → Apps Script。删除默认的 function myFunction() {},粘贴下方代码。代码定义了三个函数:GOLDPRICE(metal, currency, refreshToken) 获取当前现货价,GOLDHISTORY(fromDate, toDate, refreshToken) 获取可直接绘图的每日收盘价,GOLDPRICEDELTA(referencePrice, metal, currency, refreshToken) 计算保留正负号的价格差和百分比差。这些函数通过 CacheService 缓存相同请求,不发送 Authorization 请求头,并返回普通错误字符串,不抛出异常。
如需在同一工作簿中记录持有数量、买入成本和参考估值,请使用黄金投资组合跟踪模板。
// google-sheets: Apps Script Code.gsconst SPOT_CACHE_TTL = 55; // Cache successful spot responses for 55 seconds.const BARS_CACHE_TTL = 300; // Cache successful daily bars for 5 minutes.const REFRESH_RANGE_NAME = 'GOLDPRICE_REFRESH_TOKEN';const SPREADSHEET_ID_KEY = 'GOLDPRICE_SPREADSHEET_ID';const INSTALLER_EMAIL_KEY = 'GOLDPRICE_INSTALLER_EMAIL';const LAST_REFRESH_AT_KEY = 'GOLDPRICE_LAST_REFRESH_AT';const MIN_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000;/** * Returns the current spot price for a metal/currency pair. * @customfunction */function GOLDPRICE(metal, currency, refreshToken) { const symbol = metal + '-' + currency + '-SPOT'; const cache = CacheService.getScriptCache(); const cacheKey = 'spot_' + symbol; const cached = cache.get(cacheKey); if (cached) return cached; const url = `https://api.goldprice.dev/v1/spot/${symbol}`; const resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true, }); const code = resp.getResponseCode(); if (code === 401) return '#ACCESS_ERROR: use a server-side proxy'; if (code === 403) return '#PLAN_GATED: symbol not on your tier'; if (code === 429) return '#RATE_LIMIT: wait a minute and retry'; if (code !== 200) return '#ERROR_' + code; const data = JSON.parse(resp.getContentText()); if (!data || !data.price) return '#NO_DATA'; // Price stays a string, same as the API wire format: Sheets displays and // sorts it fine. Wrap in VALUE() if you need it in arithmetic. cache.put(cacheKey, data.price, SPOT_CACHE_TTL); return data.price;}/** * Compares live spot with a reference cell in the same currency and unit. * Spills [signed price delta, signed percentage delta] into two cells. * @customfunction */function GOLDPRICEDELTA(referencePrice, metal, currency, refreshToken) { const reference = Number(referencePrice); if (!Number.isFinite(reference) || reference <= 0) { return [['#BAD_REFERENCE: enter a positive price', '']]; } const liveResult = GOLDPRICE(metal, currency, refreshToken); const live = Number(liveResult); if (!Number.isFinite(live)) return [[liveResult, '']]; const signedDelta = live - reference; return [[Number(signedDelta.toFixed(2)), signedDelta / reference]];}/** * Returns chart-ready daily closes for a date range. * @customfunction */function GOLDHISTORY(fromDate, toDate, refreshToken) { const now = new Date(); const rollingFrom = new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000); const from = toIsoDate_( fromDate === '' || fromDate == null ? rollingFrom : fromDate ); const to = toIsoDate_(toDate === '' || toDate == null ? now : toDate); if (!from || !to) return [['#BAD_DATE: put real dates in B2/B3']]; const cache = CacheService.getScriptCache(); const cacheKey = 'bars_' + from + '_' + to; const cached = cache.get(cacheKey); if (cached) return JSON.parse(cached); const url = 'https://api.goldprice.dev/v1/bars?symbol=XAU-USD-SPOT&interval=1d' + '&from=' + from + '&to=' + to; const resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true, }); const code = resp.getResponseCode(); if (code === 401) return [['#ACCESS_ERROR: use a server-side proxy']]; if (code === 403) return [['#PLAN_GATED: range not on your tier']]; if (code === 429) return [['#RATE_LIMIT: wait a minute and retry']]; if (code !== 200) return [['#ERROR_' + code]]; const data = JSON.parse(resp.getContentText()); const bars = Array.isArray(data.bars) ? data.bars : []; if (bars.length === 0) return [['#NO_DATA: empty range']]; // /v1/bars returns bars newest-first; reverse to chronological order so // a line chart built from this range reads left-to-right in time. const rows = bars.slice().reverse().map(function (bar) { return [bar.bar_start.slice(0, 10), Number(bar.close)]; }); cache.put(cacheKey, JSON.stringify(rows), BARS_CACHE_TTL); return rows;}function installGoldRefresh() { const lock = LockService.getScriptLock(); lock.waitLock(30000); try { const spreadsheet = SpreadsheetApp.getActiveSpreadsheet(); if (!spreadsheet) throw new Error('Open the bound Google Sheet and run again'); const installerEmail = Session.getEffectiveUser().getEmail(); if (!installerEmail) throw new Error('Run the installer from your Google account'); const properties = PropertiesService.getScriptProperties(); const existingInstaller = properties.getProperty(INSTALLER_EMAIL_KEY); if (existingInstaller && existingInstaller !== installerEmail) { throw new Error('Gold refresh is already owned by another editor'); } const refreshRange = spreadsheet.getActiveSheet().getRange('B1'); spreadsheet.setNamedRange(REFRESH_RANGE_NAME, refreshRange); // getProjectTriggers() only returns this user's triggers. The shared // installer-email claim above prevents a second editor from creating a // second schedule, while this replacement keeps owner reruns idempotent. ScriptApp.getProjectTriggers() .filter(function (trigger) { return trigger.getHandlerFunction() === 'refreshGoldData'; }) .forEach(function (trigger) { ScriptApp.deleteTrigger(trigger); }); ScriptApp.newTrigger('refreshGoldData') .timeBased() .everyHours(4) .create(); properties.setProperties({ [SPREADSHEET_ID_KEY]: spreadsheet.getId(), [INSTALLER_EMAIL_KEY]: installerEmail, }); } finally { lock.releaseLock(); } refreshGoldData();}function uninstallGoldRefresh() { const lock = LockService.getScriptLock(); lock.waitLock(30000); try { const properties = PropertiesService.getScriptProperties(); const installerEmail = Session.getEffectiveUser().getEmail(); const existingInstaller = properties.getProperty(INSTALLER_EMAIL_KEY); if (existingInstaller && existingInstaller !== installerEmail) { throw new Error('Only the trigger owner can uninstall gold refresh'); } ScriptApp.getProjectTriggers() .filter(function (trigger) { return trigger.getHandlerFunction() === 'refreshGoldData'; }) .forEach(function (trigger) { ScriptApp.deleteTrigger(trigger); }); properties.deleteProperty(SPREADSHEET_ID_KEY); properties.deleteProperty(INSTALLER_EMAIL_KEY); properties.deleteProperty(LAST_REFRESH_AT_KEY); } finally { lock.releaseLock(); }}function refreshGoldData(event) { const lock = LockService.getScriptLock(); lock.waitLock(30000); try { // Clock triggers pass an event object, but they cannot supply the metal, // currency, date-range, or reference arguments required by sheet formulas. // This wrapper only changes the named token cell those formulas reference. const properties = PropertiesService.getScriptProperties(); const spreadsheetId = properties.getProperty(SPREADSHEET_ID_KEY); if (!spreadsheetId) throw new Error('Run installGoldRefresh first'); // Script properties and the script lock are shared across editors. This // gate coalesces legacy triggers owned by different users into one refresh // per four hours even though Apps Script cannot enumerate their triggers. const now = Date.now(); const lastRefreshAt = Number(properties.getProperty(LAST_REFRESH_AT_KEY)); if (lastRefreshAt && now - lastRefreshAt < MIN_REFRESH_INTERVAL_MS) return; const spreadsheet = SpreadsheetApp.openById(spreadsheetId); const refreshRange = spreadsheet.getRangeByName(REFRESH_RANGE_NAME); if (!refreshRange) throw new Error('Run installGoldRefresh to restore B1'); refreshRange.setValue(new Date(now).toISOString()); properties.setProperty(LAST_REFRESH_AT_KEY, String(now)); } finally { lock.releaseLock(); }}function toIsoDate_(value) { const d = value instanceof Date ? value : new Date(value); if (isNaN(d.getTime())) return null; return d.toISOString().slice(0, 10);}设置价格、历史数据和参考报价单元格
在 B1 中填入刷新标记,可在 C1 中填入以 USD 计价的每金衡盎司参考报价。将 B2 和 B3 留空,可获取滚动 30 个自然日的历史数据,也可输入固定日期。GOLDPRICEDELTA() 返回实时价减去参考价的价格差和百分比差,保留正负号。只有两个单元格的币种和单位一致时,对比才有效。
Google 表示,GOOGLEFINANCE 报价最多可能延迟 20 分钟,部分期货不受支持,且无法通过 Apps Script 访问历史结果。如需使用单独的参考报价,请先将其换算为每金衡盎司 USD,再填入 C1;程序调用的现货价和历史数据由 goldprice.dev 提供。不要将 NOW() 或 TODAY() 作为自定义函数的参数:当 B2/B3 留空时,GOLDHISTORY() 会在内部计算滚动日期。
// B1:刷新标记,由 refreshGoldData() 写入// B2/B3:留空可使用滚动的 30 个日历日范围,也可输入固定日期// C1:可选的参考报价,统一换算为每金衡盎司的 USD 价格// 当前 USD 现货价,每次 B1 变化时重新计算=GOLDPRICE("XAU", "USD", $B$1)// 实时现货价减去 C1:溢出结果为 [带正负号的 USD 差值, 带正负号的百分比差值]// 将第二个结果单元格的格式设为百分比。=GOLDPRICEDELTA($C$1, "XAU", "USD", $B$1)// 当前 EUR 现货价,同一刷新标记单元格触发两项计算=GOLDPRICE("XAU", "EUR", $B$1)// 30 天的每日收盘价,可直接用于图表(日期,收盘价)=GOLDHISTORY($B$2, $B$3, $B$1)根据历史数据创建折线图
选中 GOLDHISTORY() 展开的数据行,点击插入 → 图表,然后选择折线图。行数取决于请求的日期范围和可用的日线数据。函数已将 API 返回的从新到旧的数据反转为时间顺序,因此横轴会从旧到新排列,无需额外排序。
添加每 4 小时刷新的触发器
选择一个 Google 账户负责定时刷新。如果编辑者曾按旧版教程操作,每个人都需先在 Apps Script → 触发器中删除自己的 refreshGoldData 条目。然后运行一次 installGoldRefresh 并授权。安装函数会绑定 B1,创建一个四小时触发器。共享锁和时间戳会合并无法访问的旧触发器调用,确保每四小时只刷新一次。
计划交接时,由原负责人运行 uninstallGoldRefresh,再由新负责人安装。如果原负责人无法操作,请先在项目设置 → 脚本属性中删除三个 GOLDPRICE_* 条目,再重新安装;共享间隔检查仍会防止重复调用。四个公式 × 每天六次刷新,最多为每天 24 次调用、每月约 720 次,低于匿名 Free 每个 IP 每小时 100 次、每月 5,000 次的限额。
预期输出
{ "symbol": "XAU", "quote_currency": "USD", "unit": "troy_ounce", "contract_type": "spot", "price": "4012.75", "is_stale": false, "computed_at": "2026-10-23T07:00:18Z"}GOLDPRICE 将上方的 price 字段显示为文本,例如 "4012.75"。GOLDPRICEDELTA 将保留正负号的价格差和以小数表示的百分比差展开到两个单元格,请将第二个设为百分比格式。GOLDHISTORY 填充请求范围内可用的 [date, close] 数据行。
常见错误
| 代码 | 现象 | 解决方法 |
|---|---|---|
| 401 | 单元格显示 #ACCESS_ERROR: use a server-side proxy | 匿名请求未被接受。先查看 Apps Script → 执行记录。如果工作簿需要通过身份验证获取额度,请通过服务端代理发送请求,不要将密钥粘贴到绑定脚本中。 |
| 403 | 单元格显示 #PLAN_GATED: symbol not on your tier | Free 支持黄金(XAU)、白银(XAG)和铜(HG)现货。请检查品种代码;目前不提供铂金和钯金。详见 /pricing。 |
| 429 | 单元格显示 #RATE_LIMIT: wait a minute and retry | 共享 IP 已达到 Free 限额:每分钟 10 次请求、每小时 100 次或每月 5,000 次。缓存通常可避免此问题;请调大 TTL 常量、减少不同的公式,或通过服务端代理发送带身份验证的请求。 |
| N/A | GOLDHISTORY 显示 #BAD_DATE: put real dates in B2/B3 | B2 或 B3 中的文本无法被 Apps Script 解析为日期,例如填入了标签而非日期值。请输入实际日期,或引用通过格式 → 数字 → 日期设置为日期格式的单元格。 |
| N/A | GOLDPRICEDELTA 显示 #BAD_REFERENCE: enter a positive price | C1 为空、文本、零或负数。请输入正的参考价格,并使用与实时现货公式相同的币种和每金衡盎司单位。 |
| N/A | 单元格一直显示 Loading… | Apps Script 需要访问外部数据的授权。在编辑器工具栏中选择一个函数,手动运行一次以触发授权提示,然后同意授权。 |
进阶用法
- 创建价格提醒公式:
=IF(GOLDPRICE("XAU","USD",$B$1) > 4800, "ALERT", "OK") - 如需更早的每日历史数据,请使用带密钥的服务端代理:Basic 提供 1 年数据,Plus 和 Pro 提供完整历史数据
- 使用 Free 同时跟踪黄金和白银,两者共用同一个刷新标记单元格
- 将可直接绘图的 GOLDHISTORY 数据行用于回测,参阅使用 OHLC 数据回测黄金策略
- 在估值或成本模型中引用
=GOLDPRICE(...),让模型随实时数据更新,无需写死价格