A live stock pane (/ticker): a scrolling tape, prices, day change and an intraday sparkline for your watchlist, from Yahoo Finance.

A live stock pane for your Claude Code terminal. Type /ticker and a pane opens beside the transcript: a tape of your watchlist scrolls across the top, and below it every symbol has its price, the day's change in points and percent, and a sparkline of the day. Prices that just moved flash up or down, and pressing a symbol asks Claude how it is doing today.
At the prompt of a Claude Code terminal session:
/plugin install vkazas-ticker --marketplace VkaZas/claude-code-mods
Answer y to add the marketplace, then pick a scope (user is the first). From your shell instead (Claude Code 2.1.292 or later):
claude plugin install vkazas-ticker --marketplace VkaZas/claude-code-mods
| Command | Does |
|---|---|
/ticker | Open the pane |
/ticker add NVDA TSLA | Add symbols to the watchlist |
/ticker rm TSLA | Remove symbols |
The watchlist starts as SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, AMZN, TSLA and BTC-USD, and is remembered across sessions once you change it. Any symbol Yahoo Finance knows works: stocks, ETFs, indexes such as ^GSPC, currencies such as EURUSD=X, and crypto such as ETH-USD. The top line shows whether the market is open, in pre-market, after hours or closed, and the time of the last trade.
| Option | Values | Default |
|---|---|---|
| Refresh (seconds) | 5, 10, 30, 60 | 10 |
| Up and down colors | us (green up, red down), cn (red up, green down) | us |
| Language | en, zh | en |
While the pane is open, and only then, the mod asks Yahoo Finance's public quote endpoint (query1.finance.yahoo.com/v7/finance/spark) for your watchlist's symbols at the refresh interval. The request carries the symbols and nothing else: no key, no account, nothing about you or your session. That endpoint is unofficial; Yahoo may delay quotes, limit how often you ask (the pane then shows the error and keeps the last prices) or change it without notice. The prices are for a glance, not for trading decisions.
session.start: restores your saved watchlist, registers /ticker, and starts a quarter-second clock that moves the tape and refreshes the prices, idle while the pane is closed.command.run, for /ticker only: opens the pane, or adds and removes symbols, and answers with one line of text.ui.render on its own pane: draws the tape, the rows and the footer. Pressing a symbol puts a question about it in your prompt box, for you to send or not./ticker 打开实时行情面板:顶部滚动行情条,下面每只股票有现价、涨跌额、涨跌幅和当日走势小图,刚变动的价格会闪一下,点代码会在输入框里填好"看看它今天的走势"。/ticker add NVDA TSLA 加自选,/ticker rm TSLA 删除,自选跨会话保存。行情只在面板打开时,按刷新间隔从 Yahoo Finance 的公开接口拉取,只发送股票代码。在 /config 里把 Language 设成 zh 就是中文,涨跌配色选 cn 就是红涨绿跌。
hooks/register.tsx 359 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Quote, Session } from '../types'
5
6const PANE = 'ticker'
7const DEFAULT_SYMBOLS = ['SPY', 'QQQ', 'AAPL', 'MSFT', 'NVDA', 'GOOGL', 'AMZN', 'TSLA', 'BTC-USD']
8const BARS = '▁▂▃▄▅▆▇█'
9const FRAME_MS = 250
10const SYMBOL = /^[A-Z0-9.^=-]{1,12}$/
11
12const symbols = atom({ plugin: 'vkazas-ticker', key: 'symbols' } as const, DEFAULT_SYMBOLS)
13const quotes = atom({ plugin: 'vkazas-ticker', key: 'quotes' } as const, [])
14const session = atom({ plugin: 'vkazas-ticker', key: 'session' } as const, 'closed')
15const updatedAt = atom({ plugin: 'vkazas-ticker', key: 'updatedAt' } as const, null)
16const failure = atom({ plugin: 'vkazas-ticker', key: 'error' } as const, '')
17const offset = atom({ plugin: 'vkazas-ticker', key: 'offset' } as const, 0)
18
19// ---------- words, in the language the options choose ----------
20
21type Lang = 'en' | 'zh'
22
23const TEXT = {
24 en: {
25 yahooStatus: (n: number) => `Yahoo returned ${n}`,
26 missing: (list: string[]) => `Not found: ${list.join(' ')}`,
27 failed: (why: string) => `Fetch failed: ${why}`,
28 session: { regular: '● open', pre: '◐ pre-market', post: '◑ after hours', closed: '○ closed' },
29 command: 'A live stock pane; /ticker add NVDA TSLA and /ticker rm TSLA manage the watchlist',
30 usage: 'Usage: /ticker add NVDA TSLA, or /ticker rm TSLA',
31 watchlist: (list: string[]) => `Watchlist: ${list.join(' ')}`,
32 title: 'Ticker',
33 opened: 'Ticker pane opened.',
34 notOpened: (why: string) => `The ticker pane could not be shown: ${why}`,
35 ask: (symbol: string) => `How is ${symbol} trading today?`,
36 asOf: (time: string) => ` as of ${time}`,
37 fetching: 'Fetching quotes…',
38 footer: (at: string | null, seconds: number) =>
39 `${at === null ? 'not updated yet' : `updated ${at}`} · every ${seconds}s · Yahoo · press a symbol to ask Claude · /ticker add SYMBOL`,
40 },
41 zh: {
42 yahooStatus: (n: number) => `Yahoo 返回 ${n}`,
43 missing: (list: string[]) => `没取到:${list.join(' ')}`,
44 failed: (why: string) => `拉取失败:${why}`,
45 session: { regular: '● 盘中', pre: '◐ 盘前', post: '◑ 盘后', closed: '○ 休市' },
46 command: '实时行情面板;/ticker add NVDA TSLA、/ticker rm TSLA 管理自选股',
47 usage: '用法:/ticker add NVDA TSLA,或 /ticker rm TSLA',
48 watchlist: (list: string[]) => `自选股:${list.join(' ')}`,
49 title: '行情',
50 opened: '行情面板已打开。',
51 notOpened: (why: string) => `行情面板没能显示:${why}`,
52 ask: (symbol: string) => `看看 ${symbol} 今天的走势`,
53 asOf: (time: string) => ` 美东 ${time}`,
54 fetching: '正在拉取行情…',
55 footer: (at: string | null, seconds: number) =>
56 `${at === null ? '尚未更新' : `更新于 ${at}`} · 每 ${seconds}s · Yahoo · 点代码问 Claude · /ticker add 代码`,
57 },
58}
59
60let lang: Lang = 'en'
61
62// ---------- data ----------
63
64type Period = { start: number; end: number }
65type SparkMeta = {
66 symbol?: string
67 shortName?: string
68 regularMarketPrice?: number
69 chartPreviousClose?: number
70 previousClose?: number
71 regularMarketTime?: number
72 gmtoffset?: number
73 currentTradingPeriod?: { pre?: Period; regular?: Period; post?: Period }
74}
75
76function sessionAt(meta: SparkMeta | undefined, nowSeconds: number): Session {
77 const p = meta?.currentTradingPeriod
78 const within = (x?: Period) => x !== undefined && nowSeconds >= x.start && nowSeconds < x.end
79 if (within(p?.regular)) return 'regular'
80 if (within(p?.pre)) return 'pre'
81 if (within(p?.post)) return 'post'
82 return 'closed'
83}
84
85function parseSpark(text: string): { quotes: Omit<Quote, 'tick'>[]; first?: SparkMeta } {
86 const data = JSON.parse(text) as {
87 spark?: {
88 result?: { symbol: string; response?: { meta?: SparkMeta; indicators?: { quote?: { close?: (number | null)[] }[] } }[] }[]
89 }
90 }
91 const out: Omit<Quote, 'tick'>[] = []
92 let first: SparkMeta | undefined
93 for (const r of data.spark?.result ?? []) {
94 const resp = r.response?.[0]
95 const meta = resp?.meta
96 if (meta === undefined || typeof meta.regularMarketPrice !== 'number') continue
97 first ??= meta
98 const closes = (resp?.indicators?.quote?.[0]?.close ?? []).filter((c): c is number => typeof c === 'number')
99 out.push({
100 symbol: r.symbol,
101 name: meta.shortName ?? r.symbol,
102 price: meta.regularMarketPrice,
103 previousClose: meta.chartPreviousClose ?? meta.previousClose ?? meta.regularMarketPrice,
104 closes,
105 time: meta.regularMarketTime ?? 0,
106 gmtoffset: meta.gmtoffset ?? 0,
107 })
108 }
109 return { quotes: out, first }
110}
111
112let inFlight = false
113
114async function refresh($: EngineInterface) {
115 if (inFlight) return
116 inFlight = true
117 try {
118 const list = (await read($, symbols)) ?? DEFAULT_SYMBOLS
119 const fetched: Omit<Quote, 'tick'>[] = []
120 let first: SparkMeta | undefined
121 for (let i = 0; i < list.length; i += 20) {
122 const chunk = list.slice(i, i + 20)
123 const url = `https://query1.finance.yahoo.com/v7/finance/spark?symbols=${encodeURIComponent(chunk.join(','))}&range=1d&interval=5m`
124 const res = await $.http.fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0', Accept: 'application/json' } })
125 if (!res.ok) throw new Error(TEXT[lang].yahooStatus(res.status))
126 const parsed = parseSpark(res.text)
127 fetched.push(...parsed.quotes)
128 first ??= parsed.first
129 }
130 const before = new Map(((await read($, quotes)) ?? []).map(q => [q.symbol, q.price]))
131 const bySymbol = new Map(fetched.map(q => [q.symbol, q]))
132 const next: Quote[] = list
133 .map(s => bySymbol.get(s))
134 .filter((q): q is Omit<Quote, 'tick'> => q !== undefined)
135 .map(q => {
136 const was = before.get(q.symbol)
137 return { ...q, tick: was === undefined || was === q.price ? 0 : q.price > was ? 1 : -1 }
138 })
139 await update($, quotes, () => next)
140 await update($, session, () => sessionAt(first, Date.now() / 1000))
141 await update($, updatedAt, () => Date.now())
142 const missing = list.filter(s => !bySymbol.has(s))
143 await update($, failure, () => (missing.length > 0 ? TEXT[lang].missing(missing) : ''))
144 } catch (err) {
145 await update($, failure, () => TEXT[lang].failed(err instanceof Error ? err.message : String(err)))
146 } finally {
147 inFlight = false
148 }
149}
150
151// ---------- formatting ----------
152
153function price(n: number): string {
154 return n >= 1000 ? n.toFixed(1) : n >= 1 ? n.toFixed(2) : n.toFixed(4)
155}
156
157function signed(n: number, digits = 2): string {
158 return `${n > 0 ? '+' : n < 0 ? '−' : ''}${Math.abs(n).toFixed(digits)}`
159}
160
161function sparkline(closes: number[], width: number): string {
162 if (closes.length < 2 || width < 2) return ''
163 const step = closes.length / width
164 const points = Array.from({ length: Math.min(width, closes.length) }, (_, i) =>
165 closes[Math.min(closes.length - 1, Math.floor(i * step))],
166 )
167 points[points.length - 1] = closes[closes.length - 1]
168 const lo = Math.min(...points)
169 const hi = Math.max(...points)
170 const span = hi - lo || 1
171 return points.map(p => BARS[Math.round(((p - lo) / span) * (BARS.length - 1))]).join('')
172}
173
174function clock(ms: number): string {
175 return new Date(ms).toTimeString().slice(0, 8)
176}
177
178function exchangeTime(q: Quote): string {
179 return new Date((q.time + q.gmtoffset) * 1000).toISOString().slice(11, 16)
180}
181
182const SESSION_COLOR: Record<Session, string> = { regular: 'success', pre: 'warning', post: 'warning', closed: 'inactive' }
183
184type Seg = { text: string; color: string; bold?: boolean }
185
186// The tape: every quote in a row, cut to the window and moved by the frame.
187function tapeWindow(segs: Seg[], width: number, frame: number): Seg[] {
188 const chars: { ch: string; color: string; bold: boolean }[] = []
189 for (const s of segs) for (const ch of s.text) chars.push({ ch, color: s.color, bold: s.bold === true })
190 if (chars.length === 0) return []
191 const shown =
192 chars.length <= width
193 ? chars
194 : Array.from({ length: width }, (_, i) => chars[(frame + i) % chars.length])
195 const out: Seg[] = []
196 for (const c of shown) {
197 const last = out[out.length - 1]
198 if (last !== undefined && last.color === c.color && (last.bold === true) === c.bold) last.text += c.ch
199 else out.push({ text: c.ch, color: c.color, bold: c.bold })
200 }
201 return out
202}
203
204// ---------- the mod ----------
205
206type Options = { refresh?: string; colors?: string; language?: string }
207
208export const register: Register = (on, options) => {
209 const opt = (options ?? {}) as Options
210 lang = opt.language === 'zh' ? 'zh' : 'en'
211 const t = TEXT[lang]
212 const refreshMs = Math.max(5, Number(opt.refresh ?? '10') || 10) * 1000
213 const isCn = opt.colors === 'cn'
214 const UP = isCn ? 'error' : 'success'
215 const DOWN = isCn ? 'success' : 'error'
216 const dir = (n: number) => (n > 0 ? UP : n < 0 ? DOWN : 'subtle')
217
218 on('session.start', async ($, e, next) => {
219 const started = await next(e)
220 const saved = await $.store.get('symbols').catch(() => undefined)
221 if (Array.isArray(saved) && saved.every(s => typeof s === 'string' && SYMBOL.test(s))) {
222 await update($, symbols, () => saved as string[])
223 }
224 await $.command.register({
225 name: 'ticker',
226 description: t.command,
227 })
228 // One clock for the tape's motion and the refresh, idle while the pane is closed.
229 let lastFetch = 0
230 void $.clock.every(FRAME_MS, async () => {
231 const isOpen = (await $.ui.panes().catch(() => [])).some(p => p.id === PANE)
232 if (!isOpen) return
233 await update($, offset, n => ((n ?? 0) + 1) % 1_000_000)
234 if (Date.now() - lastFetch >= refreshMs) {
235 lastFetch = Date.now()
236 void refresh($)
237 }
238 })
239 return started
240 })
241
242 on('command.run', { command: 'ticker' }, async ($, e) => {
243 const [verb = '', ...rest] = e.args.trim().split(/[\s,,]+/).filter(Boolean)
244 const asked = rest.map(s => s.toUpperCase()).filter(s => SYMBOL.test(s))
245 const current = (await read($, symbols)) ?? DEFAULT_SYMBOLS
246 if (verb === 'add' || verb === 'rm' || verb === 'remove' || verb === 'del') {
247 if (asked.length === 0) return { text: t.usage }
248 const next =
249 verb === 'add' ? [...current, ...asked.filter(s => !current.includes(s))] : current.filter(s => !asked.includes(s))
250 await update($, symbols, () => next)
251 await $.store.set('symbols', next)
252 await refresh($)
253 return { text: t.watchlist(next) }
254 }
255 const opened = await $.ui.open({ id: PANE, title: t.title })
256 // Wait for the first quotes, so the pane opens with prices in it.
257 await refresh($)
258 return { text: opened.isPlaced ? t.opened : t.notOpened(String(opened.reason)) }
259 })
260
261 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
262 const { Box, Text, Button } = $.ui.resolve(e) as any
263 const list = ((await read($, quotes)) ?? []) as Quote[]
264 const current = ((await read($, session)) ?? 'closed') as Session
265 const sess = { label: t.session[current], color: SESSION_COLOR[current] }
266 const at = await read($, updatedAt)
267 const err = (await read($, failure)) ?? ''
268 const frame = (await read($, offset)) ?? 0
269 const columns = Math.max(30, e.props.bodyColumns)
270 const sparkWidth = Math.min(24, columns - 42)
271
272 const tape = tapeWindow(
273 list.flatMap(q => {
274 const change = q.price - q.previousClose
275 const pct = q.previousClose === 0 ? 0 : (change / q.previousClose) * 100
276 return [
277 { text: q.symbol, color: 'text', bold: true },
278 { text: ` ${price(q.price)} `, color: 'text' },
279 { text: `${change > 0 ? '▲' : change < 0 ? '▼' : '•'}${Math.abs(pct).toFixed(2)}%`, color: dir(change) },
280 { text: ' · ', color: 'subtle' },
281 ]
282 }),
283 columns,
284 frame,
285 )
286
287 return (
288 <Box flexDirection="column">
289 <Box flexDirection="row" justifyContent="space-between">
290 <Box flexDirection="row">
291 <Text color="claude" bold>
292 {t.title}
293 </Text>
294 {list[0] !== undefined && <Text color="subtle">{t.asOf(exchangeTime(list[0]))}</Text>}
295 </Box>
296 <Text color={sess.color}>{sess.label}</Text>
297 </Box>
298 <Text color="subtle">{'─'.repeat(columns)}</Text>
299
300 {tape.length > 0 ? (
301 <Box flexDirection="row">
302 {tape.map(s => (
303 <Text color={s.color} bold={s.bold}>
304 {s.text}
305 </Text>
306 ))}
307 </Box>
308 ) : (
309 <Text color="subtle">{err === '' ? t.fetching : ' '}</Text>
310 )}
311 <Text color="subtle">{'─'.repeat(columns)}</Text>
312
313 {list.map(q => {
314 const change = q.price - q.previousClose
315 const pct = q.previousClose === 0 ? 0 : (change / q.previousClose) * 100
316 const color = dir(change)
317 return (
318 <Box key={`row-${q.symbol}`} flexDirection="row">
319 <Box width={8}>
320 <Button
321 key={`sym-${q.symbol}`}
322 label={q.symbol}
323 plain
324 onPress={() => {
325 void $.prompt.fill({ text: t.ask(q.symbol), mode: 'insert' })
326 }}
327 />
328 </Box>
329 <Box width={11} justifyContent="flex-end">
330 <Text color={q.tick === 0 ? 'text' : dir(q.tick)} bold={q.tick !== 0}>
331 {price(q.price)}
332 </Text>
333 </Box>
334 <Box width={9} justifyContent="flex-end">
335 <Text color={color}>{signed(change)}</Text>
336 </Box>
337 <Box width={9} justifyContent="flex-end">
338 <Text color={color}>{`${signed(pct)}%`}</Text>
339 </Box>
340 {sparkWidth >= 6 && (
341 <Box marginLeft={2}>
342 <Text color={color}>{sparkline(q.closes, sparkWidth)}</Text>
343 </Box>
344 )}
345 </Box>
346 )
347 })}
348
349 {err !== '' && <Text color="error">{`⚠ ${err}`}</Text>}
350 <Box marginTop={1}>
351 <Text color="subtle">
352 {t.footer(at === null ? null : clock(at), refreshMs / 1000)}
353 </Text>
354 </Box>
355 </Box>
356 )
357 })
358}
359types/index.d.ts 28 lines1export type Quote = {
2 symbol: string
3 name: string
4 price: number
5 previousClose: number
6 closes: number[]
7 // Seconds since the epoch, and the exchange's offset from UTC in seconds.
8 time: number
9 gmtoffset: number
10 // Which way the last refresh moved it: 1 up, -1 down, 0 unchanged.
11 tick: number
12}
13
14export type Session = 'pre' | 'regular' | 'post' | 'closed'
15
16declare module 'claude-code' {
17 interface PluginState {
18 'vkazas-ticker': {
19 symbols: string[]
20 quotes: Quote[]
21 session: Session
22 updatedAt: number | null
23 error: string
24 offset: number
25 }
26 }
27}
28