SLOPSHOPPER

stock-ticker

Live stock ticker band above the Claude Code prompt: watch Taiwan and US quotes while Claude works.

newbandcommandtoastnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stock-ticker
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /stock ⎿ stock-ticker: Watchlist: SPY QQQ NVDA AAPL Quotes unavailable (US: HTTP 0) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Quotes unavailable (US: HTTP 0)
README

Claude Code Stock Ticker

English | 繁體中文

Claude writes the code. You watch the market.

Live Taiwan and US stock quotes right above your Claude Code prompt, so you can check your watchlist while Claude works.

How it works

  • Taiwan stocks, ETFs and indices; US stocks plus the Dow, S&P 500, Nasdaq and SOX
  • Refreshes every 15 seconds during market hours (down to 5), with a toast on big moves
  • Works in the CLI and the desktop app, no setup needed

Install

Requires Claude Code 2.1.287 or later.

CLI: type this inside Claude Code:

/plugin marketplace add twjackysu/claude-code-stock-ticker
/plugin install stock-ticker@claude-code-stock-ticker
/reload-plugins

Desktop app: Settings → Plugins → Add → Add from a repository, enter twjackysu/claude-code-stock-ticker and press Sync, then install Stock ticker from Discover.

Installing in the desktop app

Usage

/stock add 2330 NVDA SOX    add symbols (digits: Taiwan, letters: US)
/stock rm 2330              remove
/stock rm all               clear the watchlist
/stock list                 show quotes
/stock off                  hide (/stock on to show)

The default watchlist is SPY, QQQ, NVDA and AAPL, up to 10 symbols. The band wraps to fit the window, up to three rows; anything beyond shows as +N.

Settings

Change them with /stock; they apply at once and are kept across sessions:

/stock refresh 5            US quotes every 5 seconds (default 15, minimum 5)
/stock refresh tw 10        Taiwan quotes every 10 seconds (default 15, minimum 5)
/stock color red-up         red for gains, as in Taiwan; green-up (the default) is green for gains
/stock alert 5              toast once a day when a symbol moves 5% or more (default 3, 0 turns it off)
/stock lang zh              messages in Chinese (en for English, the default)
/stock settings             show the current settings

Update

Third-party plugins don't update on their own by default. In the CLI, type /plugin, open Installed, select stock-ticker and choose Update now. To update automatically, open Marketplaces, select claude-code-stock-ticker and choose Enable auto-update.

Or run this in a terminal (refreshing the marketplace first lets Claude Code see the new version):

claude plugin marketplace update claude-code-stock-ticker && claude plugin update stock-ticker@claude-code-stock-ticker

What it connects to

  • mis.twse.com.tw for Taiwan quotes and query1.finance.yahoo.com for US quotes, sending only the symbols on your watchlist.
  • Nothing else: it reads nothing from your project or conversation, and sends no telemetry.

Notes

  • Data sources: TWSE MIS public real-time quotes for Taiwan, Yahoo Finance for the US
  • Outside trading hours polling stops and the last quotes stay: Taiwan trades weekdays 08:30–14:00 Taipei time, the US weekdays 09:30–16:00 New York time
  • Only the session on screen polls; switching to another session pauses it
  • Quotes are for reference only. Not investment advice.

License

MIT

Source 4 files
hooks/register.tsx 389 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Lang, Quote, QuoteError, TickerSettings } from '../types'
5import { MESSAGES } from './messages'
6import type { Market } from './quotes'
7import {
8  DEFAULT_WATCHLIST,
9  cellWidth,
10  formatChange,
11  formatPrice,
12  isMarketOpen,
13  marketOf,
14  misUrl,
15  normalizeSymbol,
16  parseMis,
17  parseYahoo,
18  taipeiDay,
19  yahooUrl,
20} from './quotes'
21
22const DEFAULT_SETTINGS: TickerSettings = { refreshSeconds: { tw: 15, us: 15 }, colors: 'green-up', alertPercent: 3, lang: 'en' }
23const MIN_REFRESH_SECONDS = 5
24
25const quotes = atom({ plugin: 'stock-ticker', key: 'quotes' } as const, [] as Quote[])
26const isHidden = atom({ plugin: 'stock-ticker', key: 'isHidden' } as const, false)
27const error = atom({ plugin: 'stock-ticker', key: 'error' } as const, null as QuoteError | null)
28const settings = atom({ plugin: 'stock-ticker', key: 'settings' } as const, DEFAULT_SETTINGS)
29
30const WATCHLIST_KEY = 'watchlist'
31const HIDDEN_KEY = 'hidden'
32const SETTINGS_KEY = 'settings'
33const MAX_SYMBOLS = 10
34// The band never grows past this many rows, so it stays out of the way of the prompt.
35const MAX_ROWS = 3
36
37async function loadWatchlist($: EngineInterface): Promise<string[]> {
38  const stored = await $.store.get(WATCHLIST_KEY)
39
40  return Array.isArray(stored) ? stored.filter((s): s is string => typeof s === 'string') : DEFAULT_WATCHLIST
41}
42
43/**
44 * What the person changed, and nothing else: unchanged fields follow the
45 * defaults, so a later release's new default reaches them.
46 */
47type SettingsOverrides = {
48  refreshSeconds?: Partial<Record<Market, number>>
49  colors?: TickerSettings['colors']
50  alertPercent?: number
51  lang?: Lang
52}
53
54async function loadOverrides($: EngineInterface): Promise<SettingsOverrides> {
55  const stored = await $.store.get(SETTINGS_KEY)
56
57  return typeof stored === 'object' && stored !== null ? (stored as SettingsOverrides) : {}
58}
59
60/** The overrides over the defaults, each field checked so a bad value falls back. */
61function applyOverrides(o: SettingsOverrides): TickerSettings {
62  const seconds = (value: unknown, fallback: number) =>
63    typeof value === 'number' && value >= MIN_REFRESH_SECONDS ? value : fallback
64
65  return {
66    refreshSeconds: {
67      tw: seconds(o.refreshSeconds?.tw, DEFAULT_SETTINGS.refreshSeconds.tw),
68      us: seconds(o.refreshSeconds?.us, DEFAULT_SETTINGS.refreshSeconds.us),
69    },
70    colors: o.colors === 'red-up' || o.colors === 'green-up' ? o.colors : DEFAULT_SETTINGS.colors,
71    alertPercent: typeof o.alertPercent === 'number' && o.alertPercent >= 0 ? o.alertPercent : DEFAULT_SETTINGS.alertPercent,
72    lang: o.lang === 'zh' || o.lang === 'en' ? o.lang : DEFAULT_SETTINGS.lang,
73  }
74}
75
76/** Stores one change on top of the earlier ones and returns the settings now in force. */
77async function saveSettings($: EngineInterface, change: SettingsOverrides): Promise<TickerSettings> {
78  const stored = await loadOverrides($)
79  const merged: SettingsOverrides = {
80    ...stored,
81    ...change,
82    ...(change.refreshSeconds && { refreshSeconds: { ...stored.refreshSeconds, ...change.refreshSeconds } }),
83  }
84  await $.store.set(SETTINGS_KEY, merged)
85  const next = applyOverrides(merged)
86  await update($, settings, () => next)
87
88  return next
89}
90
91const MARKETS: readonly Market[] = ['tw', 'us']
92
93/** This session's polling per market, and which big-move toasts already fired today. */
94type Poll = {
95  timers: Partial<Record<Market, Timer>>
96  isFetching: Record<Market, boolean>
97  alerted: Set<string>
98}
99
100/** (Re)starts each market's timer at its current interval. */
101function startTimers($: EngineInterface, poll: Poll, current: TickerSettings) {
102  for (const market of MARKETS) {
103    poll.timers[market]?.cancel()
104    poll.timers[market] = $.clock.every(current.refreshSeconds[market] * 1000, () => void refresh($, poll, market))
105  }
106}
107
108/** Taiwan quotes come from TWSE MIS; US quotes from Yahoo Finance, which refuses requests without a browser User-Agent. */
109async function fetchQuotes($: EngineInterface, market: Market, codes: readonly string[], now: number): Promise<Quote[]> {
110  const response =
111    market === 'tw'
112      ? await $.http.fetch(misUrl(codes))
113      : await $.http.fetch(yahooUrl(codes, now), { headers: { 'User-Agent': 'Mozilla/5.0' } })
114  if (!response.ok) throw new Error(`HTTP ${response.status}`)
115
116  return market === 'tw' ? parseMis(response.text, codes) : parseYahoo(response.text, codes)
117}
118
119/**
120 * Every open session runs its own copy of the mod, so only a session on
121 * screen polls: the desktop app and the phone detach a session when the
122 * person switches away and attach it on return, and a terminal session is
123 * drawn by its REPL for as long as it runs.
124 */
125async function isOnScreen($: EngineInterface): Promise<boolean> {
126  return (await $.session.surfaces()).length > 0
127}
128
129/** Refreshes one market's quotes, keeping the other market's and the watchlist's order. */
130async function refresh($: EngineInterface, poll: Poll, market: Market, { force = false } = {}) {
131  const now = await $.clock.now()
132  const watchlist = await loadWatchlist($)
133  const codes = watchlist.filter(code => marketOf(code) === market)
134  if (!force) {
135    // Outside trading hours the last quotes stand; one fetch fills them in.
136    const hasQuotes = (await read($, quotes)).some(quote => marketOf(quote.symbol) === market)
137    if (!isMarketOpen(market, now) && hasQuotes) return
138    if (!(await isOnScreen($))) return
139  }
140  if (poll.isFetching[market]) return
141
142  // Merged into the latest list, not one read earlier: the other market may have landed meanwhile.
143  // Should a cache still answer with an older snapshot, a quote never goes back in time.
144  const merge = (fresh: Quote[]) => (latest: Quote[]) => {
145    const bySymbol = new Map(latest.filter(q => marketOf(q.symbol) !== market).map(q => [q.symbol, q]))
146    const previous = new Map(latest.map(q => [q.symbol, q]))
147    for (const quote of fresh) {
148      const known = previous.get(quote.symbol)
149      const isStale = known?.asOf !== undefined && quote.asOf !== undefined && quote.asOf < known.asOf
150      bySymbol.set(quote.symbol, isStale && known ? known : quote)
151    }
152    return watchlist.flatMap(code => bySymbol.get(code) ?? [])
153  }
154  if (codes.length === 0) {
155    await update($, quotes, merge([]))
156    return
157  }
158
159  poll.isFetching[market] = true
160  try {
161    const fresh = await fetchQuotes($, market, codes, now)
162    await update($, quotes, merge(fresh))
163    await update($, error, () => null)
164    notifyBigMoves($, poll, (await read($, settings)).alertPercent, fresh, now)
165  } catch (reason) {
166    const message = reason instanceof Error ? reason.message : String(reason)
167    await update($, error, () => ({ market, message }))
168  } finally {
169    poll.isFetching[market] = false
170  }
171}
172
173async function refreshAll($: EngineInterface, poll: Poll, { force = false } = {}) {
174  await Promise.all(MARKETS.map(market => refresh($, poll, market, { force })))
175}
176
177function notifyBigMoves($: EngineInterface, poll: Poll, alertPercent: number, list: Quote[], now: number) {
178  if (alertPercent <= 0) return
179  const day = taipeiDay(now)
180  for (const quote of list) {
181    const id = `${day}:${quote.symbol}`
182    if (Math.abs(quote.changePercent) < alertPercent || poll.alerted.has(id)) continue
183    poll.alerted.add(id)
184    $.ui.toast(`${quote.label} ${formatPrice(quote.price)} ${formatChange(quote.changePercent)}`)
185  }
186}
187
188/**
189 * Quotes packed into rows of `columns` cells, at most `maxRows` of them. Each
190 * row takes whole quotes; when some are left over, the last row keeps room
191 * for a "+N" saying how many.
192 */
193function layoutRows(list: Quote[], columns: number, maxRows: number, gap: number): { rows: Quote[][]; hidden: number } {
194  const rows: Quote[][] = []
195  let rest = list
196  while (rest.length > 0 && rows.length < maxRows) {
197    const isLast = rows.length === maxRows - 1
198    let row = fitRow(rest, columns, gap)
199    if (isLast && row.length < rest.length) row = fitRow(rest, columns - 4, gap)
200    // A quote wider than the whole row still gets a row of its own.
201    if (row.length === 0) row = rest.slice(0, 1)
202    rows.push(row)
203    rest = rest.slice(row.length)
204  }
205
206  return { rows, hidden: rest.length }
207}
208
209/** The leading quotes that fit in `columns` cells, each followed by `gap` cells. */
210function fitRow(list: Quote[], columns: number, gap: number): Quote[] {
211  const shown: Quote[] = []
212  let room = columns
213  for (const quote of list) {
214    const width = cellWidth(`${quote.label} ${formatPrice(quote.price)} ${formatChange(quote.changePercent)}`) + gap
215    if (width > room) break
216    room -= width
217    shown.push(quote)
218  }
219
220  return shown
221}
222
223export const register: Register = on => {
224  const poll: Poll = { timers: {}, isFetching: { tw: false, us: false }, alerted: new Set() }
225
226  on('session.start', async ($, e, next) => {
227    await $.command.register({
228      name: 'stock',
229      description: 'Stock ticker: add/remove Taiwan or US symbols, change refresh, colors, alerts and language',
230      argumentHint: 'add 2330 NVDA | rm 2330 | rm all | list | refresh 5 | color red-up | alert 5 | lang zh | settings | on | off',
231    })
232    const wasHidden = (await $.store.get(HIDDEN_KEY)) === true
233    await update($, isHidden, () => wasHidden)
234    const loaded = applyOverrides(await loadOverrides($))
235    await update($, settings, () => loaded)
236
237    void refreshAll($, poll, { force: true })
238    startTimers($, poll, loaded)
239
240    return next(e)
241  })
242
243  on('command.run', { command: 'stock' }, async ($, e) => {
244    const [verb = 'list', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
245    const watchlist = await loadWatchlist($)
246    const t = MESSAGES[(await read($, settings)).lang]
247
248    // Checked before symbols: ALL is also Allstate's ticker, which `add ALL` still adds.
249    const isRemove = verb === 'rm' || verb === 'remove'
250    if (isRemove && rest.length === 1 && rest[0]?.toLowerCase() === 'all') {
251      await $.store.set(WATCHLIST_KEY, [])
252      await refreshAll($, poll, { force: true })
253
254      return { text: t.cleared }
255    }
256
257    if (verb === 'add' || isRemove) {
258      const codes = rest.map(normalizeSymbol)
259      const invalid = rest.filter((_, i) => codes[i] === undefined)
260      const valid = codes.filter((c): c is string => c !== undefined)
261      const fresh = [...new Set(valid)].filter(c => !watchlist.includes(c))
262      const room = Math.max(0, MAX_SYMBOLS - watchlist.length)
263      const overLimit = verb === 'add' ? fresh.slice(room) : []
264      const nextList = verb === 'add' ? [...watchlist, ...fresh.slice(0, room)] : watchlist.filter(c => !valid.includes(c))
265      await $.store.set(WATCHLIST_KEY, nextList)
266      await refreshAll($, poll, { force: true })
267      const absent = verb === 'add' ? [] : valid.filter(c => !watchlist.includes(c))
268      const notes = [
269        invalid.length > 0 ? t.unknown(invalid) : '',
270        absent.length > 0 ? t.notInWatchlist(absent) : '',
271        overLimit.length > 0 ? t.overLimit(MAX_SYMBOLS, overLimit) : '',
272      ].filter(Boolean)
273
274      return { text: [t.watchlist(nextList), ...notes].join('\n') }
275    }
276
277    if (verb === 'refresh') {
278      // `/stock refresh 5` sets the US; `/stock refresh tw 10` Taiwan.
279      const named = rest[0] === 'tw' || rest[0] === 'us' ? rest[0] : undefined
280      const market: Market = named ?? 'us'
281      const seconds = Number(named ? rest[1] : rest[0])
282      if (!Number.isFinite(seconds) || seconds < MIN_REFRESH_SECONDS) {
283        return { text: t.refreshInvalid(MIN_REFRESH_SECONDS) }
284      }
285      const next = await saveSettings($, { refreshSeconds: { [market]: seconds } })
286      startTimers($, poll, next)
287
288      return { text: t.refresh(next) }
289    }
290
291    if (verb === 'color' || verb === 'colors') {
292      const choice = rest[0]?.toLowerCase()
293      if (choice !== 'red-up' && choice !== 'green-up') {
294        return { text: t.colorsInvalid }
295      }
296
297      return { text: t.colors(await saveSettings($, { colors: choice })) }
298    }
299
300    if (verb === 'alert') {
301      const percent = Number(rest[0])
302      if (!Number.isFinite(percent) || percent < 0) {
303        return { text: t.alertInvalid }
304      }
305
306      return { text: t.alert(await saveSettings($, { alertPercent: percent })) }
307    }
308
309    if (verb === 'lang') {
310      const choice = rest[0]?.toLowerCase()
311      const lang: Lang | undefined =
312        choice === 'en' ? 'en' : choice === 'zh' || choice === 'zh-tw' || choice === '中文' ? 'zh' : undefined
313      if (!lang) return { text: t.langInvalid }
314      await saveSettings($, { lang })
315
316      return { text: MESSAGES[lang].lang }
317    }
318
319    if (verb === 'settings') {
320      const current = await read($, settings)
321
322      return { text: [t.refresh(current), t.colors(current), t.alert(current), t.lang].join('\n') }
323    }
324
325    if (verb === 'on' || verb === 'off') {
326      await $.store.set(HIDDEN_KEY, verb === 'off')
327      await update($, isHidden, () => verb === 'off')
328
329      return { text: verb === 'off' ? t.hidden : t.shown }
330    }
331
332    if (verb === 'list') {
333      const list = await read($, quotes)
334      const lines = list.map(q =>
335        [q.symbol, q.label === q.symbol ? '' : q.label, formatPrice(q.price), formatChange(q.changePercent)].filter(Boolean).join(' '),
336      )
337
338      return { text: lines.length > 0 ? lines.join('\n') : t.watchlist(watchlist) }
339    }
340
341    return { text: t.usage }
342  })
343
344  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
345    if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
346
347    const list = await read($, quotes)
348    const failure = await read($, error)
349    const current = await read($, settings)
350    const isRedUp = current.colors === 'red-up'
351    const { Box, Text } = $.ui.resolve(e)
352
353    if (list.length === 0) {
354      if (failure === null) return next(e)
355      const t = MESSAGES[current.lang]
356
357      return <Text dimColor>{t.unavailable(`${t.market[failure.market]}: ${failure.message}`)}</Text>
358    }
359
360    // Wraps to the width this surface has now; a resize draws it again.
361    const gap = 3
362    const { rows, hidden } = layoutRows(list, e.props.bodyColumns, Math.max(1, Math.min(MAX_ROWS, e.props.maxRows)), gap)
363
364    const colorOf = (percent: number) => {
365      if (percent === 0) return undefined
366      return percent > 0 === isRedUp ? 'red' : 'green'
367    }
368
369    return (
370      <Box flexDirection="column">
371        {rows.map((row, index) => (
372          <Box key={`row-${index}`} flexDirection="row">
373            {row.map(quote => (
374              <Box key={quote.symbol} marginRight={gap}>
375                <Text>{quote.label} </Text>
376                <Text bold>{formatPrice(quote.price)} </Text>
377                <Text color={colorOf(quote.changePercent)} dimColor={quote.changePercent === 0}>
378                  {formatChange(quote.changePercent)}
379                </Text>
380              </Box>
381            ))}
382            {index === rows.length - 1 && hidden > 0 ? <Text dimColor>+{hidden}</Text> : null}
383          </Box>
384        ))}
385      </Box>
386    )
387  })
388}
389
hooks/messages.ts 73 lines
1import type { Lang, TickerSettings } from '../types'
2import type { Market } from './quotes'
3
4/** Every line the mod says to the person, per language. Quote codes and names are never translated. */
5export type Messages = {
6  market: Record<Market, string>
7  watchlist: (codes: readonly string[]) => string
8  cleared: string
9  unknown: (codes: readonly string[]) => string
10  notInWatchlist: (codes: readonly string[]) => string
11  overLimit: (limit: number, codes: readonly string[]) => string
12  refresh: (s: TickerSettings) => string
13  refreshInvalid: (min: number) => string
14  colors: (s: TickerSettings) => string
15  colorsInvalid: string
16  alert: (s: TickerSettings) => string
17  alertInvalid: string
18  lang: string
19  langInvalid: string
20  hidden: string
21  shown: string
22  usage: string
23  unavailable: (reason: string) => string
24}
25
26const COMMANDS = [
27  '/stock add 2330 NVDA | /stock rm 2330 | /stock rm all | /stock list | /stock on | /stock off',
28  '/stock refresh 5 | /stock refresh tw 10 | /stock color red-up | /stock alert 5 | /stock lang zh | /stock settings',
29].join('\n')
30
31export const MESSAGES: Record<Lang, Messages> = {
32  en: {
33    market: { tw: 'Taiwan', us: 'US' },
34    watchlist: codes => `Watchlist: ${codes.join(' ') || '(empty)'}`,
35    cleared: 'Watchlist cleared. Add symbols with /stock add NVDA 2330.',
36    unknown: codes => `Unknown: ${codes.join(' ')}`,
37    notInWatchlist: codes => `Not in watchlist: ${codes.join(' ')}`,
38    overLimit: (limit, codes) => `Over the ${limit}-symbol limit: ${codes.join(' ')}`,
39    refresh: s => `Refresh: US ${s.refreshSeconds.us}s, Taiwan ${s.refreshSeconds.tw}s`,
40    refreshInvalid: min => `Seconds must be ${min} or more: /stock refresh 5 | /stock refresh tw 10`,
41    colors: s => `Colors: ${s.colors === 'green-up' ? 'green for gains (green-up)' : 'red for gains (red-up)'}`,
42    colorsInvalid: 'Use green-up or red-up: /stock color red-up',
43    alert: s => (s.alertPercent > 0 ? `Alert: ${s.alertPercent}%` : 'Alerts off'),
44    alertInvalid: 'Use 0 or more; 0 turns alerts off: /stock alert 5',
45    lang: 'Language: English',
46    langInvalid: 'Use en or zh: /stock lang zh',
47    hidden: 'Ticker hidden. /stock on shows it again.',
48    shown: 'Ticker shown.',
49    usage: `Usage:\n${COMMANDS}`,
50    unavailable: reason => `Quotes unavailable (${reason})`,
51  },
52  zh: {
53    market: { tw: '台股', us: '美股' },
54    watchlist: codes => `自選股:${codes.join(' ') || '(空)'}`,
55    cleared: '自選股已清空,用 /stock add NVDA 2330 加入新的。',
56    unknown: codes => `無法辨識:${codes.join(' ')}`,
57    notInWatchlist: codes => `不在自選股:${codes.join(' ')}`,
58    overLimit: (limit, codes) => `最多 ${limit} 檔,未加入:${codes.join(' ')}`,
59    refresh: s => `更新秒數:美股 ${s.refreshSeconds.us} 秒,台股 ${s.refreshSeconds.tw} 秒`,
60    refreshInvalid: min => `秒數要 ${min} 以上:/stock refresh 5 | /stock refresh tw 10`,
61    colors: s => `漲跌顏色:${s.colors === 'green-up' ? '綠漲紅跌(green-up)' : '紅漲綠跌(red-up)'}`,
62    colorsInvalid: '請用 green-up(綠漲紅跌)或 red-up(紅漲綠跌):/stock color red-up',
63    alert: s => (s.alertPercent > 0 ? `通知門檻:${s.alertPercent}%` : '通知已關閉'),
64    alertInvalid: '門檻要 0 以上,0 是關閉:/stock alert 5',
65    lang: '語言:中文',
66    langInvalid: '請用 en 或 zh:/stock lang en',
67    hidden: '報價列已隱藏,/stock on 重新顯示。',
68    shown: '報價列已顯示。',
69    usage: `用法:\n${COMMANDS}`,
70    unavailable: reason => `報價暫時無法取得(${reason})`,
71  },
72}
73
hooks/quotes.ts 185 lines
1import type { Quote } from '../types'
2
3export type Market = 'tw' | 'us'
4
5// Index codes, and the names people actually type for them.
6const INDEX_ALIASES: Record<string, string> = {
7  t00: 't00',
8  taiex: 't00',
9  加權: 't00',
10  加權指數: 't00',
11  大盤: 't00',
12  o00: 'o00',
13  tpex: 'o00',
14  櫃買: 'o00',
15  櫃買指數: 'o00',
16  otc: 'o00',
17  道瓊: '^DJI',
18  dji: '^DJI',
19  標普: '^GSPC',
20  標普500: '^GSPC',
21  spx: '^GSPC',
22  sp500: '^GSPC',
23  那斯達克: '^IXIC',
24  nasdaq: '^IXIC',
25  費半: '^SOX',
26  sox: '^SOX',
27}
28
29const INDEX_LABELS: Record<string, string> = {
30  t00: '加權',
31  o00: '櫃買',
32  '^DJI': 'Dow',
33  '^GSPC': 'S&P 500',
34  '^IXIC': 'Nasdaq',
35  '^SOX': 'SOX',
36}
37
38export const DEFAULT_WATCHLIST = ['SPY', 'QQQ', 'NVDA', 'AAPL']
39
40/** Taiwan codes start with a digit (2330, 00631L) or are an index (t00, o00); everything else is US. */
41export const marketOf = (code: string): Market => (/^\d/.test(code) || code === 't00' || code === 'o00' ? 'tw' : 'us')
42
43/** Normalizes what someone typed into a quote code, or undefined when it is not one. */
44export function normalizeSymbol(raw: string): string | undefined {
45  const text = raw.trim()
46  const alias = INDEX_ALIASES[text.toLowerCase()]
47  if (alias) return alias
48  const code = text.toUpperCase().replace(/\.(TW|TWO)$/, '')
49  if (/^\d{4,6}[A-Z]?$/.test(code)) return code
50
51  // US tickers: AAPL, BRK-B, BRK.B, ^GSPC
52  return /^\^?[A-Z][A-Z0-9]{0,5}([.-][A-Z])?$/.test(code) ? code.replace('.', '-') : undefined
53}
54
55/** One MIS request for every Taiwan code: each asked on both TWSE and TPEx, the wrong one answers empty. */
56export function misUrl(codes: readonly string[]): string {
57  const channels = codes.flatMap(code =>
58    code === 't00' ? ['tse_t00.tw'] : code === 'o00' ? ['otc_o00.tw'] : [`tse_${code}.tw`, `otc_${code}.tw`],
59  )
60
61  return `https://mis.twse.com.tw/stock/api/getStockInfo.jsp?ex_ch=${encodeURIComponent(channels.join('|'))}&json=1&delay=0`
62}
63
64/**
65 * One Yahoo Finance request for every US code. Yahoo's edge caches answer a
66 * repeated URL with a snapshot up to several seconds old, so `now` makes each
67 * request's URL its own.
68 */
69export function yahooUrl(codes: readonly string[], now: number): string {
70  return `https://query1.finance.yahoo.com/v8/finance/spark?symbols=${encodeURIComponent(codes.join(','))}&range=1d&interval=1d&_=${now}`
71}
72
73const price = (value: unknown): number | undefined => {
74  const n = typeof value === 'string' ? Number.parseFloat(value) : typeof value === 'number' ? value : Number.NaN
75
76  return Number.isFinite(n) && n > 0 ? n : undefined
77}
78
79// Best bid/ask strings look like "2580.0000_2575.0000_..."
80const bestLevel = (value: unknown) => (typeof value === 'string' ? price(value.split('_')[0]) : undefined)
81
82const quoteOf = (symbol: string, label: string, last: number, previous: number): Quote => ({
83  symbol,
84  label,
85  price: last,
86  changePercent: ((last - previous) / previous) * 100,
87})
88
89/** Parses a MIS response into quotes, ordered as `codes`. */
90export function parseMis(text: string, codes: readonly string[]): Quote[] {
91  const rows = (JSON.parse(text) as { msgArray?: Record<string, unknown>[] }).msgArray ?? []
92  const byCode = new Map<string, Quote>()
93
94  for (const row of rows) {
95    if (typeof row.c !== 'string' || typeof row.n !== 'string') continue
96    const previous = price(row.y)
97    // z is "-" between trades; fall back to the last trade, then the order book.
98    const last = price(row.z) ?? price(row.pz) ?? bestLevel(row.b) ?? bestLevel(row.a) ?? previous
99    if (last === undefined || previous === undefined) continue
100    byCode.set(row.c, quoteOf(row.c, INDEX_LABELS[row.c] ?? row.n, last, previous))
101  }
102
103  return codes.flatMap(code => byCode.get(code) ?? [])
104}
105
106/** Parses a Yahoo spark response (`{ AAPL: { fulldayPrice, chartPreviousClose, close } }`), ordered as `codes`. */
107export function parseYahoo(text: string, codes: readonly string[]): Quote[] {
108  const bySymbol = JSON.parse(text) as Record<string, Record<string, unknown> | undefined>
109
110  return codes.flatMap(code => {
111    const row = bySymbol[code]
112    if (!row) return []
113    const closes = Array.isArray(row.close) ? row.close : []
114    const last = price(row.fulldayPrice) ?? price(closes[closes.length - 1])
115    const previous = price(row.chartPreviousClose) ?? price(row.previousClose)
116    if (last === undefined || previous === undefined) return []
117    const stamps = Array.isArray(row.timestamp) ? row.timestamp : []
118    const asOf = stamps[stamps.length - 1]
119
120    return [{ ...quoteOf(code, INDEX_LABELS[code] ?? code, last, previous), ...(typeof asOf === 'number' && { asOf }) }]
121  })
122}
123
124export function formatPrice(n: number): string {
125  if (n >= 10000) return n.toFixed(0)
126  if (Number.isInteger(n)) return String(n)
127
128  return n.toFixed(2).replace(/\.?0+$/, '')
129}
130
131export function formatChange(percent: number): string {
132  const arrow = percent > 0 ? '▲' : percent < 0 ? '▼' : '-'
133
134  return `${arrow}${Math.abs(percent).toFixed(2)}%`
135}
136
137/** Terminal cells a string takes: CJK and fullwidth characters take two. */
138export function cellWidth(text: string): number {
139  let width = 0
140  for (const ch of text) {
141    width += /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/.test(ch) ? 2 : 1
142  }
143
144  return width
145}
146
147const HOUR_MS = 60 * 60 * 1000
148
149/** The nth Sunday (1-based) of a UTC month, as a day of the month. */
150function nthSunday(year: number, month: number, n: number): number {
151  const firstDay = new Date(Date.UTC(year, month, 1)).getUTCDay()
152
153  return 1 + ((7 - firstDay) % 7) + (n - 1) * 7
154}
155
156/** New York's offset from UTC in hours: -4 from the second Sunday of March 2:00 to the first Sunday of November 2:00, -5 otherwise. */
157function newYorkOffset(now: number): number {
158  const year = new Date(now).getUTCFullYear()
159  const dstStart = Date.UTC(year, 2, nthSunday(year, 2, 2), 2 + 5)
160  const dstEnd = Date.UTC(year, 10, nthSunday(year, 10, 1), 2 + 4)
161
162  return now >= dstStart && now < dstEnd ? -4 : -5
163}
164
165/** Local weekday and minutes since midnight at a fixed UTC offset. */
166function localClock(now: number, offsetHours: number) {
167  const local = new Date(now + offsetHours * HOUR_MS)
168
169  return { day: local.getUTCDay(), minutes: local.getUTCHours() * 60 + local.getUTCMinutes() }
170}
171
172/**
173 * Whether a market is trading: Taiwan weekdays 08:30–14:00 (pre-open matching
174 * through the after-hours fixed-price session), US weekdays 09:30–16:00 New York.
175 */
176export function isMarketOpen(market: Market, now: number): boolean {
177  const { day, minutes } = market === 'tw' ? localClock(now, 8) : localClock(now, newYorkOffset(now))
178  const [open, close] = market === 'tw' ? [8 * 60 + 30, 14 * 60] : [9 * 60 + 30, 16 * 60]
179
180  return day >= 1 && day <= 5 && minutes >= open && minutes <= close
181}
182
183/** The Taipei calendar day, for once-a-day alerts. */
184export const taipeiDay = (now: number) => new Date(now + 8 * HOUR_MS).toISOString().slice(0, 10)
185
types/index.d.ts 35 lines
1export type Quote = {
2  symbol: string
3  label: string
4  price: number
5  changePercent: number
6  /** When the source priced it, in seconds, where the source says (Yahoo). */
7  asOf?: number
8}
9
10/** The language of the mod's own messages; quote codes and names are never translated. */
11export type Lang = 'en' | 'zh'
12
13export type TickerSettings = {
14  /** Seconds between polls for each market. */
15  refreshSeconds: { tw: number; us: number }
16  colors: 'red-up' | 'green-up'
17  /** Toast once a day when a quote moves at least this much; 0 turns alerts off. */
18  alertPercent: number
19  lang: Lang
20}
21
22/** The last failed fetch, kept as data so the band words it in the current language. */
23export type QuoteError = { market: 'tw' | 'us'; message: string }
24
25declare module 'claude-code' {
26  interface PluginState {
27    'stock-ticker': {
28      quotes: Quote[]
29      isHidden: boolean
30      error: QuoteError | null
31      settings: TickerSettings
32    }
33  }
34}
35