/** * Palanor Data for Google Sheets — custom functions, a menu and a sidebar, in one file. * * =PALANOR_LAST("rates.spread_2y10y") latest value (or a column of slugs at once) * =PALANOR_HISTORY("rates.spread_2y10y", "2020-01-01", , "M") * =PALANOR_FORESIGHT("rates.spread_2y10y", "30d") Palanor Foresight at a horizon (Pro and Enterprise keys) * =PALANOR_SEARCH("yield curve") signals that match, spilled * =PALANOR_SCREEN("") a saved Entity Screener screen (Pro) * =PALANOR_ENTITY("ticker:AAPL", "lei") a fact from the company graph (Pro) * * Install: Extensions → Apps Script, paste this file over Code.gs, save, reload the sheet, then * Palanor → Set API key. Docs: https://palanor.com/developers#sheets * * The key is kept in your Apps Script user properties, never in a cell. Google runs custom functions * with the spreadsheet owner's properties, so formulas in a shared sheet spend the owner's key. * Every API call counts against the key's monthly allowance, so answers are cached for 15 minutes, * and a PALANOR_LAST over a range of slugs is read in one batch that costs one request per ten slugs. * Only signals licensed for redistribution are served: market prices stay inside Palanor. * * Source: https://palanor.com/sheets/Palanor.gs · MIT licensed · version below. */ var PALANOR_VERSION = '1.0.1'; var PALANOR_API = 'https://api.palanor.com/v1'; var PALANOR_KEY = 'palanor.apiKey'; var PALANOR_EPOCH = 'palanor.epoch'; var PALANOR_TTL_S = 15 * 60; // ── menu + sidebar ───────────────────────────────────────────────────────── function onOpen(e) { SpreadsheetApp.getUi().createMenu('Palanor') .addItem('Search signals and screens', 'palanorShowSidebar') .addItem('Set API key', 'palanorPromptKey') .addSeparator() .addItem('Refresh data', 'palanorRefresh') .addItem('Add sources sheet', 'palanorSourcesSheet') .addItem('Help', 'palanorHelp') .addToUi(); } function onInstall(e) { onOpen(e); } function palanorPromptKey() { var ui = SpreadsheetApp.getUi(); var r = ui.prompt('Palanor API key', 'Paste your key (plnr_…). It is kept in your Apps Script user properties, never in a cell. No key? Get a free one at palanor.com/developers/keys.', ui.ButtonSet.OK_CANCEL); if (r.getSelectedButton() !== ui.Button.OK) return; var msg = palanorSaveKey(r.getResponseText()); ui.alert(msg); } /** Save and check a key. Returns a sentence for the person. */ function palanorSaveKey(raw) { var k = String(raw || '').trim(); if (!/^plnr_[A-Za-z0-9]{8,}$/.test(k)) return 'That does not look like a Palanor key (plnr_…).'; var res = UrlFetchApp.fetch(PALANOR_API + '/latest?slugs=rates.spread_2y10y', { headers: palanorHeaders_(k), muteHttpExceptions: true }); if (res.getResponseCode() === 401) return 'The API did not accept that key. Check it at palanor.com/api-tokens.'; PropertiesService.getUserProperties().setProperty(PALANOR_KEY, k); palanorBump_(); var h = res.getAllHeaders(); var tier = h['x-palanor-tier'] || h['X-Palanor-Tier']; var left = h['x-palanor-ratelimit-remaining'] || h['X-Palanor-RateLimit-Remaining']; return 'Key saved.' + (tier ? ' ' + tier.charAt(0).toUpperCase() + tier.slice(1) + ' tier, ' + Number(left).toLocaleString() + ' requests left this month.' : ''); } function palanorShowSidebar() { var html = HtmlService.createHtmlOutput(PALANOR_SIDEBAR_).setTitle('Palanor Data'); SpreadsheetApp.getUi().showSidebar(html); } function palanorHelp() { SpreadsheetApp.getUi().alert('Palanor Data for Google Sheets ' + PALANOR_VERSION + '\n\n' + 'PALANOR_LAST(slug) · PALANOR_HISTORY(slug, from, to, freq) · PALANOR_FORESIGHT(slug, horizon, output) (Pro and Enterprise keys) · ' + 'PALANOR_SEARCH(query) · PALANOR_SCREEN(id) · PALANOR_ENTITY(id, field)\n\nDocs: https://palanor.com/developers#sheets'); } /** Clear the cache and recalculate every PALANOR_ formula in the spreadsheet. */ function palanorRefresh() { palanorBump_(); var n = 0; SpreadsheetApp.getActiveSpreadsheet().getSheets().forEach(function (sh) { var range = sh.getDataRange(); var f = range.getFormulas(); for (var r = 0; r < f.length; r++) for (var c = 0; c < f[r].length; c++) { if (/PALANOR_/i.test(f[r][c])) { var cell = range.getCell(r + 1, c + 1); cell.setFormula(''); SpreadsheetApp.flush(); cell.setFormula(f[r][c]); n++; } } }); SpreadsheetApp.getActiveSpreadsheet().toast(n + ' PALANOR formula' + (n === 1 ? '' : 's') + ' refreshed.', 'Palanor', 4); } /** A "Palanor sources" sheet: every signal used in PALANOR_ formulas, with its source, license and credit. */ function palanorSourcesSheet() { var ss = SpreadsheetApp.getActiveSpreadsheet(); var slugs = []; ss.getSheets().forEach(function (sh) { if (sh.getName() === 'Palanor sources') return; sh.getDataRange().getFormulas().forEach(function (row) { row.forEach(function (f) { var re = /PALANOR_(LAST|HISTORY|FORESIGHT)\(\s*"([^"]+)"/gi, m; while ((m = re.exec(f))) if (slugs.indexOf(m[2]) < 0) slugs.push(m[2]); }); }); }); if (!slugs.length) { ss.toast('No PALANOR_ formulas with a written slug found.', 'Palanor', 4); return; } var items = palanorLatestMany_(slugs.slice(0, 100)); var rows = [['Slug', 'Signal', 'Unit', 'Source', 'Publisher', 'License', 'License URL', 'Attribution', 'Retrieved (UTC)', 'Palanor page']]; var now = new Date().toISOString().replace('T', ' ').slice(0, 19); items.forEach(function (it) { var p = it.provenance || {}; rows.push([it.slug, it.name || it.error || '', it.unit || '', p.source || '', p.publisher || '', p.license_name || p.license_class || '', p.license_url || '', p.attribution || '', now, 'https://palanor.com/signals/' + encodeURIComponent(it.slug)]); }); rows.push(['', '', '', '', '', '', '', 'Data: Palanor · palanor.com/data. Terms: palanor.com/terms', '', '']); var sh = ss.getSheetByName('Palanor sources') || ss.insertSheet('Palanor sources'); sh.clear(); sh.getRange(1, 1, rows.length, rows[0].length).setValues(rows); sh.getRange(1, 1, 1, rows[0].length).setFontWeight('bold'); sh.autoResizeColumns(1, rows[0].length); ss.setActiveSheet(sh); } // Sidebar calls (google.script.run). function palanorSidebarState() { var k = palanorKey_(); return { hasKey: !!k, hint: k ? k.slice(0, 9) + '…' : '' }; } function palanorSidebarSearch(q) { return palanorGet_('/search', { q: q, limit: 8 }).data || []; } function palanorSidebarScreens() { return palanorGet_('/screens', {}).data || []; } /** Write a formula into the active cell. */ function palanorInsert(formula) { var cell = SpreadsheetApp.getActiveRange().getCell(1, 1); cell.setFormula(formula); return cell.getA1Notation(); } // ── transport ────────────────────────────────────────────────────────────── function palanorKey_() { return (PropertiesService.getUserProperties().getProperty(PALANOR_KEY) || '').trim(); } function palanorHeaders_(k) { return { Authorization: 'Bearer ' + k, Accept: 'application/json', 'X-Palanor-Client': 'palanor-sheets/' + PALANOR_VERSION }; } function palanorEpoch_() { try { return PropertiesService.getScriptProperties().getProperty(PALANOR_EPOCH) || '0'; } catch (e) { return '0'; } } function palanorBump_() { try { PropertiesService.getScriptProperties().setProperty(PALANOR_EPOCH, String(Date.now())); } catch (e) { /* read-only context */ } } function palanorQs_(params) { return Object.keys(params || {}).filter(function (k) { return params[k] != null && params[k] !== ''; }) .map(function (k) { return encodeURIComponent(k) + '=' + encodeURIComponent(params[k]); }).join('&'); } /** GET a /v1 path, cached for 15 minutes. Throws an Error whose message is shown in the cell. */ function palanorGet_(path, params) { var k = palanorKey_(); if (!k) throw new Error('Add your Palanor API key: Extensions → Palanor Data → Set API key (or the Palanor menu).'); var q = palanorQs_(params); var url = PALANOR_API + path + (q ? '?' + q : ''); var cache = CacheService.getScriptCache(); var ck = 'p1:' + Utilities.base64EncodeWebSafe(Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, palanorEpoch_() + '|' + k.slice(-6) + '|' + url)); var hit = cache.get(ck); if (hit) return JSON.parse(hit); var res, attempt = 0; while (true) { res = UrlFetchApp.fetch(url, { headers: palanorHeaders_(k), muteHttpExceptions: true }); var code = res.getResponseCode(); var retry = (code === 429 && !/request cap/i.test(res.getContentText())) || code === 502 || code === 503 || code === 504; if (!retry || attempt >= 2) break; Utilities.sleep(800 * Math.pow(2, attempt++)); } var body; try { body = JSON.parse(res.getContentText() || 'null'); } catch (e) { body = { error: res.getContentText().slice(0, 200) }; } var st = res.getResponseCode(); if (st >= 200 && st < 300) { var text = JSON.stringify(body); if (text.length < 95000) cache.put(ck, text, PALANOR_TTL_S); return body; } throw new Error(palanorMessage_(st, (body && body.error) || ('HTTP ' + st))); } function palanorMessage_(status, msg) { if (status === 404 && /not licensed|signal not found/i.test(msg)) return 'Palanor does not serve this signal outside the app: its source is display-only (licensed market data, or a source that does not allow redistribution), or the slug is wrong. Try =PALANOR_SEARCH("…").'; if (status === 402) return msg + ' Upgrade at palanor.com/api-tokens.'; if (status === 403) return msg.replace(/ Tiers: \S+$/, '') + ' Upgrade at palanor.com/api-tokens.'; if (status === 429 && /costs \d+ requests/i.test(msg)) return msg + ' Upgrade at palanor.com/api-tokens.'; if (status === 429 && /request cap/i.test(msg)) return 'Your Palanor key has used its monthly requests. Upgrade at palanor.com/api-tokens.'; if (status === 401) return 'The API did not accept your key. Palanor menu → Set API key.'; return msg; } function palanorLatestMany_(slugs) { var out = []; for (var i = 0; i < slugs.length; i += 100) { var body = palanorGet_('/latest', { slugs: slugs.slice(i, i + 100).join(',') }); out = out.concat(body.data || []); } return out; } // ── argument helpers ─────────────────────────────────────────────────────── function palanorBlank_(v) { return v == null || v === ''; } function palanorDate_(v, label) { if (palanorBlank_(v)) return null; if (Object.prototype.toString.call(v) === '[object Date]') { return Utilities.formatDate(v, SpreadsheetApp.getActiveSpreadsheet().getSpreadsheetTimeZone(), 'yyyy-MM-dd'); } var s = String(v).trim(); if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; throw new Error(label + ' must be a date or "YYYY-MM-DD".'); } var PALANOR_FREQS_ = { '': '', raw: '', d: '', day: '', daily: '', w: 'week', week: 'week', weekly: 'week', m: 'month', month: 'month', monthly: 'month', q: 'quarter', quarter: 'quarter', quarterly: 'quarter', y: 'year', a: 'year', year: 'year', annual: 'year', yearly: 'year' }; function palanorDay_(iso) { return String(iso == null ? '' : iso).slice(0, 10); } function palanorCell_(v) { if (v == null) return ''; if (Object.prototype.toString.call(v) === '[object Array]') return v.join(', '); if (typeof v === 'number' || typeof v === 'boolean') return v; return String(v); } // ── the custom functions ─────────────────────────────────────────────────── /** * The latest value of a Palanor signal. Pass a range of slugs to fill a column in one batch (one request per ten slugs). * * @param {string|Array>} slug Signal slug, e.g. "rates.spread_2y10y", or a range of slugs. * @return The latest value(s). * @customfunction */ function PALANOR_LAST(slug) { var many = Object.prototype.toString.call(slug) === '[object Array]'; var grid = many ? slug : [[slug]]; var flat = []; grid.forEach(function (row) { row.forEach(function (s) { s = String(s == null ? '' : s).trim(); if (s && flat.indexOf(s) < 0) flat.push(s); }); }); if (!flat.length) throw new Error('Pass a signal slug, e.g. "rates.spread_2y10y". Find slugs with =PALANOR_SEARCH("…").'); var by = {}; palanorLatestMany_(flat).forEach(function (it) { by[it.slug] = it; }); var out = grid.map(function (row) { return row.map(function (s) { s = String(s == null ? '' : s).trim(); if (!s) return ''; var it = by[s]; if (!it || it.error || !it.latest) { var why = it && it.error ? palanorMessage_(404, it.error) : 'No reading.'; if (!many) throw new Error(why); return '#N/A ' + why.slice(0, 90); } return Number(it.latest.value); }); }); return many ? out : out[0][0]; } /** * A signal's readings between two dates, oldest first: Date and Value, spilled. * * @param {string} slug Signal slug. * @param {string} from Optional first date (a date cell or "YYYY-MM-DD"). * @param {string} to Optional last date. * @param {string} freq Optional: blank (every reading), "W", "M", "Q" or "Y" (each period's closing reading). * @param {boolean} headers Optional: TRUE (default) adds a header row. * @return A two-column range. * @customfunction */ function PALANOR_HISTORY(slug, from, to, freq, headers) { var s = String(slug == null ? '' : slug).trim(); if (!s) throw new Error('Pass a signal slug.'); var fk = String(palanorBlank_(freq) ? '' : freq).trim().toLowerCase(); if (!(fk in PALANOR_FREQS_)) throw new Error('freq is blank, "W", "M", "Q" or "Y".'); var f = PALANOR_FREQS_[fk]; var params = { start: palanorDate_(from, 'from'), end: palanorDate_(to, 'to'), freq: f, order: 'asc', limit: 1000, offset: 0 }; var rows = []; for (var guard = 0; guard < 60; guard++) { var body = palanorGet_('/signals/' + encodeURIComponent(s) + '/observations', params); var obs = (body.data && body.data.observations) || []; obs.forEach(function (o) { rows.push([palanorDay_(o.observed_at), Number(o.value)]); }); var pg = body.pagination || {}; if (!pg.has_more || !obs.length) break; params.offset = pg.next_offset || params.offset + obs.length; } if (!rows.length) throw new Error('No readings in that window.'); if (headers !== false) rows.unshift([f ? 'Period end' : 'Date', 'Value']); return rows; } /** * Palanor Foresight for a signal at a horizon. Needs a Pro or Enterprise key. * * @param {string} slug Signal slug. * @param {string} horizon Optional: days (30), a span ("30d", "6m", "1y") or a date. Blank = the full horizon. * @param {string} output Optional: value (default), low, high, date, band or path. * @return The projection. * @customfunction */ function PALANOR_FORESIGHT(slug, horizon, output) { var s = String(slug == null ? '' : slug).trim(); if (!s) throw new Error('Pass a signal slug.'); var o = String(palanorBlank_(output) ? 'value' : output).trim().toLowerCase(); if (['value', 'low', 'high', 'date', 'band', 'path'].indexOf(o) < 0) throw new Error('output is value, low, high, date, band or path.'); var h = palanorBlank_(horizon) ? null : (Object.prototype.toString.call(horizon) === '[object Date]' ? palanorDate_(horizon, 'horizon') : String(horizon).trim()); var d = palanorGet_('/foresight/' + encodeURIComponent(s), { horizon: h }).data || {}; if (o === 'value') return d.value; if (o === 'low') return d.low; if (o === 'high') return d.high; if (o === 'date') return palanorDay_(d.target_date); if (o === 'band') return [['Date', 'Value', 'Low', 'High'], [palanorDay_(d.target_date), d.value, d.low, d.high]]; var rows = (d.path || []).map(function (p) { return [palanorDay_(p.date), p.value, p.low, p.high]; }); rows.unshift(['Date', 'Foresight', 'Low', 'High']); return rows; } /** * Palanor signals that match a search: slug, name, unit, latest value, date and source. * * @param {string} query What to look for, e.g. "yield curve". * @param {number} limit Optional: 1 to 50 (default 10). * @return A table of signals. * @customfunction */ function PALANOR_SEARCH(query, limit) { var q = String(query == null ? '' : query).trim(); if (q.length < 2) throw new Error('Search for at least two characters.'); var n = palanorBlank_(limit) ? 10 : Math.max(1, Math.min(50, Math.round(Number(limit)) || 10)); var hits = palanorGet_('/search', { q: q, limit: n }).data || []; if (!hits.length) throw new Error('Nothing matched. Try fewer or plainer words.'); var rows = [['Slug', 'Name', 'Unit', 'Latest', 'As of', 'Source']]; hits.forEach(function (h) { var p = h.provenance || {}; rows.push([h.slug, h.name || '', h.unit || '', h.latest ? Number(h.latest.value) : '', h.latest ? palanorDay_(h.latest.observed_at) : '', p.publisher || p.source || '']); }); return rows; } /** * A saved Palanor Entity Screener screen with its columns and source filings. Pro and Enterprise keys. * * @param {string} saved_screen_id The screen's id (Palanor menu → Search signals and screens lists yours). * @param {number} limit Optional: rows, 1 to 1,000 (default 100). * @param {boolean} headers Optional: TRUE (default) adds a header row. * @return The screen's rows. * @customfunction */ function PALANOR_SCREEN(saved_screen_id, limit, headers) { var id = String(saved_screen_id == null ? '' : saved_screen_id).trim(); if (!id) throw new Error('Pass a saved screen id.'); var n = palanorBlank_(limit) ? 100 : Math.max(1, Math.min(1000, Math.round(Number(limit)) || 100)); var d = palanorGet_('/screens/' + encodeURIComponent(id), { limit: n }).data || {}; var cols = d.columns || []; var rows = (d.rows || []).map(function (r) { return cols.map(function (c) { return palanorCell_(r[c.id]); }); }); if (!rows.length) throw new Error('This screen has no rows right now.'); if (headers !== false) rows.unshift(cols.map(function (c) { return c.label; })); var pad = function (s) { var r = [s]; for (var i = 1; i < cols.length; i++) r.push(''); return r; }; if (d.has_more) rows.push(pad('First ' + d.rows.length + ' of ' + (d.total != null ? d.total : 'more') + ' rows. Pass a larger limit (up to 1,000).')); rows.push(pad('Source: ' + (d.source || 'Palanor from public filings.') + ' Screen: ' + ((d.screen && d.screen.url) || ''))); if (d.notice) rows.push(pad(d.notice)); return rows; } var PALANOR_ENTITY_FIELDS_ = { name: function (e) { return e.name; }, kind: function (e) { return e.kind; }, country: function (e) { return e.country; }, region: function (e) { return e.region; }, city: function (e) { return e.city; }, hq_country: function (e) { return e.hq_country; }, lei: function (e) { return e.lei; }, cik: function (e) { return (e.ciks || []).join(', '); }, ticker: function (e) { return (e.tickers || []).join(', '); }, crd: function (e) { return e.crd; }, qid: function (e) { return e.qid; }, uei: function (e) { return (e.ueis || []).join(', '); }, gb_company_number: function (e) { return e.gb_company_number; }, legal_form: function (e) { return e.legal_form; }, status: function (e) { return e.entity_status; }, lei_status: function (e) { return e.lei_status; }, incorporated: function (e) { return e.incorporated; }, founded: function (e) { return e.founded; }, industry: function (e) { return e.industry; }, website: function (e) { return e.website; }, direct_parent: function (e, b) { var p = b.ownership && b.ownership.parents && b.ownership.parents[0]; return p ? (p.name || p.lei) : ''; }, ultimate_parent: function (e, b) { var ps = (b.ownership && b.ownership.parents) || []; var p = ps[ps.length - 1]; return p ? (p.name || p.lei) : ''; }, children: function (e, b) { return b.ownership ? b.ownership.children_total : ''; }, sources: function (e) { return (e.sources || []).join(', '); }, entity_key: function (e) { return e.entity_key; }, url: function (e) { return e.entity_key ? 'https://app.palanor.com/entities/' + encodeURIComponent(e.entity_key) : ''; }, }; /** * A fact about a company, fund or adviser in the Palanor company graph. Pro and Enterprise keys. * * @param {string} id An identifier: ticker:AAPL, cik:320193, lei:…, crd:…, qid:Q…, or a bare LEI or CIK. * @param {string} field Optional: name (default), country, lei, cik, ticker, website, ultimate_parent, url… * @return The fact. * @customfunction */ function PALANOR_ENTITY(id, field) { var s = String(id == null ? '' : id).trim(); if (!s) throw new Error('Pass an identifier, e.g. "ticker:AAPL".'); var f = String(palanorBlank_(field) ? 'name' : field).trim().toLowerCase().replace(/[\s-]+/g, '_'); if (f === 'tickers') f = 'ticker'; if (f === 'ciks') f = 'cik'; if (!PALANOR_ENTITY_FIELDS_[f]) throw new Error('field is one of: ' + Object.keys(PALANOR_ENTITY_FIELDS_).join(', ') + '.'); var b = palanorGet_('/entities/' + encodeURIComponent(s), {}); return palanorCell_(PALANOR_ENTITY_FIELDS_[f](b.entity || {}, b)); } // ── the sidebar (HTML kept inline so the add-on is one file) ─────────────── var PALANOR_SIDEBAR_ = '' + '

API key

' + '

No key? Get a free one.

' + '

Find a signal /

' + '

    ↑ ↓ to pick. Enter writes PALANOR_LAST into the selected cell, ⇧ Enter monthly history, ⌥ Enter Foresight.

    ' + '

    Screens

      ' + '

      Docs · Terms

      ' + '';