/* * FirmTape Levels for TrendSpider (custom JS indicator). * * Draws FirmTape's dealer-gamma levels on a TrendSpider price chart: the zero-gamma flip, * call resistance (the call wall) and put support (the put wall) of the VOLUME book, the * book the FirmTape terminal draws, plus, optionally, the open-interest book's levels and * the strikes carrying the most gamma. A status note in the corner says which symbol was * read, whether its session is open, how old the book is, and any error. * * Data: GET https://firmtape.com/api/v1/levels and /api/v1/gamma with the account's API * token (firmtape.com/account, API tokens). The API is part of the LIVE plan. * * HOW TRENDSPIDER RUNS THIS (from its scripting docs): * - The whole script runs again on every real-time update of the chart, with no state * kept between runs. request.http() answers from TrendSpider's own cache until the TTL * passes (never under 5 s; fresh data can land up to ~10 s after the TTL), so the TTL * below IS the refresh cadence. The default of 10 s keeps one chart at about 6 calls a * minute against the API limit of 120 a minute per token. * - Every paint() must run on every execution, in the same order, or TrendSpider drops the * lines it did not see when the indicator was saved. So all 11 lines are always painted; * a level that is off or missing is painted as an empty series. * - request.http() may only send the authorization, user-agent, x-api-key and accept * headers, calls are proxied through TrendSpider (no CORS), time out after 3 s, and a * script may make at most 6 of them. This one makes 1, or 2 with gamma strikes on. * * DATED FUTURES CHARTS: a chart on a dated ES / MES / NQ / MNQ contract (ESH2027, MESH27) * asks for that contract (ESH27; micro contracts read the full-size view, same prices) and * its levels move by the contract's own basis. The API measures the next quarterly only in * the two weeks before a roll and answers "contract_not_live" otherwise, so the script asks * for both in ONE call, sym=ESH27,ES, and draws the front contract (ES) when the chart's own * is not measured, with a warning in the note. No state survives between runs, so this one * combined call is what keeps the fallback from costing a second request. Continuous charts * (ES1!, ES#F) ask plain ES, the front quarterly. * * The functions in the first half are pure (no TrendSpider globals) so that * test/levels_test.js can run them in Node. */ // ------------------------------------------------------------------ pure helpers const FT_BASE = 'https://firmtape.com'; const FT_MIN_TTL = 6; // TrendSpider wants more than 5 s const FT_MAX_TTL = 300; const FT_MAX_GAMMA_LINES = 5; const FT_SYM_RE = /^[A-Z]{1,5}$/; const FT_MONTHS = 'FGHJKMNQUVXZ'; const FT_MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; // The API's dated symbol: ESH27, MNQH27, ESH7. const FT_DATED_RE = /^(M?)(ES|NQ)([FGHJKMNQUVXZ])(\d{1,2})$/; const FT_COLORS = { flip: '#E0A040', call: '#3FB950', put: '#F85149', oiFlip: '#E0A04099', oiCall: '#3FB95099', oiPut: '#F8514999', gammaPos: '#3FB950', gammaNeg: '#F85149' }; // Chart roots and tickers that read another FirmTape symbol. const FT_ALIASES = { ES: 'ES', MES: 'ES', NQ: 'NQ', MNQ: 'NQ', SPX: 'SPX', SPXW: 'SPX', NDX: 'NDX', NDXP: 'NDX', RUT: 'RUT', RUTW: 'RUT', XSP: 'XSP' }; /** * The FirmTape symbol for the chart's instrument, or null when there is no sensible one. * `ticker` is TrendSpider's current.ticker, `root` its current.root (futures only), * `assetType` its current.assetType. ES and MES read ES, NQ and MNQ read NQ, SPXW reads * SPX; a stock or ETF ticker reads itself and the API says whether it is covered. */ function mapChartSymbol(ticker, root, assetType) { const clean = (s) => String(s || '').trim().toUpperCase(); const futuresRoot = clean(root).replace(/[^A-Z]/g, ''); if (futuresRoot && (assetType === 'futures' || !ticker)) { return FT_ALIASES[futuresRoot] === 'ES' || FT_ALIASES[futuresRoot] === 'NQ' ? FT_ALIASES[futuresRoot] : null; } let t = clean(ticker) .replace(/^(I:|\$|\^|\.|@|\/)/, '') // index and futures prefixes: $SPX, ^SPX, I:SPX, @ES, /ES .replace(/(\.X|\.XO|\.IND)$/, '') // index suffixes: SPX.X .replace(/(\d+!|#F|_F|=F|#)$/, ''); // continuous futures: ES1!, ES#F, ES_F, ES=F // A dated future: ESZ2026, ESZ26, ESZ6, MNQH27. const dated = new RegExp(`^(M?ES|M?NQ)[${FT_MONTHS}]\\d{1,4}$`).exec(t); if (dated) t = dated[1]; if (FT_ALIASES[t]) return FT_ALIASES[t]; if (assetType === 'futures' || assetType === 'crypto' || assetType === 'crypto_futures' || assetType === 'fx') return null; return FT_SYM_RE.test(t) ? t : null; } /** * The API's dated symbol for the chart's contract, or null when the chart is not on a dated * ES / MES / NQ / MNQ contract. "ESH2027", "ESH27" and "ESH7" -> "ESH27"; "MESZ2026" -> * "ESZ26" (micro contracts read the full-size view); "ES1!", "ES#F", "ES" -> null. */ function chartContract(ticker) { const t = String(ticker || '').trim().toUpperCase().replace(/^(\$|\^|\.|@|\/)/, ''); const m = new RegExp(`^M?(ES|NQ)([${FT_MONTHS}])(\\d{1,4})$`).exec(t); if (!m || m[3].length === 3) return null; const yy = m[3].length === 1 ? `2${m[3]}` : m[3].slice(-2); return `${m[1]}${m[2]}${yy}`; } /** "ESH27" -> "ES", "MNQZ26" -> "NQ"; null for anything not a dated symbol. */ function frontOf(sym) { const m = FT_DATED_RE.exec(String(sym || '').toUpperCase()); return m ? m[2] : null; } /** * The symbol to ask for: the input when it names one, else the chart's. * Returns { sym, from: 'input' | 'chart' } or { sym: null, error }. A dated contract also * carries `front`, the plain view it falls back to: { sym: 'ESH27', from, front: 'ES' }. */ function resolveSymbol(inputValue, chart) { const raw = String(inputValue || '').trim().toUpperCase().replace(/^\$/, ''); if (raw && raw !== 'AUTO') { const m = FT_DATED_RE.exec(raw); if (m) { const d = `${m[2]}${m[3]}${m[4]}`; return { sym: d, from: 'input', front: m[2] }; } const s = FT_ALIASES[raw] || raw; return FT_SYM_RE.test(s) ? { sym: s, from: 'input' } : { sym: null, error: `"${inputValue}" is not a symbol` }; } const c = chart || {}; const s = mapChartSymbol(c.ticker, c.root, c.assetType); if (!s) return { sym: null, error: `no FirmTape symbol for ${c.ticker || 'this chart'}; set the FirmTape symbol input` }; const d = (s === 'ES' || s === 'NQ') ? chartContract(c.ticker) : null; return d && d.startsWith(s) ? { sym: d, from: 'chart', front: s } : { sym: s, from: 'chart' }; } /** * The first day the API measures a contract: 22 days before its expiry, the third Friday of * the contract month. "ESZ26" -> Date of 2026-11-26 (UTC). Null for a non-dated symbol. */ function rollWindowStart(contract) { const m = FT_DATED_RE.exec(String(contract || '').toUpperCase()); if (!m) return null; const month = FT_MONTHS.indexOf(m[3]); const year = m[4].length === 1 ? 2020 + Number(m[4]) : 2000 + Number(m[4]); const first = new Date(Date.UTC(year, month, 1)); const toFriday = (5 - first.getUTCDay() + 7) % 7; return new Date(Date.UTC(year, month, 1 + toFriday + 14 - 22)); } /** * The note's warning when the levels drawn are not for the chart's contract (the API said * contract_not_live and the front was drawn): "levels are for ESZ26; ESH27 is measured from * Feb 25" before the roll window opens, else "... is not measured today". Null when they * match, or when the chart is not on a dated contract. */ function contractWarning(chartSym, levelContract, nowSec) { const unmicro = (x) => String(x || '').toUpperCase().replace(/^M(?=(ES|NQ))/, ''); const c = unmicro(chartSym), l = unmicro(levelContract); if (!FT_DATED_RE.test(c) || !l || c === l) return null; const start = rollWindowStart(c); const today = Math.floor(nowSeconds(nowSec) / 86400) * 86400; if (start && today < start.getTime() / 1000) { return `levels are for ${l}; ${c} is measured from ${FT_MONTH_NAMES[start.getUTCMonth()]} ${start.getUTCDate()}`; } return `levels are for ${l}; ${c} is not measured today`; } /** The refresh TTL TrendSpider is given, clamped to what it accepts. */ function clampTtl(v) { const n = Number(v); if (!Number.isFinite(n)) return 10; return Math.min(FT_MAX_TTL, Math.max(FT_MIN_TTL, Math.round(n))); } /** One symbol, or a list asked in one call (sym=ESH27,ES: the dated contract and its front). */ function levelsUrl(sym) { const list = Array.isArray(sym) ? sym : [sym]; return `${FT_BASE}/api/v1/levels?sym=${list.map((x) => encodeURIComponent(x)).join(',')}`; } function gammaUrl(sym, book) { return `${FT_BASE}/api/v1/gamma?sym=${encodeURIComponent(sym)}&book=${book === 'oi' ? 'oi' : 'vol'}`; } function authHeaders(key) { return { authorization: `Bearer ${String(key || '').trim()}`, accept: 'application/json' }; } /** * A readable message for a failed call. TrendSpider hands back `{ error }` for any non-2xx * answer; the exact text is TrendSpider's, so this looks for the HTTP status and for * FirmTape's error codes wherever they appear in it. */ function httpErrorMessage(err) { const text = typeof err === 'string' ? err : (() => { try { return JSON.stringify(err); } catch (e) { return String(err); } })(); const has = (re) => re.test(text); if (has(/live_required|\b403\b/)) return 'This account has no LIVE plan (403). The levels API is part of LIVE: firmtape.com/pricing'; if (has(/no_key/)) return 'No API token sent (401). Paste your token from firmtape.com/account, API tokens'; if (has(/bad_key|\b401\b/)) return 'Token unknown or revoked (401). Create one on firmtape.com/account, API tokens'; if (has(/rate_limited|\b429\b/)) { const m = /retry[-_ ]?after\D{0,6}(\d+)/i.exec(text); return `Rate limited (429): over 120 calls a minute on this token${m ? `, retry in ${m[1]} s` : ''}. Raise Refresh or use fewer charts`; } if (has(/unknown_symbol/)) return 'FirmTape does not cover this symbol; see firmtape.com/gex for the list'; if (has(/no_basis/)) return 'The futures basis for today is not measured yet; the index levels exist, the offset does not'; if (has(/no_data|\b404\b/)) return 'FirmTape has no reading for this symbol yet'; if (has(/\b5\d\d\b|unavailable|timeout|timed out|ETIMEDOUT|ECONNRESET/i)) return 'FirmTape did not answer in time; the next refresh retries'; return `Request failed: ${text.slice(0, 160)}`; } /** A response as a parsed object, or { error }. Strings are parsed when they are JSON. */ function normalizeBody(resp) { if (resp == null) return { error: 'empty response' }; let body = resp; if (typeof body === 'string') { try { body = JSON.parse(body); } catch (e) { return { error: `not JSON: ${body.slice(0, 80)}` }; } } if (typeof body !== 'object') return { error: 'unexpected response' }; return body; } const ftNum = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null); /** * One symbol's reading from a /api/v1/levels answer. * Returns { ok: true, level } with only the fields drawn, or { ok: false, error }. */ function parseLevels(resp, sym) { const body = normalizeBody(resp); if (body.error) return { ok: false, error: httpErrorMessage(body.error) }; const list = Array.isArray(body.levels) ? body.levels : null; if (!list || !list.length) return { ok: false, error: 'The answer carried no levels' }; const want = String(sym || '').toUpperCase(); // Asked as "ESH27,ES", both entries say sym "ES": the dated one names itself in `requested`. const L = list.find((x) => x && x.requested === want) || list.find((x) => x && x.sym === want && !x.requested) || list.find((x) => x && x.sym === want) || list[0]; if (L.error) { const code = typeof L.error === 'object' ? L.error.code || null : null; return { ok: false, code, live: (L.error && L.error.live) || null, error: httpErrorMessage(L.error.code || L.error.message || L.error) }; } const oi = L.oi && typeof L.oi === 'object' ? L.oi : null; const ng = L.net_gamma && typeof L.net_gamma === 'object' ? L.net_gamma : {}; return { ok: true, level: { sym: L.sym, name: L.name || L.sym, session: L.session || null, price: ftNum(L.price), flip: ftNum(L.flip), call_wall: ftNum(L.call_wall), put_wall: ftNum(L.put_wall), net_gamma: ftNum(ng.value), net_gamma_sign: ng.sign || null, unit: ng.unit || null, as_of: L.as_of || null, as_of_unix: ftNum(L.as_of_unix), age_s: ftNum(L.age_s), cadence: L.cadence && L.cadence.book ? L.cadence.book : null, contract: L.contract || null, basis: L.basis && ftNum(L.basis.value) !== null ? L.basis.value : null, grade: L.grade || null, flip_status: L.flip_status || null, oi: oi ? { flip: ftNum(oi.flip), call_wall: ftNum(oi.call_wall), put_wall: ftNum(oi.put_wall) } : null } }; } /** * The reading for a resolved symbol from one /levels answer: the dated contract when the API * measures it, else (contract_not_live) the front view asked in the same call. * Returns parseLevels' shape plus `sym`, the symbol whose reading it is. */ function pickLevels(resp, resolved) { const r = parseLevels(resp, resolved.sym); // unknown_symbol too: a server from before dated symbols answers that for ESH27. if (!r.ok && (r.code === 'contract_not_live' || r.code === 'unknown_symbol') && resolved.front) { return Object.assign(parseLevels(resp, resolved.front), { sym: resolved.front }); } return Object.assign(r, { sym: resolved.sym }); } /** * The `count` strikes with the most gamma (by size) from a /api/v1/gamma answer, strongest * first. Returns { ok: true, strikes: [{ strike, gamma }], unit, book } or { ok: false, error }. */ function pickGammaStrikes(resp, count) { const body = normalizeBody(resp); if (body.error) return { ok: false, error: httpErrorMessage(body.error) }; const ks = Array.isArray(body.strikes) ? body.strikes : []; const gs = Array.isArray(body.gamma) ? body.gamma : []; const n = Math.max(0, Math.min(FT_MAX_GAMMA_LINES, Math.floor(Number(count) || 0))); const pts = []; for (let i = 0; i < Math.min(ks.length, gs.length); i++) { if (ftNum(ks[i]) !== null && ftNum(gs[i]) !== null && gs[i] !== 0) pts.push({ strike: ks[i], gamma: gs[i] }); } pts.sort((a, b) => Math.abs(b.gamma) - Math.abs(a.gamma) || a.strike - b.strike); return { ok: true, strikes: pts.slice(0, n), unit: body.unit || null, book: body.book || null }; } /** "$812M/pt", "-$1.24B/1%": gamma in the unit the API names. */ function formatGamma(value, unit) { if (ftNum(value) === null) return 'n/a'; const usd = unit === 'usd_m_per_point' ? value * 1e6 : value; const a = Math.abs(usd); const [div, suf] = a >= 1e12 ? [1e12, 'T'] : a >= 1e9 ? [1e9, 'B'] : a >= 1e6 ? [1e6, 'M'] : a >= 1e3 ? [1e3, 'K'] : [1, '']; const digits = a / div >= 100 ? 0 : a / div >= 10 ? 1 : 2; const per = unit === 'usd_m_per_point' ? '/pt' : unit === 'usd_per_1pct' ? '/1%' : ''; return `${usd < 0 ? '-' : ''}$${(a / div).toFixed(digits)}${suf}${per}`; } /** "4 s", "12 min", "3 h 5 min", "2 d". */ function formatAge(sec) { if (ftNum(sec) === null || sec < 0) return 'n/a'; const s = Math.round(sec); if (s < 60) return `${s} s`; if (s < 3600) return `${Math.floor(s / 60)} min`; if (s < 86400) { const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60); return m ? `${h} h ${m} min` : `${h} h`; } return `${Math.floor(s / 86400)} d`; } /** Epoch seconds from TrendSpider's current.now (documented as a Unix time stamp; ms tolerated). */ function nowSeconds(now) { const n = Number(now); if (!Number.isFinite(n) || n <= 0) return Math.floor(Date.now() / 1000); return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n); } /** The book's age at this run: now minus as_of_unix, else the age_s the API sent. */ function ageOf(level, nowSec) { if (!level) return null; if (ftNum(level.as_of_unix) !== null) return Math.max(0, nowSec - level.as_of_unix); return ftNum(level.age_s); } /** "10:15:03 ET" from "2026-09-30T10:15:03-04:00". */ function etClock(iso) { const m = /T(\d{2}:\d{2}:\d{2})/.exec(String(iso || '')); return m ? `${m[1]} ET` : 'n/a'; } function fmtPrice(v) { if (ftNum(v) === null) return 'n/a'; return Math.abs(v - Math.round(v)) < 1e-9 ? String(Math.round(v)) : v.toFixed(2).replace(/0$/, ''); } /** * Everything the chart shows for one run, decided without TrendSpider: which value each of * the 11 lines carries (null = empty), the label text of each, and the status note rows. */ function buildView(opts) { const o = opts || {}; const L = o.level || null; const lines = [ { name: 'Flip', value: L ? L.flip : null, color: FT_COLORS.flip, thickness: 2, label: 'Flip' }, { name: 'Call resistance', value: L ? L.call_wall : null, color: FT_COLORS.call, thickness: 2, label: 'Call res.' }, { name: 'Put support', value: L ? L.put_wall : null, color: FT_COLORS.put, thickness: 2, label: 'Put sup.' }, { name: 'OI flip', value: L && o.showOi && L.oi ? L.oi.flip : null, color: FT_COLORS.oiFlip, thickness: 1, label: 'OI flip' }, { name: 'OI call wall', value: L && o.showOi && L.oi ? L.oi.call_wall : null, color: FT_COLORS.oiCall, thickness: 1, label: 'OI call' }, { name: 'OI put wall', value: L && o.showOi && L.oi ? L.oi.put_wall : null, color: FT_COLORS.oiPut, thickness: 1, label: 'OI put' } ]; const strikes = (o.gamma && o.gamma.ok && o.gamma.strikes) || []; for (let i = 0; i < FT_MAX_GAMMA_LINES; i++) { const g = strikes[i]; lines.push({ name: `Gamma strike ${i + 1}`, value: g ? g.strike : null, color: g && g.gamma < 0 ? FT_COLORS.gammaNeg : FT_COLORS.gammaPos, thickness: 1, label: g ? `${o.gamma.book === 'open_interest' ? 'OI ' : ''}${formatGamma(g.gamma, o.gamma.unit)}` : '' }); } for (const ln of lines) { if (ln.value === null || ln.value === undefined) { ln.value = null; ln.labelText = null; continue; } ln.labelText = ln.name.startsWith('Gamma strike') ? `${fmtPrice(ln.value)} ${ln.label}` : `${ln.label} ${fmtPrice(ln.value)}`; } const errors = []; if (o.error) errors.push(o.error); if (o.gamma && !o.gamma.ok && o.gamma.error) errors.push(`Gamma strikes: ${o.gamma.error}`); const rows = []; const title = o.sym ? `FirmTape ${o.sym}${o.symFrom === 'input' ? '' : ' (auto)'}` : 'FirmTape'; rows.push({ key: 'title', text: title }); if (L) { const age = ageOf(L, o.nowSec); const session = L.session === 'open' ? 'Open' : L.session === 'stale' ? 'STALE' : L.session === 'closed' ? 'Closed, last session' : (L.session || 'n/a'); rows.push({ key: 'session', text: `${session} | book ${etClock(L.as_of)} (${formatAge(age)} old)` }); rows.push({ key: 'gamma', text: `Net gamma ${formatGamma(L.net_gamma, L.unit)}${L.net_gamma_sign ? `, ${L.net_gamma_sign}` : ''} | volume book` }); const extra = []; if (L.cadence) extra.push(`rebuilt every ${L.cadence === '1s' ? 'second' : L.cadence === '5m' ? '5 min' : L.cadence}`); if (L.contract) extra.push(`${L.contract}${L.basis !== null ? `, basis ${L.basis}` : ''}`); if (L.grade) extra.push(`grade ${L.grade}`); if (L.flip_status && L.flip_status !== 'ok') extra.push(`flip ${String(L.flip_status).replace(/_/g, ' ')}`); if (extra.length) rows.push({ key: 'meta', text: extra.join(' | ') }); if (L.flip === null) rows.push({ key: 'note', text: 'No flip in range: the book is one-signed' }); const warn = contractWarning(o.chartContract, L.contract, o.nowSec); if (warn) rows.push({ key: 'warn', text: warn }); } for (const e of errors) rows.push({ key: 'error', text: e }); rows.push({ key: 'refresh', text: `Refresh about every ${o.ttl || 10} s` }); return { lines, rows, stale: !!(L && L.session === 'stale'), hasError: errors.length > 0 }; } /** TrendSpider overlay rows from buildView rows. Uses the theme's CSS variables. */ function overlayDefinition(rows) { const base = { color: 'var(--text-color)', fontSize: '11px', padding: '1px 6px', whiteSpace: 'nowrap' }; return { background: 'var(--background-color)', border: 'solid 1px var(--border-color)', borderRadius: '4px', rows: rows.map((r) => ({ cells: [Object.assign({ text: r.text }, base, r.key === 'title' ? { color: FT_COLORS.flip, fontWeight: 'bold', fontSize: '12px' } : {}, r.key === 'error' || r.key === 'warn' ? { color: FT_COLORS.put, whiteSpace: 'normal', maxWidth: '360px' } : {}, r.key === 'refresh' ? { color: 'var(--border-color)', fontSize: '10px' } : {})] })) }; } // ------------------------------------------------------------------ TrendSpider runtime describe_indicator('FirmTape Levels', 'price', { shortName: 'FirmTape', decimals: 'by_symbol' }); const ftKey = input.text('FirmTape API token', '', { hide_in_legend: true }); const ftSymbolInput = input.text('FirmTape symbol (auto = from chart)', 'auto'); const ftShowOi = input.boolean('Show open-interest levels', false); const ftGammaCount = Number(input.select('Gamma strikes to draw', '0', ['0', '1', '2', '3', '4', '5'])); const ftGammaBook = input.select('Gamma strikes book', 'vol', ['vol', 'oi']); const ftShowLabels = input.boolean('Show line labels', true); const ftShowNote = input.boolean('Show status note', true); const ftTtl = clampTtl(input.number('Refresh, seconds (6 or more)', 10, { min: FT_MIN_TTL, max: FT_MAX_TTL })); const ftSpan = Math.max(0, Math.floor(Number(input.number('Bars to span (0 = whole chart)', 0, { min: 0 })) || 0)); const ftChart = { ticker: current.ticker, root: current.root, assetType: current.assetType }; const ftResolved = resolveSymbol(ftSymbolInput, ftChart); let ftLevel = null; let ftGamma = null; let ftError = null; if (!String(ftKey || '').trim()) { ftError = 'Paste your API token (firmtape.com/account, API tokens) into the indicator settings. LIVE plan required'; } else if (!ftResolved.sym) { ftError = ftResolved.error; } else { const headers = authHeaders(ftKey); const asked = ftResolved.front ? [ftResolved.sym, ftResolved.front] : ftResolved.sym; const parsed = pickLevels(await request.http(levelsUrl(asked), ftTtl, headers), ftResolved); if (parsed.ok) ftLevel = parsed.level; else ftError = parsed.error; // The profile of whichever contract the levels are for. if (ftGammaCount > 0) ftGamma = pickGammaStrikes(await request.http(gammaUrl(parsed.sym, ftGammaBook), ftTtl, headers), ftGammaCount); } const ftView = buildView({ level: ftLevel, gamma: ftGamma, error: ftError, sym: ftResolved.sym, symFrom: ftResolved.from, chartContract: ftResolved.front ? ftResolved.sym : null, showOi: ftShowOi, ttl: ftTtl, nowSec: nowSeconds(current.now) }); const ftLastIndex = close.length - 1; const ftFrom = ftSpan > 0 ? Math.max(0, close.length - ftSpan) : 0; // All 11 lines, every run, same order (TrendSpider keys lines by the names seen at save). for (const ln of ftView.lines) { const series = ln.value === null ? series_of(null) : horizontal_line(ln.value, ftFrom); const ref = paint(series, { name: ln.name, color: ln.color, thickness: ln.thickness, hideInScriptEditor: true, ignoreWhenScaling: !ln.name.startsWith('Flip') && !ln.name.startsWith('Call') && !ln.name.startsWith('Put') }); if (ftShowLabels && ln.labelText && ftLastIndex >= 0) { paint_label_at_line(ref, ftLastIndex, ln.labelText, { color: '#FFFFFF', background_color: ln.color, border_color: ln.color, border_width: 1, border_radius: 2, vertical_align: 'middle' }); } } paint_overlay('FirmTape status', { position: 'top_right', offset_y: 24 }, ftShowNote || ftView.hasError ? overlayDefinition(ftView.rows) : { rows: [] });