SLOPSHOPPER

market-pane

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

newpanecommandnetworktimer
v0.1.0MITupdated 2026-10-16androidZzT/cc-moyu-kit/mods/market-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · market-pane
│ ┃ market-pane ✕ › fix the failing auth test and add an audit log call │ ┃ 过期 · 暂无数据 │ ┃ ───────────────────────────────────────────… ⏺ Read(src/auth.ts) │ ┃ A股 ○ 休市 ⎿ Read 6 lines │ ┃ 上证指数 -- ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 深证成指 -- ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ 创业板指 -- │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ───────────────────────────────────────────… │ ┃ 港股 ○ 休市 ✻ Worked for 42s · done 4:20 PM │ ┃ 恒生指数 -- │ ┃ › /market │ ┃ 恒生科技指数 -- ⎿ market-pane: 行情面板已关闭 │ ┃ │ ┃ ───────────────────────────────────────────… │ ┃ 美股 ○ 休市 │ ┃ 道琼斯 -- │ ┃ │ ┃ 纳斯达克 -- │ ┃ │ ┃ 标普500 -- │ ┃ │ ┃ ───────────────────────────────────────────… │ ┃ 自选 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · market-pane
过期 · 暂无数据 ──────────────────────────────────────────────────────── A股 ○ 休市 上证指数 -- 深证成指 -- 创业板指 -- ──────────────────────────────────────────────────────── 港股 ○ 休市 恒生指数 -- 恒生科技指数 -- ──────────────────────────────────────────────────────── 美股 ○ 休市 道琼斯 -- 纳斯达克 -- 标普500 -- ──────────────────────────────────────────────────────── 自选 /market add <代码> [名称] 添加自选 ──────────────────────────────────────────────────────── 板块 ○ 休市 板块数据暂不可用 休市中 · /market play 玩 K 线猜涨跌
README

market-pane

在 Claude Code 侧边面板里看行情,vibecoding 的时候瞄一眼大盘,不用切出终端。

  • 指数:A股(上证指数、深证成指、创业板指)、港股(恒生指数、恒生科技指数)、美股(道琼斯、纳斯达克、标普500)
  • 自选股:最多 20 只,跨会话保留
  • 走势线:每个标的第二行画当日走势,旁边一根按涨跌幅比例的条形
  • 关注板块:半导体(芯片概念)、人工智能、机器人、券商、创新药,每个板块显示当日涨跌幅和板块内涨幅前三、跌幅前三的公司(剔除 ST);想换板块改 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%
...
  • 红涨绿跌,并用 ▲/▼ 标方向,不显示颜色的终端也能看出涨跌
  • 现价带千分位,涨跌额带 +/- 号,三列数字右对齐
  • 走势线按当天自己的最高、最低价缩放;最新价高于昨收为红,低于为绿
  • 条形每格代表 1%,精度 1/8 格,涨跌 4% 及以上画满
  • 板块:板块涨跌幅与指数同色规则;成分股左列涨幅前三、右列跌幅前三;取不到时显示「板块数据暂不可用」

加载

在 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 分钟
  • 面板关闭时什么都不请求;打开面板或新增自选时,没有走势的标的立即补拉
  • 分时接口一次只能查一个代码,每次最多并发 5 个
  • 美股接口没有分时数据,走势线用面板打开期间每次报价刷新的价格采样,所以刚打开时美股走势是空的,面板关着的时间段也没有数据;每个美股交易日重新开始
  • 交易时段按北京时间判断,美股自动处理夏令时;不处理节假日,节假日按交易日算,最多多刷几次
  • 任何一类数据取不到时都保留上一次的数据,不弹窗,也不影响其他数据

数据源

都是腾讯行情的公开接口,不需要 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.tsK 线字符图布局
hooks/game-view.tsx游戏视图绘制
hooks/game-surface.tsx游戏的 Client 模块:按键、按钮、逐根揭晓
Source 18 files
hooks/register.tsx 412 lines
1import { 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}
412
hooks/game.ts 139 lines
1import 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}
139
hooks/game-props.ts 33 lines
1import 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}
33
hooks/boards.ts 12 lines
1/**
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]
12
hooks/boards-view.tsx 99 lines
1import 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}
99
hooks/hours.ts 53 lines
1import 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
53
hooks/parse.ts 56 lines
1import 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)
56
hooks/symbols.ts 44 lines
1export 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}
44
hooks/kline.ts 35 lines
1import 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}
35
hooks/movers.ts 50 lines
1import 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}
50
hooks/trend.ts 80 lines
1import 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}
80
hooks/view.tsx 214 lines
1import 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