SLOPSHOPPER

Ticker

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

newpanecommandnetworktimer
v1.0.0MITupdated 2026-10-13VkaZas/claude-code-mods/vkazas-ticker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vkazas-ticker
│ ┃ Ticker ✕ › fix the failing auth test and add an audit log call │ ┃ Ticker ○ closed │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ ──────────── ⏺ Bash(bun test) │ ┃ ⚠ Fetch failed: Yahoo returned 0 ⎿ 3 pass, 1 fail │ ┃ │ ┃ not updated yet · every 10s · Yahoo · press ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ a symbol to ask Claude · /ticker add SYMBOL │ ✻ Worked for 42s · done 4:20 PM │ │ › /ticker │ ⎿ vkazas-ticker: Ticker pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Ticker
Ticker ○ closed ──────────────────────────────────────────────────────── ──────────────────────────────────────────────────────── ⚠ Fetch failed: Yahoo returned 0 not updated yet · every 10s · Yahoo · press a symbol to ask Claude · /ticker add SYMBOL
README

Ticker (vkazas-ticker)

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.

Install

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

Using it

CommandDoes
/tickerOpen the pane
/ticker add NVDA TSLAAdd symbols to the watchlist
/ticker rm TSLARemove 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.

Settings

OptionValuesDefault
Refresh (seconds)5, 10, 30, 6010
Up and down colorsus (green up, red down), cn (red up, green down)us
Languageen, zhen

Where the prices come from

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.

What it hooks

  • 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.

Notes

  • Ticker is a mod: a hooks module with a pane. It was built and tested with Claude Code 2.1.295 in Windows Terminal.
  • The only thing it stores is your watchlist, in the plugin's own store.

中文说明

/ticker 打开实时行情面板:顶部滚动行情条,下面每只股票有现价、涨跌额、涨跌幅和当日走势小图,刚变动的价格会闪一下,点代码会在输入框里填好"看看它今天的走势"。/ticker add NVDA TSLA 加自选,/ticker rm TSLA 删除,自选跨会话保存。行情只在面板打开时,按刷新间隔从 Yahoo Finance 的公开接口拉取,只发送股票代码。在 /config 里把 Language 设成 zh 就是中文,涨跌配色选 cn 就是红涨绿跌。

License

MIT

Source 2 files
hooks/register.tsx 359 lines
1import { 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}
359
types/index.d.ts 28 lines
1export 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