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

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.

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.

/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.
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
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
mis.twse.com.tw for Taiwan quotes and query1.finance.yahoo.com for US quotes, sending only the symbols on your watchlist.MIT
hooks/register.tsx 389 lines1import { 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}
389hooks/messages.ts 73 lines1import 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}
73hooks/quotes.ts 185 lines1import 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)
185types/index.d.ts 35 lines1export 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