侧边面板查看 A股/港股/美股指数与自选股行情

在 Claude Code 侧边面板里看行情,vibecoding 的时候瞄一眼大盘,不用切出终端。
hooks/boards.ts更新于 10:29:25
────────────────────────────────────────
A股 ● 盘中
上证指数 3,775.49 -36.41 ▼0.96%
▄▅▃▂▁█▇ █
深证成指 12,394.61 -226.29 ▼1.79%
▇▆▅▅▃▂▂▁ █▊
...
────────────────────────────────────────
板块 ● 盘中
半导体 ▼1.17%
远程股份 +10.1 园林股份 -10.0
盛视科技 +10.0 宏昌电子 -9.5
格尔软件 +10.0 兴发集团 -9.0
人工智能 ▼0.51%
...
在 Claude Code 终端会话里安装:
/plugin install market-pane --marketplace androidZzT/cc-moyu-kit
或者从源码加载:
claude --plugin-dir /path/to/cc-moyu-kit/mods/market-pane
终端宽度 ≥144 列时,会话启动后面板自动出现在右侧。窄于 144 列时 Claude Code 不让面板自动占位,需要用 /market 手动打开。
| 命令 | 作用 |
|---|---|
/market | 开关面板 |
/market add <代码> [名称] | 添加自选;已经添加过的代码会改成新名称 |
/market remove <代码> | 删除自选 |
/market list | 列出自选 |
/market play | 在行情和 K 线猜涨跌之间切换(游戏内 ↑/↓ 猜涨跌,Enter 下一局,q 退出) |
代码格式:
| 市场 | 格式 | 示例 |
|---|---|---|
| A股 | sh / sz / bj + 6 位数字 | sh600519、sz000001 |
| 港股 | hk + 5 位数字 | hk00700 |
| 美股 | us + 代码 | usAAPL、usBRK.B |
接口返回的中文名是 GBK 编码,mod 读不了,所以自选股显示的是你给的名称;没给名称就显示代码。
| 数据 | 盘中 | 休市 |
|---|---|---|
| 报价与关注板块(任一市场盘中即算盘中) | 每 10 秒 | 每 5 分钟 |
| A股/港股分时走势 | 每 60 秒 | 每 30 分钟 |
| 板块成分股涨跌榜(按 A股时段) | 每 60 秒 | 每 30 分钟 |
都是腾讯行情的公开接口,不需要 key:
| 用途 | 接口 |
|---|---|
| 报价与板块 | https://qt.gtimg.cn/q=<代码,...>(板块代码如 pt02003891) |
| 分时走势 | https://ifzq.gtimg.cn/appstock/app/minute/query?code=<代码> |
| 板块成分股涨跌榜 | http://proxy.finance.qq.com/cgi/cgi-bin/rank/hs/getBoardRankList?board_code=<板块代码>&...(https 证书宿主不信任,只能走 http) |
| 日 K(游戏) | https://ifzq.gtimg.cn/appstock/app/fqkline/get?param=<代码>,day,,,320,qfq |
这些都是非官方接口,没有可用性保证,接口变更或限流时对应数据会停在上一次(报价会标「过期」)。行情仅供参考,不构成投资建议。
claude plugin validate .
claude plugin test .
npx -p typescript tsc -p . # 先用 --plugin-dir 加载一次,生成 .claude-plugin/types
| 文件 | 内容 |
|---|---|
hooks/register.tsx | 所有用到 $ 的逻辑:事件接线、刷新、命令、state |
hooks/symbols.ts | 代码校验、内置指数 |
hooks/parse.ts | 报价 URL、解析响应、跳动比较 |
hooks/trend.ts | 分时解析、美股采样、重采样、走势线字符 |
hooks/bars.ts | 涨跌幅条形 |
hooks/boards.ts | 关注板块配置:代码与显示名 |
hooks/movers.ts | 成分股涨跌榜 URL、解析、剔除 ST |
hooks/hours.ts | 交易时段、刷新间隔 |
hooks/watchlist.ts | 自选增删、子命令解析 |
hooks/view.tsx | 面板绘制:指数与自选的两行布局 |
hooks/boards-view.tsx | 板块绘制:标题 + 涨跌前三两列 |
hooks/kline.ts | 日 K URL 与解析 |
hooks/game.ts | 题库、出题、判定、计分 |
hooks/game-props.ts | 把对局状态转成视图 props:揭晓前不带名称、日期和答案 |
hooks/kline-chart.ts | K 线字符图布局 |
hooks/game-view.tsx | 游戏视图绘制 |
hooks/game-surface.tsx | 游戏的 Client 模块:按键、按钮、逐根揭晓 |
hooks/register.tsx 412 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BoardMovers, Feed, GameRound, GameState, MoversFeed, Quote, TrendFeed } from '../types'
5
6import { EMPTY_SCORE, buildPool, makeRound, mulberry32, parseGameMessage, pickItem, sanitizeScore, scoreAfter } from './game'
7import type { GameMessage, PoolItem } from './game'
8import { toViewProps } from './game-props'
9import { BOARDS } from './boards'
10import { buildBoards, renderBoards } from './boards-view'
11import { isMarketOpen, refreshIntervalMs } from './hours'
12import { buildQuoteUrl, movedCodes, parseQuotes } from './parse'
13import { INDICES, marketOf } from './symbols'
14import { buildKlineUrl, parseKline } from './kline'
15import { buildMoversUrl, parseMovers } from './movers'
16import { buildMinuteUrl, parseMinute, sampleUs } from './trend'
17import { buildSections, buildStatus, renderPane } from './view'
18import { addItem, formatList, parseCommand, removeItem, sanitizeStored } from './watchlist'
19import type { ListChange } from './watchlist'
20import type { Market } from './symbols'
21
22// `$` may only be handed to functions declared in this file, so every
23// engine-facing step lives here; the other modules hold pure logic.
24
25const PANE_ID = 'market-pane'
26const PANE_TITLE = '行情'
27const TICK_MS = 10_000
28const FETCH_TIMEOUT_MS = 8_000
29/** Slack so a tick that lands a little early still counts as due. */
30const DUE_SLACK_MS = 1_000
31const ALL_MARKETS: readonly Market[] = ['cn', 'hk', 'us']
32/** Markets whose intraday series come from the minute endpoint; US series are sampled from quotes. */
33const MINUTE_MARKETS = ['cn', 'hk'] as const
34type MinuteMarket = (typeof MINUTE_MARKETS)[number]
35const MINUTE_FEED_KEY: Readonly<Record<MinuteMarket, 'cnAt' | 'hkAt'>> = { cn: 'cnAt', hk: 'hkAt' }
36const SLOW_OPEN_MS = 60_000
37const SLOW_CLOSED_MS = 30 * 60_000
38/** The minute endpoint answers one code per request; at most this many run at once. */
39const MINUTE_BATCH = 5
40
41const quotesAtom = atom({ plugin: 'market-pane', key: 'quotes' } as const, {})
42const feedAtom = atom({ plugin: 'market-pane', key: 'feed' } as const, { status: 'loading' })
43const watchlistAtom = atom({ plugin: 'market-pane', key: 'watchlist' } as const, [])
44const trendsAtom = atom({ plugin: 'market-pane', key: 'trends' } as const, {})
45const trendFeedAtom = atom({ plugin: 'market-pane', key: 'trendFeed' } as const, {})
46const flashAtom = atom({ plugin: 'market-pane', key: 'flash' } as const, [])
47const moversAtom = atom({ plugin: 'market-pane', key: 'movers' } as const, {})
48const moversFeedAtom = atom({ plugin: 'market-pane', key: 'moversFeed' } as const, {})
49const modeAtom = atom({ plugin: 'market-pane', key: 'mode' } as const, 'market')
50const gameAtom = atom({ plugin: 'market-pane', key: 'game' } as const, { status: 'idle', dealt: 0 })
51const scoreAtom = atom({ plugin: 'market-pane', key: 'score' } as const, EMPTY_SCORE)
52
53let inFlight = false
54let trendsInFlight = false
55let moversInFlight = false
56let dealInFlight = false
57
58const isPaneActive = async ($: EngineInterface): Promise<boolean> =>
59 (await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isPlaced)
60
61const fetchWithTimeout = async ($: EngineInterface, url: string): Promise<string> => {
62 const timeout = $.clock.sleep(FETCH_TIMEOUT_MS).then(() => {
63 throw new Error(`timed out after ${FETCH_TIMEOUT_MS}ms`)
64 })
65 const response = await Promise.race([$.http.fetch(url), timeout])
66 if (!response.ok) throw new Error(`HTTP ${response.status}`)
67 return response.text
68}
69
70/** Adds this refresh's US prices to the sampled US series (only during the US session). */
71const recordUsSamples = async (
72 $: EngineInterface,
73 quotes: Readonly<Record<string, Quote>>,
74 codes: readonly string[],
75 now: number,
76): Promise<void> => {
77 const usCodes = codes.filter(code => marketOf(code) === 'us')
78 const trends = await read($, trendsAtom)
79 const { usDay } = await read($, trendFeedAtom)
80 const sampled = sampleUs(trends, quotes, usCodes, now, usDay)
81 if (sampled.trends === trends) return
82 await update($, trendsAtom, () => sampled.trends)
83 await update($, trendFeedAtom, (feed): TrendFeed => ({ ...feed, usDay: sampled.usDay }))
84}
85
86/** Fetches every index, watchlist and watched-board quote in one request; on failure keeps the old quotes and marks them stale. */
87const refresh = async ($: EngineInterface): Promise<void> => {
88 if (inFlight) return
89 inFlight = true
90 try {
91 const watchlist = await read($, watchlistAtom)
92 const codes = [...INDICES.map(index => index.code), ...watchlist.map(item => item.code), ...BOARDS.map(board => board.code)]
93 const startedAt = await $.clock.now()
94 await update($, feedAtom, feed => ({ ...feed, lastFetchAt: startedAt }))
95 try {
96 const quotes = parseQuotes(codes, await fetchWithTimeout($, buildQuoteUrl(codes)))
97 const previous = await read($, quotesAtom)
98 await update($, flashAtom, () => movedCodes(previous, quotes))
99 await update($, quotesAtom, () => quotes)
100 await update($, feedAtom, (feed): Feed => ({ ...feed, status: 'ok', lastOkAt: startedAt, lastError: undefined }))
101 await recordUsSamples($, quotes, codes, startedAt)
102 } catch (error) {
103 const lastError = error instanceof Error ? error.message : String(error)
104 await update($, flashAtom, () => [])
105 await update($, feedAtom, (feed): Feed => ({ ...feed, status: 'stale', lastError }))
106 }
107 } finally {
108 inFlight = false
109 }
110}
111
112const slowIntervalMs = (market: Market, now: number): number =>
113 isMarketOpen(market, now) ? SLOW_OPEN_MS : SLOW_CLOSED_MS
114
115const isDue = (lastAt: number | undefined, interval: number, now: number): boolean =>
116 lastAt === undefined || now - lastAt >= interval - DUE_SLACK_MS
117
118/** One code's minute series, or undefined when the request fails, so its old series stays. */
119const fetchMinute = async ($: EngineInterface, code: string): Promise<readonly [string, number[]] | undefined> => {
120 try {
121 return [code, parseMinute(code, await fetchWithTimeout($, buildMinuteUrl(code)))] as const
122 } catch {
123 return undefined
124 }
125}
126
127/** Fetches minute series in batches of MINUTE_BATCH and merges the ones that arrived. */
128const fetchMinuteBatches = async ($: EngineInterface, codes: readonly string[]): Promise<void> => {
129 for (let start = 0; start < codes.length; start += MINUTE_BATCH) {
130 const results = await Promise.all(codes.slice(start, start + MINUTE_BATCH).map(code => fetchMinute($, code)))
131 const arrived = Object.fromEntries(results.filter(result => result !== undefined))
132 await update($, trendsAtom, trends => ({ ...trends, ...arrived }))
133 }
134}
135
136/**
137 * Refreshes the intraday series of each minute market whose interval has
138 * elapsed. With `fillMissing`, codes of the other markets that have no series
139 * yet are fetched too (pane opened, code added).
140 */
141const refreshTrends = async ($: EngineInterface, fillMissing: boolean): Promise<void> => {
142 if (trendsInFlight) return
143 trendsInFlight = true
144 try {
145 const now = await $.clock.now()
146 const watchlist = await read($, watchlistAtom)
147 const [trends, trendFeed] = [await read($, trendsAtom), await read($, trendFeedAtom)]
148 const allCodes = [...INDICES.map(index => index.code), ...watchlist.map(item => item.code)]
149 const dueMarkets = MINUTE_MARKETS.filter(market =>
150 isDue(trendFeed[MINUTE_FEED_KEY[market]], slowIntervalMs(market, now), now),
151 )
152 const codes = allCodes.filter(code => {
153 const market = marketOf(code)
154 if (market === 'us') return false
155 return dueMarkets.includes(market) || (fillMissing && trends[code] === undefined)
156 })
157 if (dueMarkets.length > 0) {
158 await update($, trendFeedAtom, (feed): TrendFeed => ({
159 ...feed,
160 ...Object.fromEntries(dueMarkets.map(market => [MINUTE_FEED_KEY[market], now])),
161 }))
162 }
163 await fetchMinuteBatches($, codes)
164 } finally {
165 trendsInFlight = false
166 }
167}
168
169/** One board's gainers and losers, or undefined when either side fails, so its old movers stay. */
170const fetchBoardMovers = async ($: EngineInterface, board: string): Promise<readonly [string, BoardMovers] | undefined> => {
171 try {
172 const [gainers, losers] = await Promise.all([
173 fetchWithTimeout($, buildMoversUrl(board, 'gainers')).then(parseMovers),
174 fetchWithTimeout($, buildMoversUrl(board, 'losers')).then(parseMovers),
175 ])
176 return [board, { gainers, losers }]
177 } catch {
178 return undefined
179 }
180}
181
182/**
183 * Refreshes every watched board's movers when the A股 interval has elapsed, or
184 * at once with `fillMissing` while some board has none yet. A board that fails
185 * keeps its last movers.
186 */
187const refreshMovers = async ($: EngineInterface, fillMissing: boolean): Promise<void> => {
188 if (moversInFlight) return
189 const now = await $.clock.now()
190 const { lastFetchAt } = await read($, moversFeedAtom)
191 const movers = await read($, moversAtom)
192 const isMissing = BOARDS.some(board => movers[board.code] === undefined)
193 if (!isDue(lastFetchAt, slowIntervalMs('cn', now), now) && !(fillMissing && isMissing)) return
194 moversInFlight = true
195 try {
196 await update($, moversFeedAtom, (feed): MoversFeed => ({ ...feed, lastFetchAt: now }))
197 const results = await Promise.all(BOARDS.map(board => fetchBoardMovers($, board.code)))
198 const fetched = results.filter(result => result !== undefined)
199 if (fetched.length === 0) return
200 await update($, moversAtom, (current): Record<string, BoardMovers> => ({ ...current, ...Object.fromEntries(fetched) }))
201 await update($, moversFeedAtom, (feed): MoversFeed => ({ ...feed, lastOkAt: now }))
202 } finally {
203 moversInFlight = false
204 }
205}
206
207/** Runs a background task; a failure is recorded on the feed instead of surfacing to the person. */
208const runInBackground = ($: EngineInterface, task: Promise<void>): void => {
209 task.catch(async (error: unknown) => {
210 const lastError = error instanceof Error ? error.message : String(error)
211 await update($, feedAtom, feed => ({ ...feed, lastError })).catch(() => undefined)
212 })
213}
214
215/** Runs every TICK_MS: refreshes only while the pane is placed and the market-hours interval has elapsed. */
216const tick = async ($: EngineInterface): Promise<void> => {
217 if (!(await isPaneActive($))) return
218 const now = await $.clock.now()
219 const { lastFetchAt } = await read($, feedAtom)
220 const interval = refreshIntervalMs(ALL_MARKETS, now)
221 if (isDue(lastFetchAt, interval, now)) {
222 await refresh($)
223 }
224 await refreshTrends($, false)
225 await refreshMovers($, false)
226}
227
228/** Everything the pane shows, fetched now: quotes (boards included), plus intraday series and board movers that are due or missing. */
229const refreshOnOpen = ($: EngineInterface): void => {
230 runInBackground($, refresh($))
231 runInBackground($, refreshTrends($, true))
232 runInBackground($, refreshMovers($, true))
233}
234
235// ── K 线猜涨跌 ──────────────────────────────────────────────────────────────
236
237const GAME_KEY = 'kline-game'
238const SCORE_STORE_KEY = 'kline-score'
239/** Codes tried per round before the round is reported as failed. */
240const DEAL_TRIES = 3
241/** Spreads seeds of rounds dealt within the same millisecond. */
242const SEED_STRIDE = 7_919
243const CLOSED_HINT = '休市中 · /market play 玩 K 线猜涨跌'
244const GAME_TERMINAL_ONLY = 'K 线猜涨跌目前只支持终端 · /market play 回到行情'
245
246/** One code's round, or undefined when its history fails to load or never gives a non-flat cut. */
247const dealFrom = async ($: EngineInterface, item: PoolItem, id: number, rand: () => number): Promise<GameRound | undefined> => {
248 try {
249 const draft = makeRound(parseKline(item.code, await fetchWithTimeout($, buildKlineUrl(item.code))), rand)
250 return draft === undefined ? undefined : { ...draft, id, code: item.code, name: item.name }
251 } catch {
252 return undefined
253 }
254}
255
256/** Deals the next round from the pool; after DEAL_TRIES failed codes the game shows the retry prompt. */
257const dealRound = async ($: EngineInterface): Promise<void> => {
258 if (dealInFlight) return
259 dealInFlight = true
260 try {
261 const { dealt } = await read($, gameAtom)
262 const id = dealt + 1
263 await update($, gameAtom, (game): GameState => ({ status: 'loading', dealt: game.dealt }))
264 const rand = mulberry32((await $.clock.now()) + id * SEED_STRIDE)
265 const pool = buildPool(await read($, watchlistAtom))
266 for (let attempt = 0; attempt < DEAL_TRIES; attempt += 1) {
267 const item = pickItem(pool, rand)
268 const round = item === undefined ? undefined : await dealFrom($, item, id, rand)
269 if (round !== undefined) {
270 await update($, gameAtom, (): GameState => ({ status: 'ready', round, dealt: id }))
271 return
272 }
273 }
274 await update($, gameAtom, (game): GameState => ({ status: 'error', dealt: game.dealt }))
275 } finally {
276 dealInFlight = false
277 }
278}
279
280/** Judges a guess against the round the hooks hold; a stale round or a second guess changes nothing. */
281const applyGuess = async ($: EngineInterface, message: Extract<GameMessage, { type: 'guess' }>): Promise<void> => {
282 const game = await read($, gameAtom)
283 const round = game.round
284 if (round === undefined || round.id !== message.round || game.verdict?.round === round.id) return
285 const isCorrect = message.guess === round.answer
286 const score = scoreAfter(await read($, scoreAtom), isCorrect)
287 await update($, scoreAtom, () => score)
288 await update($, gameAtom, (current): GameState => ({ ...current, verdict: { round: round.id, guess: message.guess, isCorrect } }))
289 await $.store.set(SCORE_STORE_KEY, score)
290}
291
292/** `/market play`: switches the pane to the game (dealing the first round) or back to the quotes. */
293const toggleGame = async ($: EngineInterface): Promise<string> => {
294 if ((await read($, modeAtom)) === 'game') {
295 await update($, modeAtom, () => 'market' as const)
296 return '已回到行情'
297 }
298 await update($, modeAtom, () => 'game' as const)
299 if (!(await isPaneActive($))) await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
300 if ((await read($, gameAtom)).round === undefined) runInBackground($, dealRound($))
301 return 'K 线猜涨跌:点一下面板,按 ↑ 猜涨、↓ 猜跌,q 退出'
302}
303
304const STORE_KEY = 'watchlist'
305const USAGE = '用法:/market 开关面板 · /market add <代码> [名称] · /market remove <代码> · /market list · /market play 猜涨跌'
306
307/** Closes the pane when it is shown, otherwise opens it (at any width, since the person asked) and refreshes. */
308const togglePane = async ($: EngineInterface): Promise<string> => {
309 if (await isPaneActive($)) {
310 await $.ui.close({ id: PANE_ID })
311 return '行情面板已关闭'
312 }
313 await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
314 refreshOnOpen($)
315 return '行情面板已打开'
316}
317
318/** Writes a changed watchlist to the session state, then persists it, then refreshes so new codes show at once. */
319const applyListChange = async ($: EngineInterface, change: ListChange): Promise<string> => {
320 if (!change.isChanged) return change.message
321 const list = [...change.list]
322 await update($, watchlistAtom, () => list)
323 await $.store.set(STORE_KEY, list)
324 if (await isPaneActive($)) refreshOnOpen($)
325 return change.message
326}
327
328export const register: Register = on => {
329 on('session.start', async ($, e, next) => {
330 const stored = sanitizeStored(await $.store.get(STORE_KEY))
331 await update($, watchlistAtom, () => stored)
332 const score = sanitizeScore(await $.store.get(SCORE_STORE_KEY))
333 await update($, scoreAtom, () => score)
334 await $.command.register({ name: 'market', description: '开关行情面板;add/remove/list 管理自选' })
335 $.clock.every(TICK_MS, () => runInBackground($, tick($)))
336 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
337 if (opened.isPlaced) refreshOnOpen($)
338 return next(e)
339 })
340
341 on('command.run', { command: 'market' }, async ($, e) => {
342 const command = parseCommand(e.args)
343 const list = await read($, watchlistAtom)
344 switch (command.kind) {
345 case 'toggle':
346 return { text: await togglePane($) }
347 case 'add':
348 return { text: await applyListChange($, addItem(list, command.code, command.name)) }
349 case 'remove':
350 return { text: await applyListChange($, removeItem(list, command.code)) }
351 case 'list':
352 return { text: formatList(list) }
353 case 'play':
354 return { text: await toggleGame($) }
355 case 'usage':
356 return { text: USAGE }
357 }
358 })
359
360 on('ui.message', async ($, e, next) => {
361 if (e.element !== GAME_KEY) return next(e)
362 const message = parseGameMessage(e.data)
363 if (message === undefined) return {}
364 switch (message.type) {
365 case 'quit':
366 await update($, modeAtom, () => 'market' as const)
367 return {}
368 case 'next':
369 runInBackground($, dealRound($))
370 return {}
371 case 'guess':
372 await applyGuess($, message)
373 return { props: toViewProps(await read($, gameAtom), await read($, scoreAtom)) }
374 }
375 })
376
377 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
378 const elements = $.ui.resolve(e)
379 if ((await read($, modeAtom)) === 'game') {
380 if (e.surface !== 'terminal') {
381 const { Text } = elements
382 return <Text dimColor>{GAME_TERMINAL_ONLY}</Text>
383 }
384 const { Box, Client } = $.ui.resolve(e)
385 const props = toViewProps(await read($, gameAtom), await read($, scoreAtom))
386 return (
387 <Box flexDirection="column">
388 <Client key={GAME_KEY} module="./game-surface.tsx" props={props} width={e.props.bodyColumns} height={e.props.scroll.bodyRows} />
389 </Box>
390 )
391 }
392 const now = await $.clock.now()
393 const extras = { trends: await read($, trendsAtom), flash: await read($, flashAtom) }
394 const sections = buildSections(await read($, quotesAtom), await read($, watchlistAtom), now, extras)
395 const columns = e.props.bodyColumns
396 const boards = buildBoards(await read($, quotesAtom), await read($, moversAtom), isMarketOpen('cn', now))
397 const isAllClosed = !ALL_MARKETS.some(market => isMarketOpen(market, now))
398 const { Box, Text } = elements
399 const tail = (
400 <Box key="tail" flexDirection="column">
401 {renderBoards(elements, boards, columns)}
402 {isAllClosed && (
403 <Box key="closed-hint" marginTop={1}>
404 <Text dimColor>{CLOSED_HINT}</Text>
405 </Box>
406 )}
407 </Box>
408 )
409 return renderPane(elements, sections, buildStatus(await read($, feedAtom)), columns, tail)
410 })
411}
412hooks/game.ts 139 lines1import type { Candle, GameRound, GameScore, Guess, WatchItem } from '../types'
2import { marketOf } from './symbols'
3
4/** Trading days hidden after the cut; the guess is about the close this many days later. */
5export const REVEAL_DAYS = 5
6/** Fewest candles a round shows, so the chart has a shape to read. */
7export const MIN_VISIBLE = 40
8/** Most candles a round keeps; a wide pane shows at most this many. */
9export const MAX_VISIBLE = 120
10/** Cuts tried before a code is given up as flat. */
11export const MAX_CUT_TRIES = 8
12
13export type PoolItem = { code: string; name: string }
14
15/** Well-known A股/港股 names and the main indices; US stocks need an exchange suffix and are left out. */
16export const GAME_POOL: readonly PoolItem[] = [
17 { code: 'sh000001', name: '上证指数' },
18 { code: 'sz399001', name: '深证成指' },
19 { code: 'sz399006', name: '创业板指' },
20 { code: 'hkHSI', name: '恒生指数' },
21 { code: 'hkHSTECH', name: '恒生科技指数' },
22 { code: 'sh600519', name: '贵州茅台' },
23 { code: 'sh601318', name: '中国平安' },
24 { code: 'sh600036', name: '招商银行' },
25 { code: 'sh601899', name: '紫金矿业' },
26 { code: 'sh600900', name: '长江电力' },
27 { code: 'sh601012', name: '隆基绿能' },
28 { code: 'sh600276', name: '恒瑞医药' },
29 { code: 'sh688981', name: '中芯国际' },
30 { code: 'sz300750', name: '宁德时代' },
31 { code: 'sz002594', name: '比亚迪' },
32 { code: 'sz000858', name: '五粮液' },
33 { code: 'sz000333', name: '美的集团' },
34 { code: 'sz300059', name: '东方财富' },
35 { code: 'sz002415', name: '海康威视' },
36 { code: 'hk00700', name: '腾讯控股' },
37 { code: 'hk09988', name: '阿里巴巴' },
38 { code: 'hk03690', name: '美团' },
39 { code: 'hk01810', name: '小米集团' },
40 { code: 'hk00941', name: '中国移动' },
41 { code: 'hk02318', name: '中国平安(H)' },
42]
43
44/** The built-in pool plus the person's A股/港股 watchlist codes, first occurrence kept. */
45export const buildPool = (watchlist: readonly WatchItem[]): PoolItem[] => {
46 const extra = watchlist.filter(item => marketOf(item.code) !== 'us')
47 const seen = new Set<string>()
48 return [...GAME_POOL, ...extra].filter(item => {
49 if (seen.has(item.code)) return false
50 seen.add(item.code)
51 return true
52 })
53}
54
55/** A small seeded generator in [0, 1), so a mocked clock deals the same rounds. */
56export const mulberry32 = (seed: number): (() => number) => {
57 let a = seed >>> 0
58 return () => {
59 a = (a + 0x6d2b79f5) >>> 0
60 let t = a
61 t = Math.imul(t ^ (t >>> 15), t | 1)
62 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
63 return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296
64 }
65}
66
67export const pickItem = <T>(items: readonly T[], rand: () => number): T | undefined =>
68 items[Math.floor(rand() * items.length)]
69
70export type RoundDraft = Pick<GameRound, 'candles' | 'visible' | 'answer' | 'changePct'>
71
72/**
73 * A round cut from `candles`: the last visible candle sits at a random index
74 * with at least MIN_VISIBLE before it and REVEAL_DAYS after it. A cut whose
75 * outcome is flat is drawn again; undefined when the history is too short or
76 * every try is flat.
77 */
78export const makeRound = (candles: readonly Candle[], rand: () => number): RoundDraft | undefined => {
79 const first = MIN_VISIBLE - 1
80 const last = candles.length - 1 - REVEAL_DAYS
81 if (last < first) return undefined
82 for (let attempt = 0; attempt < MAX_CUT_TRIES; attempt += 1) {
83 const cut = first + Math.floor(rand() * (last - first + 1))
84 const base = candles[cut]?.close ?? 0
85 const later = candles[cut + REVEAL_DAYS]?.close ?? 0
86 if (base === later || base <= 0) continue
87 const start = Math.max(0, cut + 1 - MAX_VISIBLE)
88 return {
89 candles: candles.slice(start, cut + 1 + REVEAL_DAYS),
90 visible: cut + 1 - start,
91 answer: later > base ? 'up' : 'down',
92 changePct: ((later - base) / base) * 100,
93 }
94 }
95 return undefined
96}
97
98export const EMPTY_SCORE: GameScore = { played: 0, correct: 0, streak: 0, best: 0 }
99
100export const scoreAfter = (score: GameScore, isCorrect: boolean): GameScore => {
101 const streak = isCorrect ? score.streak + 1 : 0
102 return {
103 played: score.played + 1,
104 correct: score.correct + (isCorrect ? 1 : 0),
105 streak,
106 best: Math.max(score.best, streak),
107 }
108}
109
110const count = (value: unknown): number =>
111 typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : 0
112
113/** A stored score made safe: non-negative integers, correct and streak never past played. */
114export const sanitizeScore = (value: unknown): GameScore => {
115 if (typeof value !== 'object' || value === null) return EMPTY_SCORE
116 const raw = value as Record<string, unknown>
117 const played = count(raw.played)
118 return {
119 played,
120 correct: Math.min(count(raw.correct), played),
121 streak: Math.min(count(raw.streak), played),
122 best: Math.min(count(raw.best), played),
123 }
124}
125
126export type GameMessage = { type: 'next' } | { type: 'quit' } | { type: 'guess'; round: number; guess: Guess }
127
128/** What a Client posted, validated; anything else is undefined and ignored. */
129export const parseGameMessage = (data: unknown): GameMessage | undefined => {
130 if (typeof data !== 'object' || data === null) return undefined
131 const raw = data as Record<string, unknown>
132 if (raw.type === 'next' || raw.type === 'quit') return { type: raw.type }
133 const isGuess = raw.guess === 'up' || raw.guess === 'down'
134 if (raw.type === 'guess' && isGuess && typeof raw.round === 'number' && Number.isInteger(raw.round)) {
135 return { type: 'guess', round: raw.round, guess: raw.guess as Guess }
136 }
137 return undefined
138}
139hooks/game-props.ts 33 lines1import type { GameRound, GameScore, GameState } from '../types'
2import type { GameViewProps } from './game-view'
3
4/** A candle with its date dropped, so nothing on screen dates the round before it is answered. */
5const undated = (round: GameRound, count: number) =>
6 round.candles.slice(0, count).map(candle => ({ ...candle, date: '' }))
7
8/** The Client's props: visible candles only until a verdict, then the hidden ones, the name and the dates. */
9export const toViewProps = (game: GameState, score: GameScore): GameViewProps => {
10 const status = game.status === 'idle' ? 'loading' : game.status
11 const round = game.round
12 if (round === undefined) return { status, score }
13 const verdict = game.verdict?.round === round.id ? game.verdict : undefined
14 if (verdict === undefined) {
15 return { status, score, round: { id: round.id, candles: undated(round, round.visible), visible: round.visible } }
16 }
17 return {
18 status,
19 score,
20 round: { id: round.id, candles: undated(round, round.candles.length), visible: round.visible },
21 reveal: {
22 round: round.id,
23 guess: verdict.guess,
24 isCorrect: verdict.isCorrect,
25 name: round.name,
26 code: round.code,
27 from: round.candles[round.visible - 1]?.date ?? '',
28 to: round.candles.at(-1)?.date ?? '',
29 changePct: round.changePct,
30 },
31 }
32}
33hooks/boards.ts 12 lines1/**
2 * The A股 boards the pane watches, in display order. qt.gtimg.cn quotes board
3 * codes like stocks; the names it returns are GBK, so each board's name lives here.
4 */
5export const BOARDS: readonly { code: string; name: string }[] = [
6 { code: 'pt02003891', name: '半导体' }, // 芯片概念
7 { code: 'pt02003800', name: '人工智能' },
8 { code: 'pt02003640', name: '机器人' }, // 机器人概念
9 { code: 'pt02003711', name: '券商' }, // 券商概念
10 { code: 'pt02131362', name: '创新药' },
11]
12hooks/boards-view.tsx 99 lines1import type { RenderElement } from 'claude-code'
2
3import type { BoardMovers, Mover, Quote } from '../types'
4import { BOARDS } from './boards'
5import { MOVERS_PER_SIDE, formatMoverPct } from './movers'
6import { renderHeader, renderRule } from './view'
7import type { PaneElements } from './view'
8
9export const BOARDS_TITLE = '板块'
10export const BOARDS_UNAVAILABLE = '板块数据暂不可用'
11const UP_COLOR = 'red'
12const DOWN_COLOR = 'green'
13const MISSING_PCT_TEXT = '--'
14const COLUMN_GAP = 1
15
16export type BoardBlock = {
17 code: string
18 name: string
19 /** Absent while the board has no quote. */
20 pct?: number
21 gainers: readonly Mover[]
22 losers: readonly Mover[]
23}
24
25export type Boards = { blocks: BoardBlock[]; isOpen: boolean }
26
27/** A block per watched board in BOARDS order; none until some board has a quote or movers. */
28export const buildBoards = (
29 quotes: Readonly<Record<string, Quote>>,
30 movers: Readonly<Record<string, BoardMovers>>,
31 isOpen: boolean,
32): Boards => {
33 const blocks = BOARDS.map(({ code, name }): BoardBlock => {
34 const quote = quotes[code]
35 const ranked = movers[code] ?? { gainers: [], losers: [] }
36 const pct = quote === undefined || quote.isMissing ? undefined : quote.pct
37 return { code, name, pct, gainers: ranked.gainers, losers: ranked.losers }
38 })
39 const hasData = blocks.some(block => block.pct !== undefined || block.gainers.length + block.losers.length > 0)
40 return { blocks: hasData ? blocks : [], isOpen }
41}
42
43const colorOf = (pct: number): string | undefined => (pct > 0 ? UP_COLOR : pct < 0 ? DOWN_COLOR : undefined)
44
45/** ▲1.17% / ▼1.17%, like the index rows. */
46const formatBoardPct = (pct: number): string => `${pct > 0 ? '▲' : pct < 0 ? '▼' : ' '}${Math.abs(pct).toFixed(2)}%`
47
48const renderTitle = ({ Box, Text }: PaneElements, block: BoardBlock): RenderElement => (
49 <Box key={`board-${block.code}`} flexDirection="row" columnGap={1}>
50 <Text bold>{block.name}</Text>
51 {block.pct === undefined ? (
52 <Text dimColor>{MISSING_PCT_TEXT}</Text>
53 ) : (
54 <Text color={colorOf(block.pct)}>{formatBoardPct(block.pct)}</Text>
55 )}
56 </Box>
57)
58
59const renderMover = ({ Box, Text }: PaneElements, key: string, mover: Mover | undefined, width: number): RenderElement => (
60 <Box key={key} width={width} flexShrink={0} flexDirection="row" justifyContent="space-between">
61 {mover !== undefined && <Text wrap="truncate-end">{mover.name}</Text>}
62 {mover !== undefined && (
63 <Box key="pct" flexShrink={0}>
64 <Text color={colorOf(mover.pct)}>{formatMoverPct(mover.pct)}</Text>
65 </Box>
66 )}
67 </Box>
68)
69
70/** Three rows of the i-th gainer beside the i-th loser; none when the board has no movers yet. */
71const renderMovers = (elements: PaneElements, block: BoardBlock, width: number): RenderElement[] => {
72 const { Box } = elements
73 const rows = Math.min(MOVERS_PER_SIDE, Math.max(block.gainers.length, block.losers.length))
74 return Array.from({ length: rows }, (_, i) => (
75 <Box key={`movers-${block.code}-${i}`} flexDirection="row" columnGap={COLUMN_GAP}>
76 {renderMover(elements, 'up', block.gainers[i], width)}
77 {renderMover(elements, 'down', block.losers[i], width)}
78 </Box>
79 ))
80}
81
82export const renderBoards = (elements: PaneElements, boards: Boards, columns: number): RenderElement => {
83 const { Box, Text } = elements
84 const width = Math.max(Math.floor((columns - COLUMN_GAP) / 2), 1)
85 return (
86 <Box key={`section-${BOARDS_TITLE}`} flexDirection="column">
87 {renderRule(elements, `rule-${BOARDS_TITLE}`, columns)}
88 {renderHeader(elements, BOARDS_TITLE, boards.isOpen)}
89 {boards.blocks.length === 0 ? (
90 <Box key="boards-empty">
91 <Text dimColor>{BOARDS_UNAVAILABLE}</Text>
92 </Box>
93 ) : (
94 boards.blocks.flatMap(block => [renderTitle(elements, block), ...renderMovers(elements, block, width)])
95 )}
96 </Box>
97 )
98}
99hooks/hours.ts 53 lines1import type { Market } from './symbols'
2
3export const OPEN_INTERVAL_MS = 10_000
4export const CLOSED_INTERVAL_MS = 300_000
5
6const HOUR_MS = 3_600_000
7const DAY_MS = 24 * HOUR_MS
8const BEIJING_OFFSET_H = 8
9const US_EASTERN_STANDARD_OFFSET_H = -5
10const US_EASTERN_DAYLIGHT_OFFSET_H = -4
11
12/** Trading sessions in minutes after local midnight, start inclusive, end exclusive. */
13const SESSIONS: Readonly<Record<Market, readonly (readonly [number, number])[]>> = {
14 cn: [[9 * 60 + 30, 11 * 60 + 30], [13 * 60, 15 * 60]],
15 hk: [[9 * 60 + 30, 12 * 60], [13 * 60, 16 * 60]],
16 us: [[9 * 60 + 30, 16 * 60]],
17}
18
19/** Day of month (UTC) of the n-th Sunday of a month. */
20const nthSunday = (year: number, month: number, n: number): number => {
21 const firstDay = new Date(Date.UTC(year, month, 1)).getUTCDay()
22 return 1 + ((7 - firstDay) % 7) + (n - 1) * 7
23}
24
25/** US DST: 2:00 local on the second Sunday of March to 2:00 local on the first Sunday of November. */
26export const isUsDaylightTime = (nowMs: number): boolean => {
27 const year = new Date(nowMs).getUTCFullYear()
28 const start = Date.UTC(year, 2, nthSunday(year, 2, 2), 2 - US_EASTERN_STANDARD_OFFSET_H)
29 const end = Date.UTC(year, 10, nthSunday(year, 10, 1), 2 - US_EASTERN_DAYLIGHT_OFFSET_H)
30 return nowMs >= start && nowMs < end
31}
32
33const offsetHours = (market: Market, nowMs: number): number => {
34 if (market !== 'us') return BEIJING_OFFSET_H
35 return isUsDaylightTime(nowMs) ? US_EASTERN_DAYLIGHT_OFFSET_H : US_EASTERN_STANDARD_OFFSET_H
36}
37
38/** Whether the market is in a trading session; holidays are not modelled. */
39export const isMarketOpen = (market: Market, nowMs: number): boolean => {
40 const localMs = nowMs + offsetHours(market, nowMs) * HOUR_MS
41 const weekday = new Date(localMs).getUTCDay()
42 if (weekday === 0 || weekday === 6) return false
43 const minutes = Math.floor((localMs % DAY_MS) / 60_000)
44 return SESSIONS[market].some(([start, end]) => minutes >= start && minutes < end)
45}
46
47/** The market's local calendar date, YYYY-MM-DD. */
48export const marketLocalDate = (market: Market, nowMs: number): string =>
49 new Date(nowMs + offsetHours(market, nowMs) * HOUR_MS).toISOString().slice(0, 10)
50
51export const refreshIntervalMs =(markets: readonly Market[], nowMs: number): number =>
52 markets.some(market => isMarketOpen(market, nowMs)) ? OPEN_INTERVAL_MS : CLOSED_INTERVAL_MS
53hooks/parse.ts 56 lines1import type { Quote } from '../types'
2
3export const QUOTE_ENDPOINT = 'https://qt.gtimg.cn/q='
4
5const FIELD_PRICE = 3
6const FIELD_PREV_CLOSE = 4
7const LINE = /v_([A-Za-z0-9.]+)="([^"]*)"/g
8
9export const MISSING_QUOTE: Quote = { price: 0, prevClose: 0, change: 0, pct: 0, isMissing: true }
10
11export const buildQuoteUrl = (codes: readonly string[]): string =>
12 QUOTE_ENDPOINT + [...new Set(codes)].join(',')
13
14const toQuote = (fields: readonly string[]): Quote => {
15 const price = Number(fields[FIELD_PRICE])
16 const prevClose = Number(fields[FIELD_PREV_CLOSE])
17 if (!(Number.isFinite(price) && price > 0 && Number.isFinite(prevClose) && prevClose > 0)) {
18 return MISSING_QUOTE
19 }
20 const change = price - prevClose
21 return { price, prevClose, change, pct: (change / prevClose) * 100, isMissing: false }
22}
23
24/**
25 * Parses a quote response into one entry per requested code. Only numeric
26 * fields are read, so the GBK-encoded names do not matter. A code the
27 * response does not answer (absent, or `pv_none_match`) is marked missing.
28 * Throws when the body holds no quote line at all, so the caller can treat
29 * it as a failed fetch.
30 */
31export const parseQuotes = (codes: readonly string[], body: string): Record<string, Quote> => {
32 const answered = new Map<string, Quote>()
33 for (const match of body.matchAll(LINE)) {
34 const [, code, payload] = match
35 if (code && payload !== undefined && payload.includes('~')) {
36 answered.set(code, toQuote(payload.split('~')))
37 }
38 }
39 if (answered.size === 0 && !body.includes('pv_none_match') && codes.length > 0) {
40 throw new Error('quote response holds no quote line')
41 }
42 return Object.fromEntries(codes.map(code => [code, answered.get(code) ?? MISSING_QUOTE]))
43}
44
45/** Codes quoted in both refreshes whose price moved; a code without an earlier quote is not counted. */
46export const movedCodes = (
47 previous: Readonly<Record<string, Quote>>,
48 next: Readonly<Record<string, Quote>>,
49): string[] =>
50 Object.entries(next)
51 .filter(([code, quote]) => {
52 const before = previous[code]
53 return before !== undefined && !before.isMissing && !quote.isMissing && before.price !== quote.price
54 })
55 .map(([code]) => code)
56hooks/symbols.ts 44 lines1export type Market = 'cn' | 'hk' | 'us'
2
3export type IndexDef = { code: string; name: string; market: Market }
4
5export const MARKET_LABELS: Readonly<Record<Market, string>> = {
6 cn: 'A股',
7 hk: '港股',
8 us: '美股',
9}
10
11export const INDICES: readonly IndexDef[] = [
12 { code: 'sh000001', name: '上证指数', market: 'cn' },
13 { code: 'sz399001', name: '深证成指', market: 'cn' },
14 { code: 'sz399006', name: '创业板指', market: 'cn' },
15 { code: 'hkHSI', name: '恒生指数', market: 'hk' },
16 { code: 'hkHSTECH', name: '恒生科技指数', market: 'hk' },
17 { code: 'usDJI', name: '道琼斯', market: 'us' },
18 { code: 'usIXIC', name: '纳斯达克', market: 'us' },
19 { code: 'usINX', name: '标普500', market: 'us' },
20]
21
22export const CODE_FORMAT_HELP =
23 '代码格式:A股 sh/sz/bj+6位数字(如 sh600519),港股 hk+5位数字或指数字母(如 hk00700),美股 us+代码(如 usAAPL)'
24
25const CN = /^(sh|sz|bj)(\d{6})$/
26const HK = /^hk(\d{5}|[A-Z]+)$/
27const US = /^us([A-Z][A-Z.]*)$/
28
29/** Returns the canonical code, or undefined when the input is not a valid code. */
30export const normalizeCode = (input: string): string | undefined => {
31 const raw = input.trim()
32 if (raw.length < 3) return undefined
33 const prefix = raw.slice(0, 2).toLowerCase()
34 const body = raw.slice(2)
35 const candidate = prefix === 'hk' || prefix === 'us' ? prefix + body.toUpperCase() : prefix + body
36 return CN.test(candidate) || HK.test(candidate) || US.test(candidate) ? candidate : undefined
37}
38
39export const marketOf = (code: string): Market => {
40 if (code.startsWith('hk')) return 'hk'
41 if (code.startsWith('us')) return 'us'
42 return 'cn'
43}
44hooks/kline.ts 35 lines1import type { Candle } from '../types'
2
3/** Same DigiCert-chained host as the minute endpoint; see trend.ts. */
4export const KLINE_ENDPOINT = 'https://ifzq.gtimg.cn/appstock/app/fqkline/get'
5/** Daily candles per request: the endpoint's own cap, about 15 months. */
6export const KLINE_DAYS = 320
7
8/** A股 answers forward-adjusted candles under `qfqday`, 港股 and indices under `day`. */
9const SERIES_KEYS = ['qfqday', 'day'] as const
10
11type KlineResponse = { data?: Record<string, Record<string, unknown> | undefined> | string }
12
13export const buildKlineUrl = (code: string): string => `${KLINE_ENDPOINT}?param=${code},day,,,${KLINE_DAYS},qfq`
14
15/** `[date, open, close, high, low, volume, ...]`, numbers as strings; undefined when any price is unusable. */
16const toCandle = (entry: unknown): Candle | undefined => {
17 if (!Array.isArray(entry) || typeof entry[0] !== 'string') return undefined
18 const [open, close, high, low] = entry.slice(1, 5).map(Number)
19 const prices = [open, close, high, low]
20 if (prices.some(price => price === undefined || !Number.isFinite(price) || price <= 0)) return undefined
21 return { date: entry[0], open: open ?? 0, close: close ?? 0, high: high ?? 0, low: low ?? 0 }
22}
23
24/** Daily candles for one code, oldest first. Throws when the body holds no series for it. */
25export const parseKline = (code: string, body: string): Candle[] => {
26 const response = JSON.parse(body) as KlineResponse
27 const entry = typeof response.data === 'object' ? response.data[code] : undefined
28 const series = SERIES_KEYS.map(key => entry?.[key]).find(Array.isArray)
29 if (series === undefined) throw new Error(`kline response holds no series for ${code}`)
30 return (series as unknown[]).flatMap(item => {
31 const candle = toCandle(item)
32 return candle === undefined ? [] : [candle]
33 })
34}
35hooks/movers.ts 50 lines1import type { Mover } from '../types'
2
3/**
4 * Plain http on purpose: the https certificate chains to GlobalSign Root R3,
5 * which fails certificate verification in the host's `$.http.fetch`, and no
6 * other Tencent host serves this ranking. The data is public and only displayed.
7 */
8const BOARD_RANK_ENDPOINT = 'http://proxy.finance.qq.com/cgi/cgi-bin/rank/hs/getBoardRankList'
9export const MOVERS_PER_SIDE = 3
10/** Entries asked per side, so MOVERS_PER_SIDE remain after the ST names are dropped. */
11const MOVERS_FETCH_COUNT = 6
12/** ST and *ST: stocks under special treatment, whose limit moves say little about the board. */
13const SPECIAL_TREATMENT = 'ST'
14
15export type MoverSide = 'gainers' | 'losers'
16
17type RankResponse = { code?: number; data?: { rank_list?: unknown } }
18type RankEntry = { code?: unknown; name?: unknown; zdf?: unknown }
19
20/** `direct=down` ranks by change descending (gainers first), `direct=up` ascending (losers first). */
21export const buildMoversUrl = (board: string, side: MoverSide): string =>
22 `${BOARD_RANK_ENDPOINT}?board_code=${board}&sort_type=priceRatio&direct=${side === 'gainers' ? 'down' : 'up'}` +
23 `&offset=0&count=${MOVERS_FETCH_COUNT}`
24
25const toMover = (entry: RankEntry): Mover[] => {
26 const pct = Number(entry.zdf)
27 const isValid =
28 typeof entry.code === 'string' && typeof entry.name === 'string' && entry.name !== '' && Number.isFinite(pct)
29 return isValid && !(entry.name as string).includes(SPECIAL_TREATMENT)
30 ? [{ code: entry.code as string, name: entry.name as string, pct }]
31 : []
32}
33
34/** The first MOVERS_PER_SIDE non-ST stocks in the listed order. Throws on an error reply or an unexpected body. */
35export const parseMovers = (body: string): Mover[] => {
36 const response = JSON.parse(body) as RankResponse
37 const list = response.data?.rank_list
38 if (response.code !== 0 || !Array.isArray(list)) throw new Error('board rank response holds no list')
39 return (list as RankEntry[]).flatMap(toMover).slice(0, MOVERS_PER_SIDE)
40}
41
42/** Guards half-up rounding of two-decimal inputs: 9.45 is stored as 9.4499…, which plain toFixed(1) rounds down. */
43const ROUNDING_EPSILON = 1e-9
44
45/** One decimal with a sign, halves rounded away from zero: -9.45 → "-9.5". */
46export const formatMoverPct = (pct: number): string => {
47 const tenths = Math.round(Math.abs(pct) * 10 + ROUNDING_EPSILON) / 10
48 return `${pct > 0 ? '+' : pct < 0 ? '-' : ''}${tenths.toFixed(1)}`
49}
50hooks/trend.ts 80 lines1import type { Quote } from '../types'
2import { isMarketOpen, marketLocalDate } from './hours'
3
4/**
5 * ifzq.gtimg.cn rather than web.ifzq.gtimg.cn: same endpoint and body, but its
6 * certificate chains to DigiCert. web.ifzq's chains to GlobalSign Root R3,
7 * which fails certificate verification in the host's `$.http.fetch`.
8 */
9export const MINUTE_ENDPOINT = 'https://ifzq.gtimg.cn/appstock/app/minute/query?code='
10/** Most samples kept per US series; a full session at 10 s per refresh would be 2,340. */
11export const MAX_SAMPLES = 400
12
13const SPARK_LEVELS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const
14const FLAT_LEVEL = 3
15/** Position of the price in a "HHMM price volume amount" minute entry. */
16const MINUTE_FIELD_PRICE = 1
17
18type MinuteResponse = { data?: Record<string, { data?: { data?: unknown } } | undefined> }
19
20export const buildMinuteUrl = (code: string): string => MINUTE_ENDPOINT + code
21
22/**
23 * Today's minute prices for one code, oldest first. An unknown code answers
24 * with no usable entries and yields []. Throws when the body is not the
25 * expected shape, so the caller can keep the previous series.
26 */
27export const parseMinute = (code: string, body: string): number[] => {
28 const response = JSON.parse(body) as MinuteResponse
29 const entries = response.data?.[code]?.data?.data
30 if (!Array.isArray(entries)) throw new Error(`minute response holds no series for ${code}`)
31 return entries
32 .map(entry => (typeof entry === 'string' ? Number(entry.trim().split(/\s+/)[MINUTE_FIELD_PRICE]) : Number.NaN))
33 .filter(price => Number.isFinite(price) && price > 0)
34}
35
36/** At most `width` points: the last point of each equal bucket, so the latest point stays rightmost. */
37export const resample = (series: readonly number[], width: number): number[] => {
38 if (width <= 0) return []
39 if (series.length <= width) return [...series]
40 return Array.from({ length: width }, (_, i) => series[Math.floor(((i + 1) * series.length) / width) - 1] ?? 0)
41}
42
43/** One block glyph per point, scaled between the series' own low and high; '' below 2 points. */
44export const sparkline = (series: readonly number[]): string => {
45 if (series.length < 2) return ''
46 const low = Math.min(...series)
47 const span = Math.max(...series) - low
48 const top = SPARK_LEVELS.length - 1
49 return series
50 .map(price => SPARK_LEVELS[span === 0 ? FLAT_LEVEL : Math.round(((price - low) / span) * top)])
51 .join('')
52}
53
54export type UsSample = { trends: Record<string, number[]>; usDay: string | undefined }
55
56/**
57 * Appends each quoted US code's price while the US market is open. The first
58 * sample of a new US trading day drops the previous day's US series. Outside
59 * the session the input is returned unchanged.
60 */
61export const sampleUs = (
62 trends: Readonly<Record<string, number[]>>,
63 quotes: Readonly<Record<string, Quote>>,
64 usCodes: readonly string[],
65 nowMs: number,
66 usDay: string | undefined,
67): UsSample => {
68 if (!isMarketOpen('us', nowMs)) return { trends, usDay }
69 const today = marketLocalDate('us', nowMs)
70 const isNewDay = today !== usDay
71 const sampled = usCodes.flatMap(code => {
72 const quote = quotes[code]
73 if (quote === undefined || quote.isMissing) return []
74 const previous = isNewDay ? [] : (trends[code] ?? [])
75 return [[code, [...previous, quote.price].slice(-MAX_SAMPLES)] as const]
76 })
77 const kept = Object.entries(trends).filter(([code]) => !(isNewDay && usCodes.includes(code)))
78 return { trends: { ...Object.fromEntries(kept), ...Object.fromEntries(sampled) }, usDay: today }
79}
80hooks/view.tsx 214 lines1import type { BoxProps, ElementConstructor, RenderElement, TextProps } from 'claude-code'
2
3import type { Feed, Quote, WatchItem } from '../types'
4import { BAR_CELLS, pctBar } from './bars'
5import { isMarketOpen } from './hours'
6import { INDICES, MARKET_LABELS } from './symbols'
7import type { Market } from './symbols'
8import { resample, sparkline } from './trend'
9
10export type PaneElements = {
11 Box: ElementConstructor<BoxProps>
12 Text: ElementConstructor<TextProps>
13}
14
15export type Direction = 'up' | 'down' | 'flat' | 'missing' | 'pending'
16
17export type Row = {
18 code: string
19 name: string
20 priceText: string
21 changeText: string
22 pctText: string
23 direction: Direction
24 /** Today's prices, oldest first; drawn on the row's second line. */
25 series: readonly number[]
26 /** The latest point of `series` against the previous close. */
27 trendDirection: Direction
28 barText: string
29 isFlash: boolean
30}
31
32export type Section = { title: string; rows: Row[]; isOpen?: boolean; hint?: string }
33
34export type StatusLine = { text: string; isStale: boolean }
35
36/** Per-code extras the second line and the tick highlight read. */
37export type RowExtras = {
38 trends: Readonly<Record<string, readonly number[]>>
39 flash: readonly string[]
40}
41
42const UP_COLOR = 'red'
43const DOWN_COLOR = 'green'
44export const OPEN_COLOR = 'cyan'
45const STALE_COLOR = 'yellow'
46const BEIJING_OFFSET_MS = 8 * 3_600_000
47const MARKET_ORDER: readonly Market[] = ['cn', 'hk', 'us']
48/** Cell widths of the right-aligned numeric columns: 42,310.50 · -1234.56 · ▲10.00% */
49export const PRICE_WIDTH = 9
50export const CHANGE_WIDTH = 8
51export const PCT_WIDTH = 7
52/** Second line: one cell of indent, the sparkline, one cell of gap, then the bar. */
53const TREND_RESERVED_CELLS = 2 + BAR_CELLS
54const RULE_CHAR = '─'
55export const EMPTY_WATCHLIST_HINT = '/market add <代码> [名称] 添加自选'
56
57/** 1234567.891 → "1,234,567.89" */
58export const formatPrice = (value: number): string => {
59 const [whole = '0', fraction = '00'] = value.toFixed(2).split('.')
60 return `${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${fraction}`
61}
62
63const formatChange = (value: number): string =>
64 `${value > 0 ? '+' : value < 0 ? '-' : ''}${Math.abs(value).toFixed(2)}`
65
66const directionOf = (delta: number): Direction => (delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat')
67
68const toRow = (code: string, name: string, quote: Quote | undefined, extras: RowExtras): Row => {
69 const series = extras.trends[code] ?? []
70 const base = { code, name, series, isFlash: extras.flash.includes(code) }
71 if (quote === undefined) {
72 return { ...base, priceText: '--', changeText: '', pctText: '', direction: 'pending', trendDirection: 'flat', barText: '' }
73 }
74 if (quote.isMissing) {
75 return { ...base, priceText: '未找到', changeText: '', pctText: '', direction: 'missing', trendDirection: 'flat', barText: '' }
76 }
77 const direction = directionOf(quote.change)
78 const arrow = direction === 'up' ? '▲' : direction === 'down' ? '▼' : ' '
79 const latest = series.at(-1)
80 return {
81 ...base,
82 priceText: formatPrice(quote.price),
83 changeText: formatChange(quote.change),
84 pctText: `${arrow}${Math.abs(quote.pct).toFixed(2)}%`,
85 direction,
86 trendDirection: latest === undefined ? 'flat' : directionOf(latest - quote.prevClose),
87 barText: pctBar(quote.pct),
88 }
89}
90
91export const buildSections = (
92 quotes: Readonly<Record<string, Quote>>,
93 watchlist: readonly WatchItem[],
94 nowMs: number,
95 extras: RowExtras = { trends: {}, flash: [] },
96): Section[] => [
97 ...MARKET_ORDER.map(market => ({
98 title: MARKET_LABELS[market],
99 isOpen: isMarketOpen(market, nowMs),
100 rows: INDICES.filter(index => index.market === market).map(index =>
101 toRow(index.code, index.name, quotes[index.code], extras),
102 ),
103 })),
104 {
105 title: '自选',
106 rows: watchlist.map(item => toRow(item.code, item.name, quotes[item.code], extras)),
107 ...(watchlist.length === 0 ? { hint: EMPTY_WATCHLIST_HINT } : {}),
108 },
109]
110
111/** HH:MM:SS in Beijing time. */
112export const formatClock = (ms: number): string => new Date(ms + BEIJING_OFFSET_MS).toISOString().slice(11, 19)
113
114export const buildStatus = (feed: Feed): StatusLine => {
115 if (feed.status === 'loading') return { text: '加载中…', isStale: false }
116 const last = feed.lastOkAt === undefined ? '暂无数据' : `上次 ${formatClock(feed.lastOkAt)}`
117 if (feed.status === 'stale') return { text: `过期 · ${last}`, isStale: true }
118 return { text: `更新于 ${formatClock(feed.lastOkAt ?? 0)}`, isStale: false }
119}
120
121const colorOf = (direction: Direction): string | undefined =>
122 direction === 'up' ? UP_COLOR : direction === 'down' ? DOWN_COLOR : undefined
123
124export const renderRule = ({ Box, Text }: PaneElements, key: string, columns: number): RenderElement => (
125 <Box key={key}>
126 <Text dimColor wrap="truncate-end">
127 {RULE_CHAR.repeat(Math.max(columns, 1))}
128 </Text>
129 </Box>
130)
131
132export const renderHeader = (
133 { Box, Text }: PaneElements,
134 title: string,
135 isOpen: boolean | undefined,
136): RenderElement => (
137 <Box key={`head-${title}`} flexDirection="row" columnGap={1}>
138 <Text bold>{title}</Text>
139 {isOpen !== undefined && (
140 <Text key="light" color={isOpen ? OPEN_COLOR : undefined} dimColor={!isOpen}>
141 {isOpen ? '● 盘中' : '○ 休市'}
142 </Text>
143 )}
144 </Box>
145)
146
147const renderQuoteLine = ({ Box, Text }: PaneElements, row: Row, isStale: boolean): RenderElement => {
148 const isMuted = isStale || row.direction === 'missing' || row.direction === 'pending'
149 return (
150 <Box key={`row-${row.code}`} flexDirection="row" justifyContent="space-between" columnGap={1}>
151 <Text wrap="truncate-end" dimColor={isStale}>
152 {row.name}
153 </Text>
154 <Box key={`quote-${row.code}`} flexShrink={0}>
155 <Text dimColor={isMuted} inverse={row.isFlash && !isStale}>
156 {row.priceText.padStart(PRICE_WIDTH)}
157 </Text>
158 <Text dimColor={isStale} color={isStale ? undefined : colorOf(row.direction)}>
159 {` ${row.changeText.padStart(CHANGE_WIDTH)} ${row.pctText.padStart(PCT_WIDTH)}`}
160 </Text>
161 </Box>
162 </Box>
163 )
164}
165
166const renderTrendLine = ({ Box, Text }: PaneElements, row: Row, isStale: boolean, columns: number): RenderElement => {
167 const line = sparkline(resample(row.series, Math.max(columns - TREND_RESERVED_CELLS, 0)))
168 return (
169 <Box key={`trend-${row.code}`} flexDirection="row" justifyContent="space-between" columnGap={1}>
170 <Text key="spark" wrap="truncate-end" dimColor={isStale} color={isStale ? undefined : colorOf(row.trendDirection)}>
171 {` ${line}`}
172 </Text>
173 <Text key="bar" dimColor={isStale} color={isStale ? undefined : colorOf(row.direction)}>
174 {row.barText.padEnd(BAR_CELLS)}
175 </Text>
176 </Box>
177 )
178}
179
180export const renderPane = (
181 elements: PaneElements,
182 sections: readonly Section[],
183 status: StatusLine,
184 columns: number,
185 tail?: RenderElement,
186): RenderElement => {
187 const { Box, Text } = elements
188 return (
189 <Box flexDirection="column">
190 <Box key="status">
191 <Text dimColor={!status.isStale} color={status.isStale ? STALE_COLOR : undefined}>
192 {status.text}
193 </Text>
194 </Box>
195 {sections.map(section => (
196 <Box key={`section-${section.title}`} flexDirection="column">
197 {renderRule(elements, `rule-${section.title}`, columns)}
198 {renderHeader(elements, section.title, section.isOpen)}
199 {section.hint !== undefined && (
200 <Box key="watch-hint">
201 <Text dimColor>{section.hint}</Text>
202 </Box>
203 )}
204 {section.rows.flatMap(row => [
205 renderQuoteLine(elements, row, status.isStale),
206 renderTrendLine(elements, row, status.isStale, columns),
207 ])}
208 </Box>
209 ))}
210 {tail}
211 </Box>
212 )
213}
214