SLOPSHOPPER

market-pane

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

newpanecommandnetworktimer
v0.1.0no licenseupdated 2026-10-10androidZzT/claude-trading-mods/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 只,跨会话保留
  • 走势线:每个标的第二行画当日走势,旁边一根按涨跌幅比例的条形
  • 板块热力图:A股申万二级行业涨幅前 6、跌幅前 6,色块深浅表示幅度
  • 跳动高亮:现价比上次刷新有变化时,反色显示一个刷新周期
  • 低打扰:不抢输入焦点,没有弹窗和声音;拉取失败时继续显示旧数据,并标上「过期」
更新于 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
  • 红涨绿跌,并用 ▲/▼ 标方向,不显示颜色的终端也能看出涨跌
  • 现价带千分位,涨跌额带 +/- 号,三列数字右对齐
  • 走势线按当天自己的最高、最低价缩放;最新价高于昨收为红,低于为绿
  • 条形每格代表 1%,精度 1/8 格,涨跌 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 分钟
  • 面板关闭时什么都不请求;打开面板或新增自选时,没有走势的标的立即补拉
  • 分时接口一次只能查一个代码,每次最多并发 5 个
  • 美股接口没有分时数据,走势线用面板打开期间每次报价刷新的价格采样,所以刚打开时美股走势是空的,面板关着的时间段也没有数据;每个美股交易日重新开始
  • 交易时段按北京时间判断,美股自动处理夏令时;不处理节假日,节假日按交易日算,最多多刷几次
  • 任何一类数据取不到时都保留上一次的数据,不弹窗,也不影响其他数据

数据源

都是腾讯行情的公开接口,不需要 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.tsK 线字符图布局
hooks/game-view.tsx游戏视图绘制
hooks/game-surface.tsx游戏的 Client 模块:按键、按钮、逐根揭晓
Source 16 files
hooks/register.tsx 428 lines
1import { 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}
428
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-view.tsx 166 lines
1import 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}
166
hooks/heatmap-view.tsx 80 lines
1import 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}
80
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/sector.ts 55 lines
1import 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)}`
55
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/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
hooks/watchlist.ts 76 lines
1import 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