TradingView Chart: Shows TradingView's advanced chart widget in a frame, driven by panel state (symbol, interval, studies), reporting back the last quote.
| 1 | // The plugin's pure half: what a chart panel holds, the address of TradingView's widget for it, and |
| 2 | // the quotes the widget posts back. The widget is TradingView's page in a frame, so the view never |
| 3 | // sees the bars; what it can say about the chart is what it asked for and the quote it was told. |
| 4 | |
| 5 | /** Where the widget's page is served from, and the only origin whose messages the view reads. */ |
| 6 | export const WIDGET_ORIGIN = 'https://www.tradingview-widget.com' |
| 7 | |
| 8 | /** Bar sizes: minutes, then a day, a week, a month. */ |
| 9 | export const INTERVALS = ['1', '3', '5', '15', '30', '60', '120', '180', '240', 'D', 'W', 'M'] as const |
| 10 | |
| 11 | /** How much history shows. The widget picks the bar size for a range itself. */ |
| 12 | export const RANGES = ['1D', '5D', '1M', '3M', '6M', 'YTD', '12M', '60M', 'ALL'] as const |
| 13 | |
| 14 | /** |
| 15 | * What people call the indices, futures, and macro symbols, against the symbol TradingView charts |
| 16 | * them under: 'S&P 500', 'SPX', and 'Dow' reach no chart on their own, since the widget wants an |
| 17 | * exchange. Keys are the name with everything but letters and digits dropped, so case, spaces, '&', |
| 18 | * and dots do not matter. Every symbol here was checked against TradingView's own symbol search. |
| 19 | */ |
| 20 | const SYMBOL_ALIASES: Record<string, string> = { |
| 21 | // Indices. |
| 22 | SP: 'SP:SPX', |
| 23 | SP500: 'SP:SPX', |
| 24 | SPX: 'SP:SPX', |
| 25 | SPX500: 'SP:SPX', |
| 26 | NDX: 'NASDAQ:NDX', |
| 27 | NAS100: 'NASDAQ:NDX', |
| 28 | NASDAQ100: 'NASDAQ:NDX', |
| 29 | IXIC: 'NASDAQ:IXIC', |
| 30 | NASDAQCOMPOSITE: 'NASDAQ:IXIC', |
| 31 | DJI: 'DJ:DJI', |
| 32 | DJIA: 'DJ:DJI', |
| 33 | DOW: 'DJ:DJI', |
| 34 | DOWJONES: 'DJ:DJI', |
| 35 | RUT: 'TVC:RUT', |
| 36 | RUSSELL: 'TVC:RUT', |
| 37 | RUSSELL2000: 'TVC:RUT', |
| 38 | VIX: 'CBOE:VIX', |
| 39 | DXY: 'TVC:DXY', |
| 40 | DOLLARINDEX: 'TVC:DXY', |
| 41 | // Treasury yields. |
| 42 | US10Y: 'TVC:US10Y', |
| 43 | US10YR: 'TVC:US10Y', |
| 44 | UST10Y: 'TVC:US10Y', |
| 45 | US2Y: 'TVC:US02Y', |
| 46 | US02Y: 'TVC:US02Y', |
| 47 | UST2Y: 'TVC:US02Y', |
| 48 | // Commodities. A company named for the metal keeps its own chart: NYSE:GOLD carries a prefix. |
| 49 | GOLD: 'TVC:GOLD', |
| 50 | XAU: 'TVC:GOLD', |
| 51 | SILVER: 'TVC:SILVER', |
| 52 | XAG: 'TVC:SILVER', |
| 53 | OIL: 'TVC:USOIL', |
| 54 | USOIL: 'TVC:USOIL', |
| 55 | WTI: 'TVC:USOIL', |
| 56 | CRUDE: 'TVC:USOIL', |
| 57 | CRUDEOIL: 'TVC:USOIL', |
| 58 | // Futures, as the continuous front-month contract. |
| 59 | ES: 'CME_MINI:ES1!', |
| 60 | ES1: 'CME_MINI:ES1!', |
| 61 | SPFUTURES: 'CME_MINI:ES1!', |
| 62 | SP500FUTURES: 'CME_MINI:ES1!', |
| 63 | NQ: 'CME_MINI:NQ1!', |
| 64 | NQ1: 'CME_MINI:NQ1!', |
| 65 | NASDAQFUTURES: 'CME_MINI:NQ1!', |
| 66 | // Crypto, on the venue with the deepest book the widget carries. |
| 67 | BTC: 'BINANCE:BTCUSDT', |
| 68 | BTCUSD: 'BINANCE:BTCUSDT', |
| 69 | BITCOIN: 'BINANCE:BTCUSDT', |
| 70 | ETH: 'BINANCE:ETHUSDT', |
| 71 | ETHUSD: 'BINANCE:ETHUSDT', |
| 72 | ETHEREUM: 'BINANCE:ETHUSDT', |
| 73 | } |
| 74 | |
| 75 | /** Chart types by name, and the number TradingView knows each by. */ |
| 76 | const STYLE_CODES = { candles: '1', bars: '0', line: '2', area: '3', 'heikin-ashi': '8', 'hollow-candles': '9' } as const |
| 77 | |
| 78 | /** |
| 79 | * Indicators by name, and TradingView's id for each. The ids are the widget's own: its bundle carries |
| 80 | * a table from the old `RSI@tv-basicstudies` names to these, and only ids found there are listed, so |
| 81 | * an indicator the widget would silently drop is not offered. Ichimoku and Supertrend are missing |
| 82 | * from that table, so they are left out rather than guessed at. |
| 83 | */ |
| 84 | const STUDY_IDS = { |
| 85 | rsi: 'STD;RSI', |
| 86 | macd: 'STD;MACD', |
| 87 | sma: 'STD;SMA', |
| 88 | ema: 'STD;EMA', |
| 89 | 'bollinger-bands': 'STD;Bollinger_Bands', |
| 90 | vwap: 'STD;VWAP', |
| 91 | stochastic: 'STD;Stochastic', |
| 92 | 'stochastic-rsi': 'STD;Stochastic_RSI', |
| 93 | atr: 'STD;Average_True_Range', |
| 94 | // TradingView draws ADX with the +DI and -DI lines, under the directional movement id. |
| 95 | adx: 'STD;DMI', |
| 96 | obv: 'STD;On_Balance_Volume', |
| 97 | cci: 'STD;CCI', |
| 98 | 'keltner-channels': 'STD;Keltner_Channels', |
| 99 | 'donchian-channels': 'STD;Donchian_Channels', |
| 100 | 'parabolic-sar': 'STD;PSAR', |
| 101 | } as const |
| 102 | |
| 103 | export type Interval = (typeof INTERVALS)[number] |
| 104 | export type Range = (typeof RANGES)[number] |
| 105 | export type Style = keyof typeof STYLE_CODES |
| 106 | export type Study = keyof typeof STUDY_IDS |
| 107 | |
| 108 | export const STYLES = Object.keys(STYLE_CODES) as [Style, ...Style[]] |
| 109 | export const STUDIES = Object.keys(STUDY_IDS) as [Study, ...Study[]] |
| 110 | |
| 111 | /** |
| 112 | * The widget takes five indicators and drops the rest: its embed slices the studies it was given to |
| 113 | * five unless the page is a TradingView customer's. Volume is not one of them, since the widget |
| 114 | * draws it under the price by itself. |
| 115 | */ |
| 116 | export const MAX_STUDIES = 5 |
| 117 | |
| 118 | /** Symbols drawn over the main one. More than this and one pane is a thicket. */ |
| 119 | export const MAX_COMPARE = 3 |
| 120 | |
| 121 | /** How often a price that keeps moving is published: every publish is a push of the whole app state. */ |
| 122 | export const QUOTE_EVERY_MS = 15_000 |
| 123 | |
| 124 | export interface State { |
| 125 | /** EXCHANGE:TICKER, like NASDAQ:AAPL, once resolved. Empty until one is set. */ |
| 126 | symbol: string |
| 127 | interval: Interval |
| 128 | range?: Range |
| 129 | style: Style |
| 130 | studies: Study[] |
| 131 | /** Symbols drawn over the main one for comparison. Absent when there are none. */ |
| 132 | compare?: string[] |
| 133 | /** Pre- and post-market bars, where the exchange has them. Absent means regular hours. */ |
| 134 | extendedHours?: boolean |
| 135 | } |
| 136 | |
| 137 | /** The latest quote the widget posted: the symbol it is showing and its price. */ |
| 138 | export interface Quote { |
| 139 | name: string |
| 140 | exchange?: string |
| 141 | description?: string |
| 142 | last: number |
| 143 | change?: number |
| 144 | changePercent?: number |
| 145 | volume?: number |
| 146 | } |
| 147 | |
| 148 | export interface Output extends State { |
| 149 | quote?: Quote |
| 150 | } |
| 151 | |
| 152 | /** The app's light or dark, which the widget draws in. The view reads it from its page; a panel's state never holds it. */ |
| 153 | export type Scheme = 'light' | 'dark' |
| 154 | |
| 155 | /** |
| 156 | * The panel's state with its defaults, read the forgiving way: main checks what is written against |
| 157 | * the schema, but stores it as written, so a key that was never set is simply not there. |
| 158 | */ |
| 159 | export function readState(raw: unknown): State { |
| 160 | const state = record(raw) ?? {} |
| 161 | const range = RANGES.find((r) => r === state['range']) |
| 162 | const studies: Study[] = [] |
| 163 | for (const study of Array.isArray(state['studies']) ? state['studies'] : []) { |
| 164 | if (typeof study === 'string' && Object.hasOwn(STUDY_IDS, study) && !studies.includes(study as Study)) studies.push(study as Study) |
| 165 | } |
| 166 | const style = state['style'] |
| 167 | const symbol = resolveSymbol(state['symbol']) |
| 168 | const compare: string[] = [] |
| 169 | for (const written of Array.isArray(state['compare']) ? state['compare'] : []) { |
| 170 | const other = resolveSymbol(written) |
| 171 | // The main symbol drawn over itself is a line on a line. |
| 172 | if (other !== '' && other !== symbol && !compare.includes(other)) compare.push(other) |
| 173 | } |
| 174 | return { |
| 175 | symbol, |
| 176 | interval: INTERVALS.find((i) => i === state['interval']) ?? 'D', |
| 177 | ...(range ? { range } : {}), |
| 178 | style: typeof style === 'string' && Object.hasOwn(STYLE_CODES, style) ? (style as Style) : 'candles', |
| 179 | studies: studies.slice(0, MAX_STUDIES), |
| 180 | ...(compare.length ? { compare: compare.slice(0, MAX_COMPARE) } : {}), |
| 181 | ...(state['extendedHours'] === true ? { extendedHours: true } : {}), |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | /** |
| 186 | * The symbol TradingView charts for what was written: an alias as the symbol it stands for, anything |
| 187 | * else as written, in the case the widget uses. A symbol that already names its exchange is never |
| 188 | * rewritten, so NYSE:GOLD stays Barrick while 'gold' is the metal. |
| 189 | */ |
| 190 | export function resolveSymbol(raw: unknown): string { |
| 191 | if (typeof raw !== 'string') return '' |
| 192 | const written = raw.trim().toUpperCase() |
| 193 | if (written === '' || written.includes(':')) return written |
| 194 | return SYMBOL_ALIASES[written.replace(/[^A-Z0-9]/g, '')] ?? written |
| 195 | } |
| 196 | |
| 197 | /** |
| 198 | * The widget's page for a chart, built the way TradingView's embed script builds it: the locale in |
| 199 | * the query, and the chart as JSON in the hash, so changing the chart is loading another address. |
| 200 | * Its theme is the app's scheme, in the hash like the rest, so light and dark are two addresses. |
| 201 | */ |
| 202 | export function widgetUrl(state: State, scheme: Scheme): string { |
| 203 | const chart = { |
| 204 | symbol: state.symbol, |
| 205 | interval: state.interval, |
| 206 | ...(state.range ? { range: state.range } : {}), |
| 207 | style: STYLE_CODES[state.style], |
| 208 | studies: state.studies.map((study) => STUDY_IDS[study]), |
| 209 | // The widget's compare only acts on SameScale, NewPriceScale, and NewPane; an overlay in the |
| 210 | // price pane is what comparing two symbols on one chart means here. |
| 211 | ...(state.compare?.length |
| 212 | ? { compareSymbols: state.compare.map((symbol) => ({ symbol, position: 'SameScale' })) } |
| 213 | : {}), |
| 214 | ...(state.extendedHours ? { extended_hours: true } : {}), |
| 215 | theme: scheme, |
| 216 | autosize: true, |
| 217 | allow_symbol_change: true, |
| 218 | support_host: 'https://www.tradingview.com', |
| 219 | // What the embed script sends for a page that is not http(s). The chart opens its data connection with it. |
| 220 | 'page-uri': '__NHTTP__', |
| 221 | } |
| 222 | return `${WIDGET_ORIGIN}/embed-widget/advanced-chart/?locale=en#${encodeURIComponent(JSON.stringify(chart))}` |
| 223 | } |
| 224 | |
| 225 | /** A quoteUpdate the widget posted, as a quote; anything else it posts is null. */ |
| 226 | export function readQuote(message: unknown): Quote | null { |
| 227 | const post = record(typeof message === 'string' ? parseJson(message) : message) |
| 228 | const data = post?.['name'] === 'quoteUpdate' ? record(post['data']) : null |
| 229 | if (!data) return null |
| 230 | const name = text(data['short_name']) |
| 231 | const last = finite(data['last_price']) |
| 232 | if (name === undefined || last === undefined) return null |
| 233 | return defined({ |
| 234 | name, |
| 235 | exchange: text(data['exchange']), |
| 236 | description: text(data['description']), |
| 237 | last, |
| 238 | change: finite(data['change']), |
| 239 | changePercent: finite(data['change_percent']), |
| 240 | volume: finite(data['volume']), |
| 241 | }) |
| 242 | } |
| 243 | |
| 244 | /** |
| 245 | * The quote to publish now, or null to leave the published one. A symbol the chart has just started |
| 246 | * showing goes out at once; the same symbol's moving price waits out the interval. |
| 247 | */ |
| 248 | export function nextQuote(shown: Quote | null, shownAt: number, latest: Quote | null, now: number): Quote | null { |
| 249 | if (!latest) return null |
| 250 | if (!shown || latest.name !== shown.name) return latest |
| 251 | if (now - shownAt < QUOTE_EVERY_MS) return null |
| 252 | return JSON.stringify(latest) === JSON.stringify(shown) ? null : latest |
| 253 | } |
| 254 | |
| 255 | /** The line the model's map carries for a chart. */ |
| 256 | export function summarize(state: unknown, output: unknown): string { |
| 257 | const chart = readState(state) |
| 258 | const quote = record(record(output)?.['quote']) as Quote | null |
| 259 | const head = quote ? [quote.name, String(quote.last), percent(quote.changePercent)].filter(Boolean).join(' ') : chart.symbol |
| 260 | return [ |
| 261 | `Chart: ${head}`, |
| 262 | chart.range ?? chart.interval, |
| 263 | chart.style, |
| 264 | ...chart.studies, |
| 265 | ...(chart.compare ? [`vs ${chart.compare.join(' ')}`] : []), |
| 266 | ...(chart.extendedHours ? ['extended hours'] : []), |
| 267 | ].join(', ') |
| 268 | } |
| 269 | |
| 270 | function percent(value: number | undefined): string { |
| 271 | return value === undefined ? '' : `${value > 0 ? '+' : ''}${value.toFixed(2)}%` |
| 272 | } |
| 273 | |
| 274 | function parseJson(text: string): unknown { |
| 275 | try { |
| 276 | return JSON.parse(text) |
| 277 | } catch { |
| 278 | return null |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | function record(value: unknown): Record<string, unknown> | null { |
| 283 | return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null |
| 284 | } |
| 285 | |
| 286 | function text(value: unknown): string | undefined { |
| 287 | return typeof value === 'string' && value !== '' ? value : undefined |
| 288 | } |
| 289 | |
| 290 | function finite(value: unknown): number | undefined { |
| 291 | return typeof value === 'number' && Number.isFinite(value) ? value : undefined |
| 292 | } |
| 293 | |
| 294 | /** The same object without its undefined keys, so what is published says only what is known. */ |
| 295 | function defined<T extends object>(value: T): T { |
| 296 | return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T |
| 297 | } |