Jaspers Hub

jaspers / tradingview

pluginOfficialReviewed★ 0↓ 9

TradingView Chart: Shows TradingView's advanced chart widget in a frame, driven by panel state (symbol, interval, studies), reporting back the last quote.

11 KB
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. */
6export const WIDGET_ORIGIN = 'https://www.tradingview-widget.com'
7
8/** Bar sizes: minutes, then a day, a week, a month. */
9export 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. */
12export 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 */
20const 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. */
76const 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 */
84const 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
103export type Interval = (typeof INTERVALS)[number]
104export type Range = (typeof RANGES)[number]
105export type Style = keyof typeof STYLE_CODES
106export type Study = keyof typeof STUDY_IDS
107
108export const STYLES = Object.keys(STYLE_CODES) as [Style, ...Style[]]
109export 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 */
116export const MAX_STUDIES = 5
117
118/** Symbols drawn over the main one. More than this and one pane is a thicket. */
119export 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. */
122export const QUOTE_EVERY_MS = 15_000
123
124export 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. */
138export 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
148export 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. */
153export 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 */
159export 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 */
190export 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 */
202export 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. */
226export 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 */
248export 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. */
256export 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
270function percent(value: number | undefined): string {
271 return value === undefined ? '' : `${value > 0 ? '+' : ''}${value.toFixed(2)}%`
272}
273
274function parseJson(text: string): unknown {
275 try {
276 return JSON.parse(text)
277 } catch {
278 return null
279 }
280}
281
282function 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
286function text(value: unknown): string | undefined {
287 return typeof value === 'string' && value !== '' ? value : undefined
288}
289
290function 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. */
295function defined<T extends object>(value: T): T {
296 return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T
297}