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

在 Claude Code 侧边面板里看行情,vibecoding 的时候瞄一眼大盘,不用切出终端。
更新于 10:29:25
────────────────────────────────────────
A股 ● 盘中
上证指数 3,775.49 -36.41 ▼0.96%
▄▅▃▂▁█▇ █
深证成指 12,394.61 -226.29 ▼1.79%
▇▆▅▅▃▂▂▁ █▊
...
────────────────────────────────────────
板块 ● 盘中
数字媒体 +8.0 出版 +7.2 影视院线 +7.2
种植业 +5.8 贵金属 +5.6 广告营销 +5.5
其他电子 -2.4 电子化学 -2.7 航天装备 -2.7
风电设备 -3.0 航海装备 -3.6 元件 -4.3
claude --plugin-dir /path/to/claude_trading_mods/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=<代码,...> |
| 分时走势 | https://ifzq.gtimg.cn/appstock/app/minute/query?code=<代码> |
| 日 K(游戏) | https://ifzq.gtimg.cn/appstock/app/fqkline/get?param=<代码>,day,,,320,qfq |
| 板块排行 | http://proxy.finance.qq.com/cgi/cgi-bin/rank/pt/getRank?board_type=hy2&... |
这些都是非官方接口,没有可用性保证,接口变更或限流时对应数据会停在上一次(报价会标「过期」)。行情仅供参考,不构成投资建议。
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/sector.ts | 板块排行解析、排序、色块分档 |
hooks/hours.ts | 交易时段、刷新间隔 |
hooks/watchlist.ts | 自选增删、子命令解析 |
hooks/view.tsx | 面板绘制:指数与自选的两行布局 |
hooks/heatmap-view.tsx | 板块热力图绘制 |
hooks/kline.ts | 日 K URL 与解析 |
hooks/game.ts | 题库、出题、判定、计分 |
hooks/kline-chart.ts | K 线字符图布局 |
hooks/game-view.tsx | 游戏视图绘制 |
hooks/game-surface.tsx | 游戏的 Client 模块:按键、按钮、逐根揭晓 |
hooks/register.tsx 428 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Feed, GameRound, GameScore, GameState, Quote, Sectors, TrendFeed } from '../types'
5
6import { EMPTY_SCORE, buildPool, makeRound, mulberry32, parseGameMessage, pickItem, sanitizeScore, scoreAfter } from './game'
7import type { GameMessage, PoolItem } from './game'
8import type { GameViewProps } from './game-view'
9import { buildHeatmap, renderHeatmap } from './heatmap-view'
10import { isMarketOpen, refreshIntervalMs } from './hours'
11import { buildQuoteUrl, movedCodes, parseQuotes } from './parse'
12import { buildSectorUrl, parseSectorRank } from './sector'
13import { INDICES, marketOf } from './symbols'
14import { buildKlineUrl, parseKline } from './kline'
15import { buildMinuteUrl, parseMinute, sampleUs } from './trend'
16import { buildSections, buildStatus, renderPane } from './view'
17import { addItem, formatList, parseCommand, removeItem, sanitizeStored } from './watchlist'
18import type { ListChange } from './watchlist'
19import type { Market } from './symbols'
20
21// `$` may only be handed to functions declared in this file, so every
22// engine-facing step lives here; the other modules hold pure logic.
23
24const PANE_ID = 'market-pane'
25const PANE_TITLE = '行情'
26const TICK_MS = 10_000
27const FETCH_TIMEOUT_MS = 8_000
28/** Slack so a tick that lands a little early still counts as due. */
29const DUE_SLACK_MS = 1_000
30const ALL_MARKETS: readonly Market[] = ['cn', 'hk', 'us']
31/** Markets whose intraday series come from the minute endpoint; US series are sampled from quotes. */
32const MINUTE_MARKETS = ['cn', 'hk'] as const
33type MinuteMarket = (typeof MINUTE_MARKETS)[number]
34const MINUTE_FEED_KEY: Readonly<Record<MinuteMarket, 'cnAt' | 'hkAt'>> = { cn: 'cnAt', hk: 'hkAt' }
35const SLOW_OPEN_MS = 60_000
36const SLOW_CLOSED_MS = 30 * 60_000
37/** The minute endpoint answers one code per request; at most this many run at once. */
38const MINUTE_BATCH = 5
39
40const quotesAtom = atom({ plugin: 'market-pane', key: 'quotes' } as const, {})
41const feedAtom = atom({ plugin: 'market-pane', key: 'feed' } as const, { status: 'loading' })
42const watchlistAtom = atom({ plugin: 'market-pane', key: 'watchlist' } as const, [])
43const trendsAtom = atom({ plugin: 'market-pane', key: 'trends' } as const, {})
44const trendFeedAtom = atom({ plugin: 'market-pane', key: 'trendFeed' } as const, {})
45const sectorsAtom = atom({ plugin: 'market-pane', key: 'sectors' } as const, { gainers: [], losers: [] })
46const flashAtom = atom({ plugin: 'market-pane', key: 'flash' } as const, [])
47const modeAtom = atom({ plugin: 'market-pane', key: 'mode' } as const, 'market')
48const gameAtom = atom({ plugin: 'market-pane', key: 'game' } as const, { status: 'idle', dealt: 0 })
49const scoreAtom = atom({ plugin: 'market-pane', key: 'score' } as const, EMPTY_SCORE)
50
51let inFlight = false
52let trendsInFlight = false
53let sectorsInFlight = false
54let dealInFlight = false
55
56const isPaneActive = async ($: EngineInterface): Promise<boolean> =>
57 (await $.ui.panes()).some(pane => pane.id === PANE_ID && pane.isPlaced)
58
59const fetchWithTimeout = async ($: EngineInterface, url: string): Promise<string> => {
60 const timeout = $.clock.sleep(FETCH_TIMEOUT_MS).then(() => {
61 throw new Error(`timed out after ${FETCH_TIMEOUT_MS}ms`)
62 })
63 const response = await Promise.race([$.http.fetch(url), timeout])
64 if (!response.ok) throw new Error(`HTTP ${response.status}`)
65 return response.text
66}
67
68/** Adds this refresh's US prices to the sampled US series (only during the US session). */
69const recordUsSamples = async (
70 $: EngineInterface,
71 quotes: Readonly<Record<string, Quote>>,
72 codes: readonly string[],
73 now: number,
74): Promise<void> => {
75 const usCodes = codes.filter(code => marketOf(code) === 'us')
76 const trends = await read($, trendsAtom)
77 const { usDay } = await read($, trendFeedAtom)
78 const sampled = sampleUs(trends, quotes, usCodes, now, usDay)
79 if (sampled.trends === trends) return
80 await update($, trendsAtom, () => sampled.trends)
81 await update($, trendFeedAtom, (feed): TrendFeed => ({ ...feed, usDay: sampled.usDay }))
82}
83
84/** Fetches every index and watchlist quote in one request; on failure keeps the old quotes and marks them stale. */
85const refresh = async ($: EngineInterface): Promise<void> => {
86 if (inFlight) return
87 inFlight = true
88 try {
89 const watchlist = await read($, watchlistAtom)
90 const codes = [...INDICES.map(index => index.code), ...watchlist.map(item => item.code)]
91 const startedAt = await $.clock.now()
92 await update($, feedAtom, feed => ({ ...feed, lastFetchAt: startedAt }))
93 try {
94 const quotes = parseQuotes(codes, await fetchWithTimeout($, buildQuoteUrl(codes)))
95 const previous = await read($, quotesAtom)
96 await update($, flashAtom, () => movedCodes(previous, quotes))
97 await update($, quotesAtom, () => quotes)
98 await update($, feedAtom, (feed): Feed => ({ ...feed, status: 'ok', lastOkAt: startedAt, lastError: undefined }))
99 await recordUsSamples($, quotes, codes, startedAt)
100 } catch (error) {
101 const lastError = error instanceof Error ? error.message : String(error)
102 await update($, flashAtom, () => [])
103 await update($, feedAtom, (feed): Feed => ({ ...feed, status: 'stale', lastError }))
104 }
105 } finally {
106 inFlight = false
107 }
108}
109
110const slowIntervalMs = (market: Market, now: number): number =>
111 isMarketOpen(market, now) ? SLOW_OPEN_MS : SLOW_CLOSED_MS
112
113const isDue = (lastAt: number | undefined, interval: number, now: number): boolean =>
114 lastAt === undefined || now - lastAt >= interval - DUE_SLACK_MS
115
116/** One code's minute series, or undefined when the request fails, so its old series stays. */
117const fetchMinute = async ($: EngineInterface, code: string): Promise<readonly [string, number[]] | undefined> => {
118 try {
119 return [code, parseMinute(code, await fetchWithTimeout($, buildMinuteUrl(code)))] as const
120 } catch {
121 return undefined
122 }
123}
124
125/** Fetches minute series in batches of MINUTE_BATCH and merges the ones that arrived. */
126const fetchMinuteBatches = async ($: EngineInterface, codes: readonly string[]): Promise<void> => {
127 for (let start = 0; start < codes.length; start += MINUTE_BATCH) {
128 const results = await Promise.all(codes.slice(start, start + MINUTE_BATCH).map(code => fetchMinute($, code)))
129 const arrived = Object.fromEntries(results.filter(result => result !== undefined))
130 await update($, trendsAtom, trends => ({ ...trends, ...arrived }))
131 }
132}
133
134/**
135 * Refreshes the intraday series of each minute market whose interval has
136 * elapsed. With `fillMissing`, codes of the other markets that have no series
137 * yet are fetched too (pane opened, code added).
138 */
139const refreshTrends = async ($: EngineInterface, fillMissing: boolean): Promise<void> => {
140 if (trendsInFlight) return
141 trendsInFlight = true
142 try {
143 const now = await $.clock.now()
144 const watchlist = await read($, watchlistAtom)
145 const [trends, trendFeed] = [await read($, trendsAtom), await read($, trendFeedAtom)]
146 const allCodes = [...INDICES.map(index => index.code), ...watchlist.map(item => item.code)]
147 const dueMarkets = MINUTE_MARKETS.filter(market =>
148 isDue(trendFeed[MINUTE_FEED_KEY[market]], slowIntervalMs(market, now), now),
149 )
150 const codes = allCodes.filter(code => {
151 const market = marketOf(code)
152 if (market === 'us') return false
153 return dueMarkets.includes(market) || (fillMissing && trends[code] === undefined)
154 })
155 if (dueMarkets.length > 0) {
156 await update($, trendFeedAtom, (feed): TrendFeed => ({
157 ...feed,
158 ...Object.fromEntries(dueMarkets.map(market => [MINUTE_FEED_KEY[market], now])),
159 }))
160 }
161 await fetchMinuteBatches($, codes)
162 } finally {
163 trendsInFlight = false
164 }
165}
166
167/**
168 * Refreshes the A股 industry ranking when its interval has elapsed, or at once
169 * with `fillMissing` while none has arrived yet. Both sides must arrive;
170 * otherwise the last ranking stays.
171 */
172const refreshSectors = async ($: EngineInterface, fillMissing: boolean): Promise<void> => {
173 if (sectorsInFlight) return
174 const now = await $.clock.now()
175 const { lastFetchAt, lastOkAt } = await read($, sectorsAtom)
176 const isWanted = isDue(lastFetchAt, slowIntervalMs('cn', now), now) || (fillMissing && lastOkAt === undefined)
177 if (!isWanted) return
178 sectorsInFlight = true
179 try {
180 await update($, sectorsAtom, (sectors): Sectors => ({ ...sectors, lastFetchAt: now }))
181 const [gainers, losers] = await Promise.all([
182 fetchWithTimeout($, buildSectorUrl('gainers')).then(parseSectorRank),
183 fetchWithTimeout($, buildSectorUrl('losers')).then(parseSectorRank),
184 ])
185 await update($, sectorsAtom, (sectors): Sectors => ({ ...sectors, gainers, losers, lastOkAt: now }))
186 } catch {
187 // keep the last ranking; the next interval retries
188 } finally {
189 sectorsInFlight = false
190 }
191}
192
193/** Runs a background task; a failure is recorded on the feed instead of surfacing to the person. */
194const runInBackground = ($: EngineInterface, task: Promise<void>): void => {
195 task.catch(async (error: unknown) => {
196 const lastError = error instanceof Error ? error.message : String(error)
197 await update($, feedAtom, feed => ({ ...feed, lastError })).catch(() => undefined)
198 })
199}
200
201/** Runs every TICK_MS: refreshes only while the pane is placed and the market-hours interval has elapsed. */
202const tick = async ($: EngineInterface): Promise<void> => {
203 if (!(await isPaneActive($))) return
204 const now = await $.clock.now()
205 const { lastFetchAt } = await read($, feedAtom)
206 const interval = refreshIntervalMs(ALL_MARKETS, now)
207 if (isDue(lastFetchAt, interval, now)) {
208 await refresh($)
209 }
210 await refreshTrends($, false)
211 await refreshSectors($, false)
212}
213
214/** Everything the pane shows, fetched now: quotes, plus intraday series and sectors that are due or missing. */
215const refreshOnOpen = ($: EngineInterface): void => {
216 runInBackground($, refresh($))
217 runInBackground($, refreshTrends($, true))
218 runInBackground($, refreshSectors($, true))
219}
220
221// ── K 线猜涨跌 ──────────────────────────────────────────────────────────────
222
223const GAME_KEY = 'kline-game'
224const SCORE_STORE_KEY = 'kline-score'
225/** Codes tried per round before the round is reported as failed. */
226const DEAL_TRIES = 3
227/** Spreads seeds of rounds dealt within the same millisecond. */
228const SEED_STRIDE = 7_919
229const CLOSED_HINT = '休市中 · /market play 玩 K 线猜涨跌'
230const GAME_TERMINAL_ONLY = 'K 线猜涨跌目前只支持终端 · /market play 回到行情'
231
232/** One code's round, or undefined when its history fails to load or never gives a non-flat cut. */
233const dealFrom = async ($: EngineInterface, item: PoolItem, id: number, rand: () => number): Promise<GameRound | undefined> => {
234 try {
235 const draft = makeRound(parseKline(item.code, await fetchWithTimeout($, buildKlineUrl(item.code))), rand)
236 return draft === undefined ? undefined : { ...draft, id, code: item.code, name: item.name }
237 } catch {
238 return undefined
239 }
240}
241
242/** Deals the next round from the pool; after DEAL_TRIES failed codes the game shows the retry prompt. */
243const dealRound = async ($: EngineInterface): Promise<void> => {
244 if (dealInFlight) return
245 dealInFlight = true
246 try {
247 const { dealt } = await read($, gameAtom)
248 const id = dealt + 1
249 await update($, gameAtom, (game): GameState => ({ status: 'loading', dealt: game.dealt }))
250 const rand = mulberry32((await $.clock.now()) + id * SEED_STRIDE)
251 const pool = buildPool(await read($, watchlistAtom))
252 for (let attempt = 0; attempt < DEAL_TRIES; attempt += 1) {
253 const item = pickItem(pool, rand)
254 const round = item === undefined ? undefined : await dealFrom($, item, id, rand)
255 if (round !== undefined) {
256 await update($, gameAtom, (): GameState => ({ status: 'ready', round, dealt: id }))
257 return
258 }
259 }
260 await update($, gameAtom, (game): GameState => ({ status: 'error', dealt: game.dealt }))
261 } finally {
262 dealInFlight = false
263 }
264}
265
266/** A candle with its date dropped, so nothing on screen dates the round before it is answered. */
267const undated = (round: GameRound, count: number) =>
268 round.candles.slice(0, count).map(candle => ({ ...candle, date: '' }))
269
270/** The Client's props: visible candles only until a verdict, then the hidden ones, the name and the dates. */
271const toViewProps = (game: GameState, score: GameScore): GameViewProps => {
272 const status = game.status === 'idle' ? 'loading' : game.status
273 const round = game.round
274 if (round === undefined) return { status, score }
275 const verdict = game.verdict?.round === round.id ? game.verdict : undefined
276 if (verdict === undefined) {
277 return { status, score, round: { id: round.id, candles: undated(round, round.visible), visible: round.visible } }
278 }
279 return {
280 status,
281 score,
282 round: { id: round.id, candles: undated(round, round.candles.length), visible: round.visible },
283 reveal: {
284 round: round.id,
285 guess: verdict.guess,
286 isCorrect: verdict.isCorrect,
287 name: round.name,
288 code: round.code,
289 from: round.candles[round.visible - 1]?.date ?? '',
290 to: round.candles.at(-1)?.date ?? '',
291 changePct: round.changePct,
292 },
293 }
294}
295
296/** Judges a guess against the round the hooks hold; a stale round or a second guess changes nothing. */
297const applyGuess = async ($: EngineInterface, message: Extract<GameMessage, { type: 'guess' }>): Promise<void> => {
298 const game = await read($, gameAtom)
299 const round = game.round
300 if (round === undefined || round.id !== message.round || game.verdict?.round === round.id) return
301 const isCorrect = message.guess === round.answer
302 const score = scoreAfter(await read($, scoreAtom), isCorrect)
303 await update($, scoreAtom, () => score)
304 await update($, gameAtom, (current): GameState => ({ ...current, verdict: { round: round.id, guess: message.guess, isCorrect } }))
305 await $.store.set(SCORE_STORE_KEY, score)
306}
307
308/** `/market play`: switches the pane to the game (dealing the first round) or back to the quotes. */
309const toggleGame = async ($: EngineInterface): Promise<string> => {
310 if ((await read($, modeAtom)) === 'game') {
311 await update($, modeAtom, () => 'market' as const)
312 return '已回到行情'
313 }
314 await update($, modeAtom, () => 'game' as const)
315 if (!(await isPaneActive($))) await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
316 if ((await read($, gameAtom)).round === undefined) runInBackground($, dealRound($))
317 return 'K 线猜涨跌:点一下面板,按 ↑ 猜涨、↓ 猜跌,q 退出'
318}
319
320const STORE_KEY = 'watchlist'
321const USAGE = '用法:/market 开关面板 · /market add <代码> [名称] · /market remove <代码> · /market list · /market play 猜涨跌'
322
323/** Closes the pane when it is shown, otherwise opens it (at any width, since the person asked) and refreshes. */
324const togglePane = async ($: EngineInterface): Promise<string> => {
325 if (await isPaneActive($)) {
326 await $.ui.close({ id: PANE_ID })
327 return '行情面板已关闭'
328 }
329 await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
330 refreshOnOpen($)
331 return '行情面板已打开'
332}
333
334/** Writes a changed watchlist to the session state, then persists it, then refreshes so new codes show at once. */
335const applyListChange = async ($: EngineInterface, change: ListChange): Promise<string> => {
336 if (!change.isChanged) return change.message
337 const list = [...change.list]
338 await update($, watchlistAtom, () => list)
339 await $.store.set(STORE_KEY, list)
340 if (await isPaneActive($)) refreshOnOpen($)
341 return change.message
342}
343
344export const register: Register = on => {
345 on('session.start', async ($, e, next) => {
346 const stored = sanitizeStored(await $.store.get(STORE_KEY))
347 await update($, watchlistAtom, () => stored)
348 const score = sanitizeScore(await $.store.get(SCORE_STORE_KEY))
349 await update($, scoreAtom, () => score)
350 await $.command.register({ name: 'market', description: '开关行情面板;add/remove/list 管理自选' })
351 $.clock.every(TICK_MS, () => runInBackground($, tick($)))
352 const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
353 if (opened.isPlaced) refreshOnOpen($)
354 return next(e)
355 })
356
357 on('command.run', { command: 'market' }, async ($, e) => {
358 const command = parseCommand(e.args)
359 const list = await read($, watchlistAtom)
360 switch (command.kind) {
361 case 'toggle':
362 return { text: await togglePane($) }
363 case 'add':
364 return { text: await applyListChange($, addItem(list, command.code, command.name)) }
365 case 'remove':
366 return { text: await applyListChange($, removeItem(list, command.code)) }
367 case 'list':
368 return { text: formatList(list) }
369 case 'play':
370 return { text: await toggleGame($) }
371 case 'usage':
372 return { text: USAGE }
373 }
374 })
375
376 on('ui.message', async ($, e, next) => {
377 if (e.element !== GAME_KEY) return next(e)
378 const message = parseGameMessage(e.data)
379 if (message === undefined) return {}
380 switch (message.type) {
381 case 'quit':
382 await update($, modeAtom, () => 'market' as const)
383 return {}
384 case 'next':
385 runInBackground($, dealRound($))
386 return {}
387 case 'guess':
388 await applyGuess($, message)
389 return { props: toViewProps(await read($, gameAtom), await read($, scoreAtom)) }
390 }
391 })
392
393 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
394 const elements = $.ui.resolve(e)
395 if ((await read($, modeAtom)) === 'game') {
396 if (e.surface !== 'terminal') {
397 const { Text } = elements
398 return <Text dimColor>{GAME_TERMINAL_ONLY}</Text>
399 }
400 const { Box, Client } = $.ui.resolve(e)
401 const props = toViewProps(await read($, gameAtom), await read($, scoreAtom))
402 return (
403 <Box flexDirection="column">
404 <Client key={GAME_KEY} module="./game-surface.tsx" props={props} width={e.props.bodyColumns} height={e.props.scroll.bodyRows} />
405 </Box>
406 )
407 }
408 const now = await $.clock.now()
409 const extras = { trends: await read($, trendsAtom), flash: await read($, flashAtom) }
410 const sections = buildSections(await read($, quotesAtom), await read($, watchlistAtom), now, extras)
411 const columns = e.props.bodyColumns
412 const heatmap = renderHeatmap(elements, buildHeatmap(await read($, sectorsAtom), isMarketOpen('cn', now)), columns)
413 const isAllClosed = !ALL_MARKETS.some(market => isMarketOpen(market, now))
414 const { Box, Text } = elements
415 const tail = (
416 <Box key="tail" flexDirection="column">
417 {heatmap}
418 {isAllClosed && (
419 <Box key="closed-hint" marginTop={1}>
420 <Text dimColor>{CLOSED_HINT}</Text>
421 </Box>
422 )}
423 </Box>
424 )
425 return renderPane(elements, sections, buildStatus(await read($, feedAtom)), columns, tail)
426 })
427}
428hooks/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-view.tsx 166 lines1import type { ClientElements, RenderElement } from 'claude-code'
2
3import type { Candle, GameScore, Guess } from '../types'
4import { REVEAL_DAYS } from './game'
5import { layoutChart } from './kline-chart'
6import type { ChartRow, Tone } from './kline-chart'
7
8/** What the hooks hand the game's Client: the round's visible candles, and the outcome once guessed. */
9export type GameViewProps = {
10 status: 'loading' | 'ready' | 'error'
11 score: GameScore
12 round?: { id: number; candles: Candle[]; visible: number }
13 reveal?: {
14 round: number
15 guess: Guess
16 isCorrect: boolean
17 name: string
18 code: string
19 from: string
20 to: string
21 changePct: number
22 }
23}
24
25/** Rows the view keeps around the chart: title, subtitle, result line, controls. */
26export const CHROME_ROWS = 4
27export const MIN_CHART_ROWS = 6
28const UP_COLOR = 'red'
29const DOWN_COLOR = 'green'
30const RIGHT_COLOR = 'green'
31const WRONG_COLOR = 'red'
32
33const TONE_COLOR: Readonly<Record<Tone, string | undefined>> = {
34 up: UP_COLOR,
35 down: DOWN_COLOR,
36 none: undefined,
37 divider: undefined,
38}
39
40export const formatScore = (score: GameScore): string => {
41 const rate = score.played === 0 ? '—' : `${Math.round((score.correct / score.played) * 100)}%`
42 return `已玩 ${score.played} 局 · 正确率 ${rate} · 连对 ${score.streak}(最佳 ${score.best})`
43}
44
45export const formatChange = (pct: number): string => `${pct >= 0 ? '+' : '-'}${Math.abs(pct).toFixed(2)}%`
46
47/** The result line once every hidden candle is shown. */
48export const formatReveal = (reveal: NonNullable<GameViewProps['reveal']>): string =>
49 `${reveal.isCorrect ? '✓ 猜对' : '✗ 猜错'} · ${reveal.name} ${reveal.code} · ${reveal.from} → ${reveal.to} · ${REVEAL_DAYS} 日 ${formatChange(reveal.changePct)}`
50
51const renderChartRow = ({ Box, Text }: ClientElements, row: ChartRow, index: number): RenderElement => (
52 <Box key={`row-${index}`} flexDirection="row">
53 {row.map((run, i) => (
54 <Text key={`run-${i}`} color={TONE_COLOR[run.tone]} dimColor={run.tone === 'divider'}>
55 {run.text}
56 </Text>
57 ))}
58 </Box>
59)
60
61export type ViewState = { columns: number; rows: number; shown: number; guess?: Guess }
62
63/** What the controls do; the surface module binds them to posts. */
64export type GameActions = { guess: (guess: Guess) => void; next: () => void; quit: () => void }
65
66const renderFooter = (
67 elements: ClientElements,
68 props: GameViewProps,
69 view: ViewState,
70 isRevealed: boolean,
71 actions: GameActions,
72): RenderElement => {
73 const { Box, Text, Button } = elements
74 if (props.status === 'error') {
75 return (
76 <Box key="footer" flexDirection="row" columnGap={2}>
77 <Text color={WRONG_COLOR}>题目加载失败,按 Enter 重试</Text>
78 <Button key="next" onPress={actions.next}>重试</Button>
79 <Button key="quit" onPress={actions.quit}>退出</Button>
80 </Box>
81 )
82 }
83 if (props.status === 'loading' || props.round === undefined) {
84 return (
85 <Box key="footer">
86 <Text dimColor>出题中…</Text>
87 </Box>
88 )
89 }
90 if (isRevealed) {
91 return (
92 <Box key="footer" flexDirection="row" columnGap={2}>
93 <Button key="next" onPress={actions.next}>下一局 ⏎</Button>
94 <Button key="quit" onPress={actions.quit}>退出 q</Button>
95 </Box>
96 )
97 }
98 if (view.guess !== undefined) {
99 return (
100 <Box key="footer">
101 <Text dimColor>揭晓中…</Text>
102 </Box>
103 )
104 }
105 return (
106 <Box key="footer" flexDirection="row" columnGap={2}>
107 <Button key="guess-up" onPress={() => actions.guess('up')}>
108 <Text color={UP_COLOR}>↑ 涨</Text>
109 </Button>
110 <Button key="guess-down" onPress={() => actions.guess('down')}>
111 <Text color={DOWN_COLOR}>↓ 跌</Text>
112 </Button>
113 <Text dimColor>点面板后按 ↑/↓ · q 退出 · Esc 还焦点</Text>
114 </Box>
115 )
116}
117
118/** The whole game view: score, chart, result and controls. */
119export const renderGame = (
120 elements: ClientElements,
121 props: GameViewProps,
122 view: ViewState,
123 actions: GameActions,
124): RenderElement => {
125 const { Box, Text } = elements
126 const round = props.round
127 const reveal = props.reveal !== undefined && props.reveal.round === round?.id ? props.reveal : undefined
128 const isRevealed = reveal !== undefined && view.shown >= REVEAL_DAYS
129 const chartRows = Math.max(MIN_CHART_ROWS, view.rows - CHROME_ROWS)
130 const chart =
131 round === undefined
132 ? []
133 : layoutChart({
134 candles: round.candles,
135 visible: round.visible,
136 shown: reveal === undefined ? 0 : view.shown,
137 rows: chartRows,
138 columns: view.columns,
139 reveal: REVEAL_DAYS,
140 })
141 return (
142 <Box flexDirection="column">
143 <Box key="title">
144 <Text bold>K 线猜涨跌</Text>
145 <Text dimColor>{` ${formatScore(props.score)}`}</Text>
146 </Box>
147 <Box key="subtitle">
148 <Text dimColor>{`猜 ${REVEAL_DAYS} 个交易日后收盘比现在涨还是跌 · 名称和日期揭晓后显示`}</Text>
149 </Box>
150 <Box key="chart" flexDirection="column">
151 {chart.map((row, i) => renderChartRow(elements, row, i))}
152 </Box>
153 <Box key="result">
154 {isRevealed && reveal !== undefined ? (
155 <Text bold color={reveal.isCorrect ? RIGHT_COLOR : WRONG_COLOR} wrap="truncate-end">
156 {formatReveal(reveal)}
157 </Text>
158 ) : (
159 <Text> </Text>
160 )}
161 </Box>
162 {renderFooter(elements, props, view, isRevealed, actions)}
163 </Box>
164 )
165}
166hooks/heatmap-view.tsx 80 lines1import type { RenderElement } from 'claude-code'
2
3import type { SectorCell, Sectors } from '../types'
4import { formatSectorPct, heatTier, orderCells } from './sector'
5import type { HeatTier } from './sector'
6import { renderHeader, renderRule } from './view'
7import type { PaneElements } from './view'
8
9export const HEATMAP_TITLE = '板块'
10export const HEATMAP_COLUMNS = 3
11export const SECTORS_UNAVAILABLE = '板块数据暂不可用'
12const CELL_GAP = 1
13const CELL_TEXT_COLOR = 'white'
14
15/** Strong and mild shades of red (up) and green (down); a flat cell keeps the pane's own background. */
16const TIER_BACKGROUND: Readonly<Record<HeatTier, string | undefined>> = {
17 upStrong: '#c62828',
18 upMild: '#5c1f1f',
19 downStrong: '#2e7d32',
20 downMild: '#1f3d22',
21 flat: undefined,
22}
23
24export type Heatmap = { cells: SectorCell[]; isOpen: boolean }
25
26export const buildHeatmap = (sectors: Sectors, isOpen: boolean): Heatmap => ({
27 cells: orderCells(sectors.gainers, sectors.losers),
28 isOpen,
29})
30
31/** Cells per row of HEATMAP_COLUMNS, keeping the given order. */
32const toRows = (cells: readonly SectorCell[]): SectorCell[][] =>
33 Array.from({ length: Math.ceil(cells.length / HEATMAP_COLUMNS) }, (_, i) =>
34 cells.slice(i * HEATMAP_COLUMNS, (i + 1) * HEATMAP_COLUMNS),
35 )
36
37const renderCell = ({ Box, Text }: PaneElements, cell: SectorCell, index: number, width: number): RenderElement => {
38 const background = TIER_BACKGROUND[heatTier(cell.pct)]
39 const color = background === undefined ? undefined : CELL_TEXT_COLOR
40 return (
41 <Box
42 key={`cell-${index}`}
43 width={width}
44 flexShrink={0}
45 flexDirection="row"
46 justifyContent="space-between"
47 backgroundColor={background}
48 >
49 <Text wrap="truncate-end" color={color}>
50 {cell.name}
51 </Text>
52 <Box key="pct" flexShrink={0}>
53 <Text color={color}>{formatSectorPct(cell.pct)}</Text>
54 </Box>
55 </Box>
56 )
57}
58
59export const renderHeatmap = (elements: PaneElements, heatmap: Heatmap, columns: number): RenderElement => {
60 const { Box, Text } = elements
61 const cellWidth = Math.max(Math.floor((columns - CELL_GAP * (HEATMAP_COLUMNS - 1)) / HEATMAP_COLUMNS), 1)
62 return (
63 <Box key={`section-${HEATMAP_TITLE}`} flexDirection="column">
64 {renderRule(elements, `rule-${HEATMAP_TITLE}`, columns)}
65 {renderHeader(elements, HEATMAP_TITLE, heatmap.isOpen)}
66 {heatmap.cells.length === 0 ? (
67 <Box key="sector-empty">
68 <Text dimColor>{SECTORS_UNAVAILABLE}</Text>
69 </Box>
70 ) : (
71 toRows(heatmap.cells).map((row, rowIndex) => (
72 <Box key={`heat-row-${rowIndex}`} flexDirection="row" columnGap={CELL_GAP}>
73 {row.map((cell, i) => renderCell(elements, cell, rowIndex * HEATMAP_COLUMNS + i, cellWidth))}
74 </Box>
75 ))
76 )}
77 </Box>
78 )
79}
80hooks/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/sector.ts 55 lines1import type { SectorCell } 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 RANK_ENDPOINT = 'http://proxy.finance.qq.com/cgi/cgi-bin/rank/pt/getRank'
9/** 申万二级行业 */
10const BOARD_TYPE = 'hy2'
11export const CELLS_PER_SIDE = 6
12/** |pct| from which a cell takes the strong shade. */
13export const STRONG_PCT = 3
14
15export type RankSide = 'gainers' | 'losers'
16export type HeatTier = 'upStrong' | 'upMild' | 'downStrong' | 'downMild' | 'flat'
17
18type RankResponse = { code?: number; data?: { rank_list?: unknown } }
19type RankEntry = { name?: unknown; zdf?: unknown }
20
21/** `direct=down` ranks by change descending (gainers first), `direct=up` ascending (losers first). */
22export const buildSectorUrl = (side: RankSide): string =>
23 `${RANK_ENDPOINT}?board_type=${BOARD_TYPE}&sort_type=PriceRatio&direct=${side === 'gainers' ? 'down' : 'up'}` +
24 `&offset=0&count=${CELLS_PER_SIDE}`
25
26/** Industries in the order the response lists them. Throws on an error reply or an unexpected body. */
27export const parseSectorRank = (body: string): SectorCell[] => {
28 const response = JSON.parse(body) as RankResponse
29 const list = response.data?.rank_list
30 if (response.code !== 0 || !Array.isArray(list)) throw new Error('sector rank response holds no list')
31 return (list as RankEntry[]).flatMap(entry => {
32 const pct = Number(entry.zdf)
33 return typeof entry.name === 'string' && entry.name !== '' && Number.isFinite(pct)
34 ? [{ name: entry.name, pct }]
35 : []
36 })
37}
38
39const byPctDescending = (a: SectorCell, b: SectorCell): number => b.pct - a.pct
40
41/** Top gainers then top losers, each side high to low: the biggest gain comes first, the biggest loss last. */
42export const orderCells = (gainers: readonly SectorCell[], losers: readonly SectorCell[]): SectorCell[] => [
43 ...[...gainers].sort(byPctDescending).slice(0, CELLS_PER_SIDE),
44 ...[...losers].sort(byPctDescending).slice(-CELLS_PER_SIDE),
45]
46
47export const heatTier = (pct: number): HeatTier => {
48 if (pct === 0) return 'flat'
49 const isStrong = Math.abs(pct) >= STRONG_PCT
50 if (pct > 0) return isStrong ? 'upStrong' : 'upMild'
51 return isStrong ? 'downStrong' : 'downMild'
52}
53
54export const formatSectorPct = (pct: number): string => `${pct > 0 ? '+' : pct < 0 ? '-' : ''}${Math.abs(pct).toFixed(1)}`
55hooks/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/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}
214hooks/watchlist.ts 76 lines1import type { WatchItem } from '../types'
2import { CODE_FORMAT_HELP, normalizeCode } from './symbols'
3
4export const MAX_WATCHLIST = 20
5
6export type MarketCommand =
7 | { kind: 'toggle' }
8 | { kind: 'add'; code: string; name?: string }
9 | { kind: 'remove'; code: string }
10 | { kind: 'list' }
11 | { kind: 'play' }
12 | { kind: 'usage' }
13
14export type ListChange = { list: readonly WatchItem[]; message: string; isChanged: boolean }
15
16/** Splits `/market` arguments into a subcommand; anything unrecognised is `usage`. */
17export const parseCommand = (args: string): MarketCommand => {
18 const [verb, code, ...rest] = args.trim().split(/\s+/).filter(Boolean)
19 if (verb === undefined) return { kind: 'toggle' }
20 if (verb === 'list' && code === undefined) return { kind: 'list' }
21 if (verb === 'play' && code === undefined) return { kind: 'play' }
22 if (verb === 'remove' && code !== undefined && rest.length === 0) return { kind: 'remove', code }
23 if (verb === 'add' && code !== undefined) {
24 return rest.length > 0 ? { kind: 'add', code, name: rest.join(' ') } : { kind: 'add', code }
25 }
26 return { kind: 'usage' }
27}
28
29export const addItem = (list: readonly WatchItem[], rawCode: string, name?: string): ListChange => {
30 const code = normalizeCode(rawCode)
31 if (code === undefined) return { list, message: CODE_FORMAT_HELP, isChanged: false }
32 const existing = list.find(item => item.code === code)
33 if (existing !== undefined) {
34 const renamed = { code, name: name ?? existing.name }
35 return {
36 list: list.map(item => (item.code === code ? renamed : item)),
37 message: `已更新自选 ${code}(${renamed.name})`,
38 isChanged: renamed.name !== existing.name,
39 }
40 }
41 if (list.length >= MAX_WATCHLIST) {
42 return { list, message: `自选已达上限 ${MAX_WATCHLIST} 只,请先 /market remove`, isChanged: false }
43 }
44 const item = { code, name: name ?? code }
45 return { list: [...list, item], message: `已添加自选 ${code}(${item.name})`, isChanged: true }
46}
47
48export const removeItem = (list: readonly WatchItem[], rawCode: string): ListChange => {
49 const code = normalizeCode(rawCode) ?? rawCode
50 if (!list.some(item => item.code === code)) {
51 return { list, message: `自选中未找到 ${rawCode}`, isChanged: false }
52 }
53 return { list: list.filter(item => item.code !== code), message: `已删除自选 ${code}`, isChanged: true }
54}
55
56export const formatList = (list: readonly WatchItem[]): string =>
57 list.length === 0
58 ? '自选为空。/market add <代码> [名称] 添加,例如 /market add sh600519 茅台'
59 : list.map(item => `${item.code} ${item.name}`).join('\n')
60
61/** Keeps only well-formed entries from stored data, which may be stale or hand-edited. */
62export const sanitizeStored = (value: unknown): WatchItem[] => {
63 if (!Array.isArray(value)) return []
64 const seen = new Set<string>()
65 return value
66 .flatMap((entry: unknown) => {
67 if (typeof entry !== 'object' || entry === null) return []
68 const { code, name } = entry as Record<string, unknown>
69 const normalized = typeof code === 'string' ? normalizeCode(code) : undefined
70 if (normalized === undefined || seen.has(normalized)) return []
71 seen.add(normalized)
72 return [{ code: normalized, name: typeof name === 'string' && name !== '' ? name : normalized }]
73 })
74 .slice(0, MAX_WATCHLIST)
75}
76