SLOPSHOPPER

tw-stock-mod

Watchlist band above the Claude Code prompt: Taiwan hours show the Taiwan list (紅漲綠跌), US hours show the US list (綠漲紅跌), in a broker-style table with a Solari…

newbandprocessnetworktimer
★ 60v0.13.5MITupdated 2026-10-05darrell-tw/darrelltw-mods/mods/tw-stock-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tw-stock-mod
› fix the failing auth test and add an audit log call ● tw-stock-mod: tw-stock-mod: feed HTTP 0 (台股), next try in 60s ⏺ Read(src/auth.ts) ⎿ 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 [ 市場:台股 ▾ ] ☽ 休市 下次開盤 09:00 [ 翻頁 1/2 ][ 趨勢圖 ][ 收起 30分 ] ▣ client module ./board.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ 市場:台股 ▾ ] ☽ 休市 下次開盤 09:00 [ 翻頁 1/2 ][ 趨勢圖 ][ 收起 30分 ] ▣ client module ./board.tsx ⟨Claude Code's own drawing⟩
README

tw-stock-mod

A stock watchlist band above the Claude Code prompt, in the style of a broker's watchlist table. Taiwan trading hours show the Taiwan list, US trading hours show the US list, and the red/green convention flips with the market: 台股紅漲綠跌、美股綠漲紅跌. Price is the main column. A watchlist over five symbols draws two side by side instead of scrolling, and the 趨勢圖 button swaps the table for one symbol's K bars.

preview

Both markets are live, each from its own source, and the footer says which. Both read Yahoo's public endpoints by default (Yahoo 即時 for the US, Yahoo 延遲 for Taiwan, since Yahoo's Taiwan quotes run about twenty minutes behind) — no key and no account either way. Set "twSources": ["shioaji"] for real intraday ticks through a 永豐 brokerage account, or ["capital"] for a 群益 one on Windows — the band runs the fetcher itself either way (永豐 即時 / 群益 即時). A market the feed cannot reach falls back to a deterministic sine walk off each symbol's previous close and the footer says 示範資料(未接 API), so the tag always tells you what you are looking at. See The live feed.

損益 shows your holdings, not just the watchlist. It is a stop in the market button's own cycle (美股 → 台股 → 台股庫存, …) — landing on it opens a sortable, scrollable P&L table (張數/成本/現價/今日%/今日損益/總損益/損益% per position, plus a portfolio total) read from a holdings block in stock-band.json (the recommended spot for hand-written positions), or from stock-holdings.json — the runtime dir's copy is the fetcher's own output, and <project>/.claude/ holds an advanced manual override. See Holdings and the 損益 view.

Layout, colors and badges are ported from prototype/stock-band-demo.py — read that first if the numbers in hooks/board.tsx look arbitrary.

Requirements

  • Claude Code 2.1.269 or later, with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 set in ~/.claude/settings.json:
  { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }

(Merge the env key if the file already has one.)

  • An interactive terminal. The band is AbovePrompt, so nothing draws in claude -p, the desktop app or mobile. Measured on macOS iTerm2, Terminal.app, and tmux. Windows and the VS Code integrated terminal are untested — reports welcome.

Install

Install from inside the project you want the band in. --scope local keeps the mod in that one project, so your other projects keep a clean prompt:

claude plugin marketplace add darrell-tw/darrelltw-mods
cd /path/to/your/project
claude plugin install tw-stock-mod@darrelltw-mods --scope local

Restart Claude Code in that project and the band appears above the prompt. Drop --scope local to get the band in every project on the machine.

Or try it for one session without installing:

git clone https://github.com/darrell-tw/darrelltw-mods.git
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir darrelltw-mods/mods/tw-stock-mod

To remove it:

claude plugin uninstall tw-stock-mod@darrelltw-mods --scope local
claude plugin marketplace remove darrelltw-mods

Run the uninstall from the same project, and match the scope you installed with: a user install needs --scope user. Uninstalling leaves the runtime files behind in ~/.claude/stock-band/<project slug>/ (quote cache, heartbeat, a broker fetcher's log and pid, the SDK's own shioaji.log or CapitalLog/, and any holdings it fetched) — delete that whole folder to clean those up too, using the same slug rule as 哪個檔放哪裡 below. The folder only exists once a broker fetcher has run; a Yahoo-only install never creates it. On Windows the same folder sits under %USERPROFILE%, since Windows does not set HOME.

哪個檔放哪裡. ~/.claude/stock-band.json(使用者層級,不進版控)放個人偏好—— twSources、shioaji/capital 的券商路徑;<project>/.claude/stock-band.json (可進版控)放觀察清單。專案檔的 key 蓋掉個人檔同名的 key,見 Configure。

band 不會在你的 repo 裡寫任何檔。 報價、庫存、心跳、券商 log、券商 pid 這五個 執行期檔案都寫進 ~/.claude/stock-band/<專案路徑 slug>/,不再寫進專案的 .claude/ ——<project>/.claude/stock-band.json 因此可以放心進版控,只有券商路徑該留在個人檔。 SDK 自己寫的 log 也在這個執行期目錄:fetch-quotes-shioaji.py 會先切到這裡再匯入 shioaji,fetch-quotes-capital.py 則把 SKCOM 的 CapitalLog/ 指到這裡,所以都不會 跑進你的 repo。Windows 沒有 HOME,這個目錄會落在 %USERPROFILE% 底下,slug 也會把 磁碟機代號的冒號和反斜線一起換成 -(D:\app → D--app)。

Nothing shows up?

Four different causes produce the exact same symptom — no band, and no error message anywhere — so check all four in order:

  1. claude --version needs to be 2.1.269 or later.
  2. Open Claude Code in that project and run ! echo $CLAUDE_CODE_ENABLE_FUNCTION_HOOKS. It needs to print 1. A blank line means the flag is off — merge { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } } into ~/.claude/settings.json (add just that key if env already exists).
  3. Fully quit and reopen Claude Code after editing settings. /reload-plugins does not re-read env.
  4. Confirm the mod installed into the project you have open right now (--scope local scopes one install to one project):
   sed -n '/tw-stock-mod@darrelltw-mods/,/^    \]/p' ~/.claude/plugins/installed_plugins.json | grep projectPath

It prints one path per install; this project must be one of them. If it is not, run the install command again from inside this project's directory. (claude plugin list will not help here — every local install prints the same tw-stock-mod@darrelltw-mods / Scope: local line with no path.)

What the band shows

The built-in Taiwan watchlist is 20 symbols, so the two-column table is what a fresh install actually shows for Taiwan:

 台股 ▾  ☀ 盤中 09:00-13:30                    [翻頁 1/2] [趨勢圖] [收起 30分]
 代號              價格    變更%     代號              價格    變更%
 ──────────────────────────────────────────────────────────────────
 2382   廣達      341.56  ▲ +2.57%   2891   中信金      70.06  ▲ +0.52%
 2330   台積電  2,423.04  ▲ +1.59%   2412   中華電     143.95  ▲ +0.31%
 3711   日月光投控 599.88 ▲ +1.33%   1301   台塑         62.13  ▲ +0.21%
 2603   長榮      235.81  ▲ +0.99%   2002   中鋼         18.67  ▲ +0.11%
 0050   元大台灣50 107.16 ▲ +0.86%   006208 富邦台50    243.67  ▲ +0.07%
 加權指數 46,143.02 ▲ +280.50               13:12:25 ● Yahoo 延遲 · darrell_tw_

The built-in US watchlist is 20 symbols too, so it draws the same two-column table (this one is the real board fed by Yahoo, US session closed):

 美股 ▾  ☾ 休市 下次開盤 09:30 ET            [翻頁 1/2] [趨勢圖] [收起 30分]
 代號                           價格     ↓變更%       代號                           價格     ↓變更%
 ────────────────────────────────────────────────────────────────────────────────────────────── 1/2
 CRWD   CrowdStrike           242.49   ▲ +3.02%       MU     Micron                927.60   ▲ +0.39%
 AMD    AMD                   504.20   ▲ +2.19%       PLTR   Palantir              172.56   ▼ -0.43%
 ARM    Arm                   241.83   ▲ +1.18%       VOO    Vanguard 500          696.20   ▼ -0.44%
 META   Meta                  670.24   ▲ +0.70%       AAPL   Apple                 331.34   ▼ -0.52%
 NVDA   NVIDIA                212.17   ▲ +0.57%       QQQ    Invesco QQQ           704.54   ▼ -0.65%
 NASDAQ  25,981.57 ▼ -204.84                    收盤 16:00 · 30s Yahoo 即時 · darrell_tw_

Cut the list to five symbols, or set "columns": 1, and the same board draws the single-column table instead — that one keeps the 變更$ column and the highlighted top mover:

 美股 ▾  ☾ 休市 下次開盤 09:30 ET                        [趨勢圖] [收起 30分]
 代號                          價格      變更$   變更%
 ──────────────────────────────────────────────────
 TSLA    Tesla                366.33     +0.89 ▲ +0.24%
 QQQ     Invesco QQQ          715.56     +0.68 ▲ +0.10%
 NVDA    NVIDIA               218.28     -0.01 ▼ -0.00%
 VOO     Vanguard 500         699.16     -3.40 ▼ -0.48%
 NET     Cloudflare           304.77     -1.76 ▼ -0.57%
 NASDAQ 26,306.53 ▼ -26.51                  收盤 16:00 Yahoo 即時 · darrell_tw_
  • The title row is the market button. 台股 ▾ / 美股 ▾ names the market ON THE BAND, and nothing else — two markets, two labels. A session starts on the clock's pick and keeps tracking it until you press; the press shows the other market, and after that the button just toggles the two. (There is no third "auto" stop on the cycle: pressing into it would redraw the same board and read as a dead button, and "market": "auto" in the config is what puts a fresh session back on the clock.) The session state (☀ 盤中 orange / ☾ 休市 blue), the hours, and — when there is room — the same hours restated in Taipei time sit next to it. 翻頁 / 趨勢圖 / 收起 30 分 stay right-aligned in the same row. In the trend view the row changes: the session state and hours step aside (the chart draws its own title with both on it) and ◀ 上一檔 / 下一檔 ▶ n/N / 回清單 take that space, left- aligned beside the market button — next to the symbol they move through, rather than across the terminal from it. No hotkeys: a letter hotkey only fires once a Button already holds the focus ring, which buys nothing over Enter, and a digit hotkey would eat a prompt that starts with that digit — every button here is a click, or focus then Enter.
  • 8 rows below that in the table (header, rule, five quote rows, footer); the chart view keeps its own 9, title included, since that title names the symbol being charted rather than the market. Price gets the widest, brightest column in the single-column table, with its own breathing room; change$ and change% are right-anchored after it, capped at column 74. Under ~46 columns the name goes too.
  • Two columns once the watchlist holds more than five symbols — columns in the config controls it (see Configure). Each half only carries 代號/名稱/價格/變更% (變更$ has no room next to a second symbol), filled column-major off the current sort: the left half is ranks 1–5 on the page, the right half ranks 6–10, so the biggest gainers head the left column and the biggest fallers end the right one under the default change% sort. A 6-column gutter separates the halves so 變更% and the next 代號 do not read as one run of digits, and the two-column table caps at 104 columns (the single-column one caps at 74). Below 77 columns it falls back to the single-column table instead of squeezing — a half needs at least 35 columns (代號 + a name + 價格 + 變更%; see MIN_HALF_WIDTH in hooks/board.tsx), and two of those plus the gutter is 76 out of width - 1.
  • Rows are sorted by change% (hence ↓變更% in the header); the biggest mover gets the highlighted row in the single-column table. Two-column mode drops the highlight — a row there can hold two unrelated symbols, so there is no single "this row" to stripe. "sort": "list" keeps your own order.
  • Closed: prices go gray, the blink stops, and the header reads 休市 下次開盤 09:00 with 收盤 13:30 on the right. Outside both sessions the band keeps showing the market that closed most recently — its closing prices are the news right after 13:30 — until the other market is within an hour of opening.
  • The footer's right end is the clock — bare while the market is open (13:12:25), since its place on the row already says it is live; 收盤 13:30 once it closes — its live dot, the countdown to the next feed request, the source tag, and the credit sign-off, in that order. A narrow row drops the least essential piece first: the countdown, then the credit, then the clock and dot, always keeping the source tag — knowing what prices you are looking at matters more than anything else here.

The trend view (K bars)

 台股 ▾  ◀ 上一檔  下一檔 ▶ 2/10  回清單                        [收起 30分]
 2330 台積電  1,188.29 ▲ +23.29 (+2.00%)           K 棒(示範)· 台股 ☀ 盤中
 ▀▄          ▄▀▀▀▀▄          ▄▀▄▀▀          ▄▄▀▄▀▄          ▄▀▀   1,190.77
  ...candles, one per two columns, red/green by bar direction...
 09:00────────────────────────11:15───────────────────────13:30
 10 檔中第 2 檔                                    示範資料(未接 API) · darrell_tw_

Same 9 rows as the button row draws it into, one symbol's candles instead of the table — this view keeps its own title row, since the line names the symbol rather than the market. The plot is 6 terminal rows of 12 pixel rows: each cell packs two pixels as a half-block (▀ with the top pixel as foreground and the bottom as background) — and unlike braille, those glyphs are everywhere. Body color follows the market's convention (a bar that closed above its open is red on the Taiwan board), wicks are the same hue darkened, and the previous close is a faint horizontal reference line. The right edge carries high / previous close / low; the axis under the plot is the session's own clock.

Bars come from the quotes file (bars), so with no feed connected the chart draws demo bars and says so in the title. Only the focused symbol's bars are sent to the board, which is why moving through the list is a button press rather than a scroll.

Three buttons, one job each. ◀ 上一檔 and 下一檔 ▶ step through the symbols on the current page and wrap around at both ends; 回清單 leaves. They replace a single 趨勢圖 button that used to mean all three things at once — enter the view, step forward, and fall out to the table on the last symbol — which left no way back to the symbol you had just passed and no exit except walking to the end of the list.

Or click the row. Clicking a quote in the table opens that symbol's chart directly, so reaching the tenth symbol costs one click rather than ten presses. The whole half-row is the target, not just the four characters of the code. A Client has no Button, so the board hit-tests the pointer itself and posts the row back to the hook module (surface.onPointer → surface.post → ui.message). Clicking is an addition, not a replacement: the table is not on screen once the chart is up, so the three buttons remain the only way to move between symbols from there.

Configure

Everything has a default; the band works with no config at all. To change the watchlist, copy stock-band.example.json to <project>/.claude/stock-band.json.

Two config files, merged. ~/.claude/stock-band.json (a USER-level file, never inside a project — outside version control) is read first, then <project>/.claude/stock-band.json on top of it: any key the project file states wins, and any key only the user file states still applies. Your own source order and broker paths (twSources, shioaji, capital) belong in the user-level file, so a shared project's config stays neutral and each person who opens it keeps their own preference — see Your own source order.

keydefaultmeaning
market"auto"the state a session starts in: auto picks by the clock and keeps tracking it until the market button is pressed; tw/us opens on that market instead
marketSwitcher"menu"how the market button lets you jump between markets/holdings: menu (default) is a header Button (市場:台股 ▾) that opens a column of plain option Buttons below it — every option takes a mouse click, unlike select's dropdown (below); select is the engine's own dropdown, 市場: ... — reads great but its options only take the keyboard (arrows + Enter) while it holds focus, no click; tabs draws every stop (台股/台股庫存/美股/美股庫存/加密貨幣) as its own Button in a row, and drops to cycle on a terminal too narrow to fit them; cycle is one Button that walks the stops in order. An unrecognized value falls back to menu
refreshMs3000how often the module rebuilds the snapshot (min 1000; fixed at session start — changing it needs /reload-plugins)
sort"change"change = by change% desc, list = your order
highlighttruehighlight the biggest mover's row (single-column table only)
columns"auto"how many symbols a row draws: auto = 1 when the watchlist is 5 symbols or fewer, 2 for 6 or more; 1/2 force it (the board still falls back to 1 if the terminal is too narrow — see What the band shows)
feed"auto"auto prices whichever market is on the band; both keeps the other side warm; tw/us pins one; off = demo prices only
twSources["yahoo"]Taiwan's routes, in preference order — the band tries the first and falls through to the next for a tick that one has nothing fresh for. yahoo = Yahoo, ~20 min behind Taiwan but one request whatever the list length; shioaji = 永豐 real-time ticks on macOS/Linux and capital = 群益 real-time ticks on Windows, the band runs the fetcher itself either way — see 永豐 Shioaji as that fetcher and 群益 Capital as that fetcher. A legacy "twSource": "x" (a single string) still works as an alias for ["x"]. The shipped default never includes a broker route — put that in your own ~/.claude/stock-band.json
shioaji{ "python": "python3", "env": "~/.sinobon.env", "interval": 10 }read only when "shioaji" is somewhere in twSources — the interpreter, the env file holding SINOBON_API_KEY/SINOBON_SECRET_KEY (~ expands to $HOME), and seconds between snapshots
capital{ "python": "python", "env": "~/.capital.env", "dll": "", "interval": 10, "indices": [TSEA, OTCA] }read only when "capital" is somewhere in twSources — the interpreter (must have comtypes and match the registered 元件's bitness), the env file holding CAPITAL_USER_ID/CAPITAL_PASSWORD, the path to the registered SKCOM.dll (no default — 群益 ships a zip with no install location), seconds between snapshots, and the SKCOM 商品代號 the footer's index board flaps through ([] turns it off)
feedMs30000seconds between feed requests, in ms (floor 15000 — below that Yahoo answers 429; the request budget can widen it further)
pageMs10000how long one page holds before the board turns, in ms (floor 4000; 0 turns auto-paging off and leaves 翻頁 as the only way to page). Pressing 翻頁 restarts this countdown
tw / usbuilt-in lists{ code, name, prevClose } per symbol; only code is required. Taiwan 上櫃 symbols need "ex": "otc" (e.g. 6488 環球晶)
holdings{ "tw": [], "us": [] }manual positions for the 損益 view, { code, qty, cost } per holding — the recommended place to hand-write your positions; add "holdingsSource": "config" to make this win over a fetched stock-holdings.json for a market where you want your own numbers to stick — see Holdings and the 損益 view

Both built-in lists are 20 symbols, so columns resolves to 2 and each page holds 10 (a single-column page holds 5). Past that the watchlist pages, and 翻頁 / the rule's page tag only show up once there is a second page.

Pressing 翻頁 restarts the auto-page countdown. The page clock is a deadline measured from when the current page arrived, not an interval ticking on its own — an interval cannot be reset, so pressing the button 9.9 s into a 10 s interval used to leave the page you asked for on screen for 100 ms before the interval fired and took it away. The check rides the refreshMs poll rather than owning a timer, so an automatic turn lands up to refreshMs after its deadline: with the defaults, a page holds 10–13 s instead of exactly 10.

Your own source order

A source order is a personal preference, not a project one — the person running the band is who has (or does not have) a broker account, not the repository. Put twSources and the broker block in ~/.claude/stock-band.json instead of the project's own stock-band.json:

// ~/.claude/stock-band.json - never in a repo, one per person
{
  "twSources": ["shioaji", "yahoo"],
  "shioaji": { "python": "python3", "env": "~/.sinobon.env", "interval": 10 }
}

python is whichever interpreter has shioaji installed — the system python3, or a venv's own bin/python3 if that is where you pip install shioaji. Point it at that venv, not at python3 blindly.

On Windows the same file lives at %USERPROFILE%\.claude\stock-band.json and names the 群益 route instead:

{
  "twSources": ["capital", "yahoo"],
  "capital": {
    "python": "python",
    "env": "~/.capital.env",
    "dll": "~/CapitalAPI/元件/x64/SKCOM.dll",
    "interval": 10
  }
}

Every project that has no twSources of its own then uses this order, and the project's stock-band.json stays free to commit — it never has to name a broker account or a path only one contributor's machine has. A project that DOES set its own twSources (or the legacy twSource) still overrides this, key for key: see the merge order at the top of Configure.

The live feed

The module fetches quotes itself, through $.http.fetch, on its own clock (feedMs, default 30 s) separate from the redraw poll (refreshMs, 3 s). Under the default feed: "auto" only the market on the band costs a request, and pressing the market button fetches the market you land on straight away instead of leaving it on demo prices until the next tick.

  • US: two requests per tick with the built-in list. Yahoo's spark endpoint answers every symbol on the board plus ^DJI/^GSPC/^IXIC in a single call: last price, previous close, and the day's regular-market numbers. Past 20 symbols Yahoo answers Number of symbols needs to be less than or equal to 20, so the built-in 20-symbol list plus its three indices is 23 and splits into two requests — fetchSpark batches every call at 20 symbols for exactly this reason. Two requests a tick puts the budget floor at 24 s, still under the 30 s default feedMs, so nothing slows down unless you set feedMs below 24000. Cut the list to 17 and it is one request again.
  • Taiwan: Yahoo by default, same batching as the US route. The built-in 20-symbol list plus its index is 21 symbols, so it costs two requests a tick the same way a 20-symbol US list would. Yahoo's Taiwan quotes run about twenty minutes behind the exchange's own tape.
  • Taiwan: "twSources": ["shioaji"] (macOS/Linux) or ["capital"] (Windows) for a broker's own real-time ticks — the band runs the fetcher, you never touch a terminal. See 永豐 Shioaji as that fetcher and 群益 Capital as that fetcher; the built-in Yahoo feed does not run for Taiwan on either route, only the per-symbol Yahoo chart call the trend view already makes for K bars.
  • K bars cost extra, so they are fetched only when the chart view wants them — one request for the one symbol it is drawing, always through Yahoo's per-symbol chart call regardless of which twSources route prices the table.
  • Rate limits are real. A request with no browser User-Agent gets 429 on the first try, and the ban lasts minutes. Every non-2xx doubles the wait, up to 5 minutes.
  • Repeated URLs come back cached — measured: six ticks over 80 seconds returned a byte-identical body and a frozen price — so every request carries a _=<timestamp> and no-cache headers.

The footer animation

  • The footer is a Solari split-flap board. ^DJI, ^GSPC and ^IXIC ride the same batched request as the quotes, so all three cost nothing extra. Each holds for 5 seconds, then the row turns: a two-column block front sweeps left to right, and behind it every flap riffles through its own drum of characters until it reaches its target, 28 ms a step. A terminal cell cannot show half a character, so nothing here tries to fold one; every frame holds real characters, which is what a real airport board shows too.
  • The front covers every column it passes, including the ones whose charac
Source 2 files
hooks/register.tsx 3889 lines
1/* @jsx h */
2import type { Register } from 'claude-code'
3
4// tw-stock-mod: a watchlist band above the Claude Code prompt. Taiwan trading
5// hours show the Taiwan list, US trading hours show the US list, and the
6// red/green convention flips with the market (台股紅漲綠跌 / 美股綠漲紅跌).
7//
8// This module never calls $.model.* and never touches the prompt: it computes
9// the market session off $.clock.now(), builds a quote snapshot, and draws a
10// Client board (hooks/board.tsx). Both markets are priced live by the feed
11// below, each from its own source and each saying which in the footer: US
12// quotes come from Yahoo's public endpoints, and so does Taiwan by default -
13// Yahoo's Taiwan quotes run about twenty minutes behind, the tradeoff for a
14// feed that answers with one request whatever the list length.
15// `twSources` (an order of preference, e.g. `["shioaji", "yahoo"]`) tries
16// the exchange's own real-time intraday endpoint (`mis`), 永豐's real-time
17// feed (`shioaji`, macOS/Linux) or 群益's (`capital`, Windows) first, falling
18// through to the next entry for a tick that source has nothing fresh for; the
19// shipped default is `["yahoo"]` alone. A market the feed cannot reach at all
20// falls back to a deterministic sine walk off each symbol's previous close,
21// and the footer then says 示範資料 rather than pretending.
22// Machine-written quotes and holdings live under the user's home directory
23// now (see runtimeDir below), never in the project's `.claude/`. Quotes read
24// order: the runtime-dir file while it is fresh (<120s), then the project's
25// `.claude/stock-quotes.json` - which stays as the override seam (see
26// stock-band.example.json and docs/stock-api-notes.md) for a hand-edited
27// snapshot or another fetcher to take the band over - then the built-in
28// feed. Holdings follow the same order, except the runtime-dir file never
29// expires (see parseHoldingsFile): a position does not go stale just
30// because nobody wrote a fresh copy recently.
31//
32// Never name a local variable `h`: every JSX tag in this file compiles to h(...).
33
34const CONFIG_PATH = '.claude/stock-band.json'
35const QUOTES_PATH = '.claude/stock-quotes.json'
36const HOLDINGS_PATH = '.claude/stock-holdings.json'
37// A user-level config, never inside a project (so it never lands in version
38// control): each person's own source order and broker paths live here, and
39// a shared project's stock-band.json stays neutral. `~` is resolved with
40// userHome() at poll time, since a path constant cannot expand it.
41const USER_CONFIG_REL = '.claude/stock-band.json'
42
43/**
44 * The home directory every `~` and every runtime path resolves against.
45 * `$HOME` first, `%USERPROFILE%` second: Windows does not set `HOME` for a
46 * normal process, and without the fallback runtimeDir() below would put one
47 * person's live prices and PID file inside the project's own `.claude/` -
48 * exactly what the runtime dir exists to prevent. `scripts/fetch-quotes-
49 * capital.py`'s user_home() reads the same two, in the same order.
50 */
51async function userHome($: { env: { get(name: string): Promise<string | undefined> } }): Promise<string> {
52  return (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
53}
54
55// Everything the module or a broker fetcher writes at runtime - quotes,
56// holdings, the heartbeat, the fetcher's log and its PID file - lives under
57// this directory instead of the project's `.claude/`, so a shared project
58// never picks up one person's live prices or PID file. One directory per
59// project avoids collisions: RUNTIME_DIR_ROOT plus the project path with
60// its leading separators dropped and every remaining separator turned into
61// "-" (e.g. `/Users/x/app` -> `Users-x-app`, `D:\app` -> `D--app`). `\` and
62// `:` count as separators alongside `/` so a Windows path becomes a legal
63// directory name; a POSIX path contains neither, so this is byte-identical
64// to the old `/`-only rule there and no existing runtime dir moves.
65// `home` falls back to the project's own `.claude/` only when neither $HOME
66// nor %USERPROFILE% is set, matching how this module wrote its runtime files
67// before runtimeDir existed.
68const RUNTIME_DIR_ROOT = '.claude/stock-band'
69function runtimeDir(home: string, project: string): string {
70  if (!home) return `${project}/.claude/`
71  const slug = project.replace(/^[/\\]+/, '').replace(/[/\\:]/g, '-')
72  return `${home}/${RUNTIME_DIR_ROOT}/${slug}/`
73}
74
75const DEFAULT_REFRESH_MS = 3000
76const QUOTE_STALE_MS = 120_000
77const SNOOZE_MS = 30 * 60 * 1000
78// One symbol page in single-column mode, two in two-column mode (see
79// `columns` below) - the table Client is 8 terminal rows either way, the
80// chart one 9, whatever the list holds.
81const PAGE_SIZE_1COL = 5
82const PAGE_SIZE_2COL = 10
83// Yahoo's spark endpoint answers `Number of symbols needs to be less than or
84// equal to 20` above 20 symbols (measured 2026-09-16: 20 -> 200, 21 -> 400).
85// That is a request-batching limit, not a watchlist-length one - `fetchSpark`
86// already splits a longer symbol list into 20-symbol requests - but a list
87// longer than Yahoo answers in two requests is not worth carrying, so this
88// caps it there too.
89const MAX_SYMBOLS = 20
90const SPARK_BATCH = 20 // Yahoo's own per-request symbol cap
91const PAGE_MS_DEFAULT = 10_000 // one page holds this long before the board turns
92const PAGE_MS_MIN = 4000
93// A budget, not an interval: `feedMs` alone cannot keep the host inside the
94// limit once one tick costs more than one request. See feedInterval().
95const REQUESTS_PER_HOUR = 300
96const CHART_BARS = 40 // K bars the chart view asks for (it draws what fits)
97const DEMO_BAR_MS = 3000 // demo time per fake bar; a real feed sets its own
98
99// --- live feed --------------------------------------------------------------
100// Yahoo's public endpoints, no key, no account. One batched spark request per
101// tick covers the whole list plus the index, which is what keeps the feed
102// inside the rate limit: a request with no browser User-Agent gets 429 on the
103// first try, and a burst of per-symbol requests gets 429 as well. K bars cost
104// one request per symbol, so nothing fetches them until the chart view asks
105// for the one symbol it is drawing.
106const FEED_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
107const FEED_MS_DEFAULT = 30_000
108const FEED_MS_MIN = 15_000 // a floor, so a bad config cannot get the host banned
109const FEED_BACKOFF_MAX_MS = 300_000
110const BARS_MAX_AGE_MS = 120_000 // a 5-minute bar refetched sooner than this says nothing new
111const BARS_STALE_MS = 900_000 // past this a bar set is dropped rather than drawn
112const US_INDEX_SYMBOL = '^IXIC' // NASDAQ Composite, what MARKETS.us calls its index
113// the three the US market is read by. They ride the same batched request as
114// the quotes, so showing all three costs no extra call.
115const US_INDICES: { symbol: string; name: string }[] = [
116  // Latin names: the board flaps them character by character, and a Chinese
117  // character has no drum to riffle through
118  { symbol: '^DJI', name: 'DOW' },
119  { symbol: '^GSPC', name: 'S&P 500' },
120  { symbol: US_INDEX_SYMBOL, name: 'NASDAQ' },
121]
122
123// Pionex's public ticker endpoint, no key, no header required at all
124// (verified 2026-09-18: unlike Yahoo, a bare GET with no User-Agent still
125// answers 200). `symbol=A,B` does NOT batch multiple codes in one request
126// (verified 2026-09-18: it answers `{"result":false,"code":
127// "MARKET_INVALID_SYMBOL", ...}`, HTTP 200 regardless) - the endpoint with
128// no `symbol` param at all answers the whole exchange instead (~330
129// tickers, ~55 KB), which is what feedCrypto fetches and filters locally so
130// a ten-coin watchlist still costs one request a tick, not ten.
131const PIONEX_TICKERS_URL = 'https://api.pionex.com/api/v1/market/tickers'
132// Pionex documents the limit as "10 per second" but as a WEIGHT budget, not
133// a request count, and never publishes a per-endpoint weight table
134// (https://pionex-doc.gitbook.io/apidocs/restful/general/rate-limit) - so
135// this cannot be read as "10 requests/second" for every endpoint. Measured
136// against THIS endpoint specifically (2026-09-18, see docs/stock-api-
137// notes.md §11.2 for the full readout): every response carries an
138// `x-ratelimit-tokens` header, steady-state ~29-30, and both a bulk fetch
139// (no `symbol`, ~330 tickers) and a single-symbol fetch cost the same ~1
140// token each - so for `market/tickers`, weight is 1 per request regardless
141// of payload size. That is evidence for this one endpoint only; `depth`,
142// `klines` and anything private have not been measured and are not assumed
143// to match.
144// A 429 blocks the IP for 60s and adds +10s for every request that still
145// lands during the block, so retrying while blocked only makes it worse.
146// CRYPTO_COOLDOWN_MS sits comfortably above that 60s floor rather than
147// matching it exactly, and it is a flat wait, not an exponential backoff -
148// Pionex's own block is a fixed length, not a curve this module needs to
149// invent on top of it (contrast FEED_BACKOFF_MAX_MS, which doubles because
150// Yahoo's own throttling behavior was never this well specified).
151const CRYPTO_COOLDOWN_MS = 90_000
152// `x-ratelimit-tokens` reflects the WHOLE IP's shared bucket, not this
153// module's own usage - anything else on the same machine hitting Pionex
154// lowers the number this module reads too. Below this many tokens,
155// feedCrypto skips firing this one tick rather than spend what is left of
156// someone else's headroom; it does not retry sooner or shorten the
157// interval to compensate; that would be "failing to back off" the way the
158// rate-limit doc warns against, this time self-inflicted.
159const CRYPTO_LOW_TOKENS = 5
160
161// CoinGecko's free `coins/markets` endpoint - no API key needed (verified
162// 2026-09-19, HTTP 200 with no auth header). This is ONLY the market-cap
163// sort's circulating-supply source, never a price: prices still come from
164// Pionex on every tick (see feedCrypto) so a CoinGecko outage never touches
165// what is on screen, only how the crypto list is ordered.
166const COINGECKO_MARKETS_URL = 'https://api.coingecko.com/api/v3/coins/markets'
167// Circulating supply barely moves hour to hour, so this bounds how often
168// fetchCryptoSupply is allowed to hit CoinGecko - market cap itself still
169// updates every tick because it is computed as supply(cached) x price(live),
170// never fetched as a whole number.
171const CRYPTO_SUPPLY_TTL_MS = 3_600_000 // 1 hour
172// CoinGecko publishes no per-endpoint free-tier rate limit (unmeasured as of
173// 2026-09-19 - unlike CRYPTO_COOLDOWN_MS above, which IS a measured Pionex
174// number). This cooldown after a failed/empty answer is a conservative
175// guess, not a documented limit - kept long on purpose until someone
176// measures the real one.
177const CRYPTO_SUPPLY_COOLDOWN_MS = 600_000 // 10 minutes
178
179// `"mis"` in `twSources` sends Taiwan to the exchange instead of Yahoo. Both
180// are keyless, but Yahoo's Taiwan quotes run about twenty minutes behind the
181// floor (measured 2026-09-16: Yahoo answered 10:21:51 while MIS was on
182// 10:41:59), and a band that says 即時 has to mean it - which is what `mis`
183// is for. MIS takes the whole watchlist and both indices in one request
184// whatever the list length, and has no 20-symbol cap of its own.
185const MIS_URL = 'https://mis.twse.com.tw/stock/api/getStockInfo.jsp'
186// 上市 / 上櫃. It decides the MIS channel prefix and the Yahoo suffix, and
187// nothing else about a symbol tells them apart - 6488 is 上櫃, 2330 is 上市.
188type TwExchange = 'tse' | 'otc'
189const TW_INDEX_SYMBOL = 't00' // 發行量加權股價指數, what MARKETS.tw calls its index
190// Latin names for the same reason the US ones are Latin: the board flaps one
191// character at a time and a Chinese character has no drum to riffle through.
192// they are named `code` rather than `symbol` so misChannel() takes them as-is
193// MIS answers every index on the same request as the quotes, so the length of
194// this list costs nothing. What it does cost is time on the footer: each row
195// holds 5 s before the board flaps to the next, so four indices is a 20 s lap.
196// `twIndices` in the config replaces the whole list - the exchange publishes
197// 146 of them (getCategory.jsp?ex=tse&i=TIDX lists every channel).
198const TW_INDICES: TwIndex[] = [
199  { code: TW_INDEX_SYMBOL, name: 'TAIEX', ex: 'tse' }, // 發行量加權股價指數
200  { code: 't24', name: 'SEMI', ex: 'tse' }, // 半導體類指數
201  { code: 't17', name: 'FINANCE', ex: 'tse' }, // 金融保險類指數
202  { code: 't15', name: 'SHIPPING', ex: 'tse' }, // 航運類指數
203  // 櫃買 is { code: 'o00', name: 'TPEx', ex: 'otc' } - it needs the otc channel
204]
205const TW_YAHOO_INDEX = '^TWII' // the Yahoo route's only index; ^TWOII answers a year-old close
206
207/** a footer index row on the MIS route; `code`/`ex` are what misChannel() reads */
208type TwIndex = { code: string; name: string; ex: TwExchange }
209
210type MarketId = 'tw' | 'us' | 'crypto'
211type Phase = 'open' | 'closed'
212type MarketMode = 'auto' | MarketId
213/** a route the Taiwan feed can try, in the order `Config.twSources` lists them */
214type TwSourceName = 'shioaji' | 'capital' | 'yahoo' | 'mis'
215type View = 'table' | 'chart' | 'pnl'
216/** how many symbols the table draws per row; "auto" picks off the page size, see effectiveColumns() */
217type ColumnMode = 'auto' | 1 | 2
218/**
219 * `'change'`/`'list'` are the original two - rank by 24h(tw/us)/24h(crypto)
220 * %, or leave the watchlist's own order alone. `'marketcap'`/`'volume'` are
221 * crypto-only (see effectiveSort): tw/us have neither Pionex's `amount` nor
222 * a CoinGecko supply cache, so either one falls back to `'change'` there.
223 */
224type SortKey = 'change' | 'list' | 'marketcap' | 'volume'
225/**
226 * How the band lets a person jump between the market/holdings stops (see
227 * marketStops()): `tabs` draws every stop as its own Button, `select` draws
228 * the engine's own dropdown, `cycle` draws one Button that walks the stops
229 * in order, `menu` draws a header Button that opens a column of option
230 * Buttons below it. `menu` is the default (2026-09-19, at the user's
231 * request): every option in `select`'s dropdown is a Select value, and the
232 * engine's Select only lets the keyboard pick one while it holds focus -
233 * arrows move, Enter picks - a mouse click on an option does nothing (d.ts's
234 * own SelectProps carries no `onPress`/click path). `menu`'s options are
235 * plain Buttons, so they take a click the same way every other Button in
236 * this row already does. `tabs` was tried 2026-09-19 at the user's request
237 * (the Select's own reflow/highlight chrome is the engine's, not something
238 * this mod can restyle) and dropped after width measurements on a real
239 * terminal showed it does not reliably fit - see defaultConfig's own
240 * comment on marketSwitcher for the numbers. `tabs`, `select` and `cycle`
241 * stay as config-switchable alternatives. See parseConfigRoot for how a
242 * config file picks one; an invalid value falls back to `menu` rather than
243 * throwing.
244 */
245type MarketSwitcher = 'tabs' | 'select' | 'cycle' | 'menu'
246
247type Ticker = {
248  code: string
249  name: string
250  /** 上市 tse (default) or 上櫃 otc; Taiwan only, and both price routes need it */
251  ex?: TwExchange
252  prevClose: number
253  // demo-only price walk parameters (ignored once a quotes file drives the band)
254  amp: number
255  phase: number
256  period: number
257  drift: number
258}
259
260// prevClose is the change basis and, in demo mode, the level the fake walk
261// oscillates around; amp/phase/period/drift only shape that fake walk and are
262// ignored once a quotes file drives the band. All 20 prevClose values were
263// read directly off Yahoo's spark endpoint on 2026-09-16 ~12:39 Taipei time
264// and cross-checked against 證交所 MIS's own `y` field (exact match on every
265// symbol) - refresh both if they drift. All twenty are 上市 (no otc symbol
266// needed a `.TWO`/`otc_` route).
267const TW_LIST: Ticker[] = [
268  { code: '2330', name: '台積電', prevClose: 2385, amp: 0.9, phase: 0, period: 47, drift: 1.1 },
269  { code: '2317', name: '鴻海', prevClose: 246.5, amp: 0.7, phase: 1.7, period: 61, drift: 0.35 },
270  { code: '2454', name: '聯發科', prevClose: 4430, amp: 1.1, phase: 3.1, period: 53, drift: -0.6 },
271  { code: '0050', name: '元大台灣50', prevClose: 106.25, amp: 0.4, phase: 0.8, period: 71, drift: 0.55 },
272  { code: '006208', name: '富邦台50', prevClose: 243.5, amp: 0.35, phase: 2.4, period: 67, drift: -0.15 },
273  { code: '2412', name: '中華電', prevClose: 143.5, amp: 0.25, phase: 0.5, period: 83, drift: 0.1 },
274  { code: '2881', name: '富邦金', prevClose: 151.0, amp: 0.5, phase: 1.2, period: 57, drift: 0.2 },
275  { code: '2882', name: '國泰金', prevClose: 110.0, amp: 0.5, phase: 2.0, period: 63, drift: -0.15 },
276  { code: '2891', name: '中信金', prevClose: 69.7, amp: 0.45, phase: 2.8, period: 69, drift: 0.1 },
277  { code: '3008', name: '大立光', prevClose: 6055, amp: 1.4, phase: 3.5, period: 41, drift: -0.8 },
278  { code: '2603', name: '長榮', prevClose: 233.5, amp: 1.6, phase: 4.2, period: 39, drift: 1.0 },
279  { code: '1301', name: '台塑', prevClose: 62.0, amp: 0.35, phase: 4.9, period: 77, drift: -0.2 },
280  { code: '2002', name: '中鋼', prevClose: 18.65, amp: 0.3, phase: 5.5, period: 87, drift: 0.05 },
281  { code: '2308', name: '台達電', prevClose: 1670, amp: 0.9, phase: 0.2, period: 49, drift: 0.5 },
282  { code: '3711', name: '日月光投控', prevClose: 592.0, amp: 0.8, phase: 0.9, period: 52, drift: 0.3 },
283  { code: '2379', name: '瑞昱', prevClose: 703.0, amp: 1.0, phase: 1.6, period: 45, drift: -0.4 },
284  { code: '3034', name: '聯詠', prevClose: 541.0, amp: 0.95, phase: 2.3, period: 48, drift: 0.35 },
285  { code: '2357', name: '華碩', prevClose: 928.0, amp: 0.7, phase: 3.0, period: 59, drift: -0.25 },
286  { code: '2382', name: '廣達', prevClose: 333.0, amp: 1.3, phase: 3.7, period: 43, drift: 0.9 },
287  { code: '2303', name: '聯電', prevClose: 138.5, amp: 0.6, phase: 4.4, period: 64, drift: -0.3 },
288]
289
290// Same field contract as TW_LIST above. All 20 prevClose values came off
291// Yahoo's spark endpoint in one request on 2026-09-16 ~13:05 Taipei time
292// (US market closed, so these are the 09-15 closes) - refresh them if the
293// demo walk starts oscillating around the wrong level. NFLX is post-split.
294const US_LIST: Ticker[] = [
295  { code: 'NVDA', name: 'NVIDIA', prevClose: 210.96, amp: 1.3, phase: 0.4, period: 43, drift: 0.9 },
296  { code: 'TSLA', name: 'Tesla', prevClose: 358.97, amp: 1.8, phase: 2.2, period: 37, drift: -1.2 },
297  { code: 'NET', name: 'Cloudflare', prevClose: 330.36, amp: 1.5, phase: 4, period: 59, drift: 0.4 },
298  { code: 'QQQ', name: 'Invesco QQQ', prevClose: 709.18, amp: 0.5, phase: 1.1, period: 73, drift: 0.25 },
299  { code: 'VOO', name: 'Vanguard 500', prevClose: 699.3, amp: 0.4, phase: 3.6, period: 79, drift: -0.1 },
300  { code: 'AAPL', name: 'Apple', prevClose: 333.08, amp: 0.7, phase: 0.9, period: 61, drift: 0.3 },
301  { code: 'MSFT', name: 'Microsoft', prevClose: 505.41, amp: 0.6, phase: 1.6, period: 67, drift: -0.25 },
302  { code: 'GOOGL', name: 'Alphabet', prevClose: 349.39, amp: 0.8, phase: 2.3, period: 55, drift: 0.45 },
303  { code: 'AMZN', name: 'Amazon', prevClose: 253.54, amp: 0.85, phase: 3.0, period: 51, drift: -0.35 },
304  { code: 'META', name: 'Meta', prevClose: 665.6, amp: 0.95, phase: 3.7, period: 47, drift: 0.5 },
305  { code: 'AVGO', name: 'Broadcom', prevClose: 344.72, amp: 1.2, phase: 4.4, period: 45, drift: 0.7 },
306  { code: 'AMD', name: 'AMD', prevClose: 493.41, amp: 1.4, phase: 5.1, period: 41, drift: 1.0 },
307  { code: 'TSM', name: 'TSMC ADR', prevClose: 418.01, amp: 1.0, phase: 5.8, period: 49, drift: 0.6 },
308  { code: 'NFLX', name: 'Netflix', prevClose: 80.32, amp: 0.9, phase: 0.2, period: 57, drift: -0.4 },
309  { code: 'PLTR', name: 'Palantir', prevClose: 173.31, amp: 1.7, phase: 1.0, period: 39, drift: 0.85 },
310  { code: 'COIN', name: 'Coinbase', prevClose: 191.45, amp: 2.0, phase: 1.9, period: 35, drift: -1.1 },
311  { code: 'CRWD', name: 'CrowdStrike', prevClose: 235.38, amp: 1.3, phase: 2.7, period: 44, drift: 0.4 },
312  { code: 'MU', name: 'Micron', prevClose: 924.03, amp: 1.6, phase: 3.4, period: 40, drift: 0.95 },
313  { code: 'ORCL', name: 'Oracle', prevClose: 144.79, amp: 1.1, phase: 4.1, period: 53, drift: -0.5 },
314  { code: 'ARM', name: 'Arm', prevClose: 239.01, amp: 1.25, phase: 4.8, period: 46, drift: 0.55 },
315]
316
317// Same field contract as TW_LIST/US_LIST, but `code` is the plain ticker
318// (`BTC`), never the Pionex symbol (`BTC_USDT`) - pionexSymbol() below does
319// that translation the same way yahooSymbol() does for Taiwan, and `name`
320// is the ticker again rather than a company name (there is no issuer to
321// name). `prevClose` here is NOT "yesterday's close" the way it is for
322// tw/us: Pionex has no such concept (see feedCrypto's comment on 24-hour
323// change), so it is only the demo-walk anchor and the config fallback -
324// close prices read directly off the tickers endpoint on 2026-09-18
325// ~23:15 UTC (see docs/stock-api-notes.md §11). amp/phase/period/drift are
326// demo-only, same as the other two lists. Every code here must have a real
327// Pionex market: TON does not (checked against the full ~330-symbol response,
328// 2026-09-18) and was replaced by BCH, the next major by turnover that Pionex
329// actually lists. A code with no market is not a crash - the existing "market
330// has a snapshot but never priced this code" path draws it as a dim
331// placeholder rather than a fake price (see buildProps) - but a default list
332// must not ship a row that can never fill in.
333const CRYPTO_LIST: Ticker[] = [
334  { code: 'BTC', name: 'BTC', prevClose: 80744.04, amp: 1.2, phase: 0, period: 53, drift: 0.3 },
335  { code: 'ETH', name: 'ETH', prevClose: 2579.91, amp: 1.6, phase: 1.1, period: 47, drift: 0.4 },
336  { code: 'SOL', name: 'SOL', prevClose: 110.92, amp: 2.1, phase: 2.2, period: 41, drift: 0.6 },
337  { code: 'BNB', name: 'BNB', prevClose: 756.24, amp: 1.3, phase: 3.3, period: 59, drift: 0.2 },
338  { code: 'XRP', name: 'XRP', prevClose: 1.3785, amp: 2.4, phase: 4.4, period: 37, drift: 0.5 },
339  { code: 'DOGE', name: 'DOGE', prevClose: 0.08735, amp: 3.0, phase: 0.6, period: 33, drift: 0.7 },
340  { code: 'ADA', name: 'ADA', prevClose: 0.2191, amp: 2.6, phase: 1.7, period: 43, drift: 0.35 },
341  { code: 'AVAX', name: 'AVAX', prevClose: 8.1, amp: 2.8, phase: 2.8, period: 39, drift: 0.55 },
342  { code: 'LINK', name: 'LINK', prevClose: 12.16, amp: 2.2, phase: 3.9, period: 45, drift: 0.45 },
343  { code: 'BCH', name: 'BCH', prevClose: 252.6, amp: 1.9, phase: 5.0, period: 51, drift: 0.25 },
344]
345
346// Pionex has no market-cap or circulating-supply field at all (verified
347// 2026-09-18 against the full ticker response: symbol/time/open/close/high/
348// low/volume/amount/count, nothing else) - fetchCryptoSupply asks CoinGecko
349// instead, and CoinGecko's `id` is NOT the ticker code (BNB is
350// `binancecoin`, XRP is `ripple`, AVAX is `avalanche-2`, BCH is
351// `bitcoin-cash` - the rest happen to match their lowercase full name). A
352// user's config.lists.crypto is not required to stay inside this table: a
353// code with no entry here is logged once per session (cryptoUnmappedWarned)
354// and its market cap reads 0, so it sorts last rather than crashing or
355// dropping off the list (see marketCapOf).
356const CRYPTO_COINGECKO_ID: Record<string, string> = {
357  BTC: 'bitcoin',
358  ETH: 'ethereum',
359  SOL: 'solana',
360  BNB: 'binancecoin',
361  XRP: 'ripple',
362  DOGE: 'dogecoin',
363  ADA: 'cardano',
364  AVAX: 'avalanche-2',
365  LINK: 'chainlink',
366  BCH: 'bitcoin-cash',
367}
368
369type MarketConf = {
370  label: string
371  list: Ticker[]
372  hours: string
373  indexName: string
374  indexClose: number
375  indexAmp: number
376  indexDrift: number
377  /** minutes from local midnight */
378  open: number
379  close: number
380  offset: (now: number) => number
381  /**
382   * true for a market that never closes (crypto). phaseOf() reads this
383   * before it ever looks at `open`/`close`/weekday, because a 24/7 market
384   * has no boundary those fields could express - there is no real "closed"
385   * moment to compare `now` against, so minutesToOpen/minutesSinceClose
386   * (which walk forward/back to the next/last such moment) do not apply
387   * either and are never called once this is true. `open`/`close` still
388   * carry 0/1440 for this market (see MARKETS.crypto) so the places that
389   * print them (chart-view axis labels) get a literally true "00:00-24:00"
390   * span instead of an undefined read.
391   */
392  alwaysOpen?: boolean
393  /**
394   * what `sort: undefined` resolves to for THIS market (see effectiveSort) -
395   * the per-market default the old single global `defaultConfig().sort`
396   * used to hardcode. tw/us keep the original 'change'; crypto opens on
397   * 'marketcap', at the user's request (2026-09-19).
398   */
399  defaultSort: SortKey
400}
401
402// Taipei is UTC+8 all year; US eastern is UTC-5, UTC-4 between the 2nd Sunday
403// of March and the 1st Sunday of November. Doing the arithmetic here beats
404// trusting a tz database to exist inside the hooks sandbox.
405function usEasternOffset(now: number): number {
406  const d = new Date(now)
407  const year = d.getUTCFullYear()
408  const month = d.getUTCMonth() + 1
409  const day = d.getUTCDate()
410  if (month < 3 || month > 11) return -5
411  if (month > 3 && month < 11) return -4
412  const firstDow = new Date(Date.UTC(year, month - 1, 1)).getUTCDay() // 0 = Sunday
413  const firstSunday = 1 + ((7 - firstDow) % 7)
414  if (month === 3) return day >= firstSunday + 7 ? -4 : -5
415  return day >= firstSunday ? -5 : -4
416}
417
418const MARKETS: Record<MarketId, MarketConf> = {
419  tw: {
420    label: '台股',
421    list: TW_LIST,
422    hours: '09:00-13:30',
423    indexName: '加權指數',
424    indexClose: 45862.52,
425    indexAmp: 0.6,
426    indexDrift: 0.75,
427    open: 9 * 60,
428    close: 13 * 60 + 30,
429    offset: () => 8,
430    defaultSort: 'change',
431  },
432  us: {
433    label: '美股',
434    list: US_LIST,
435    hours: '09:30-16:00 ET',
436    indexName: 'NASDAQ',
437    indexClose: 26333.04,
438    indexAmp: 0.5,
439    indexDrift: -0.35,
440    open: 9 * 60 + 30,
441    close: 16 * 60,
442    offset: usEasternOffset,
443    defaultSort: 'change',
444  },
445  crypto: {
446    label: '加密貨幣',
447    list: CRYPTO_LIST,
448    hours: '24 小時',
449    // BTC stands in for a headline index (see feedCrypto) - this is only the
450    // pre-fetch demo-walk anchor, same read as CRYPTO_LIST's prevClose
451    // values, 2026-09-18 ~23:15 UTC.
452    indexName: 'BTC',
453    indexClose: 80744.04,
454    indexAmp: 1.2,
455    indexDrift: 0.3,
456    // The whole day, 00:00-24:00 - see alwaysOpen's comment on MarketConf.
457    open: 0,
458    close: 24 * 60,
459    // Crypto has no exchange-local session to translate, so this reads as
460    // Taipei time - taipeiNote() then sees offset === TAIPEI_OFFSET and
461    // skips the "台灣 HH:MM" restatement it would otherwise add for a
462    // market whose hours are in another timezone.
463    offset: () => TAIPEI_OFFSET,
464    alwaysOpen: true,
465    defaultSort: 'marketcap',
466  },
467}
468
469type LocalParts = { dow: number; minutes: number; clock: string }
470
471function pad2(n: number): string {
472  return n < 10 ? `0${n}` : `${n}`
473}
474
475function localParts(now: number, offsetHours: number): LocalParts {
476  const d = new Date(now + offsetHours * 3_600_000)
477  return {
478    dow: d.getUTCDay(), // 0 = Sunday
479    minutes: d.getUTCHours() * 60 + d.getUTCMinutes(),
480    clock: `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`,
481  }
482}
483
484const TAIPEI_OFFSET = 8 // UTC+8 all year, no daylight saving
485
486function isWeekday(dow: number): boolean {
487  return dow >= 1 && dow <= 5
488}
489
490// PROTOTYPE LIMIT: weekday-only. Taiwan and US market holidays (and the
491// Taiwan make-up trading Saturdays) are not in here - a real feed's own
492// "no trades today" answer is what should decide this later.
493function phaseOf(now: number, market: MarketId): Phase {
494  const conf = MARKETS[market]
495  if (conf.alwaysOpen) return 'open'
496  const { dow, minutes } = localParts(now, conf.offset(now))
497  return isWeekday(dow) && minutes >= conf.open && minutes < conf.close ? 'open' : 'closed'
498}
499
500function minutesToOpen(now: number, market: MarketId): number {
501  const conf = MARKETS[market]
502  const { dow, minutes } = localParts(now, conf.offset(now))
503  if (isWeekday(dow) && minutes < conf.open) return conf.open - minutes
504  let days = 1
505  let d = (dow + 1) % 7
506  while (!isWeekday(d)) {
507    days += 1
508    d = (d + 1) % 7
509  }
510  return days * 1440 - minutes + conf.open
511}
512
513function minutesSinceClose(now: number, market: MarketId): number {
514  const conf = MARKETS[market]
515  const { dow, minutes } = localParts(now, conf.offset(now))
516  if (isWeekday(dow) && minutes >= conf.close) return minutes - conf.close
517  let days = 1
518  let d = (dow + 6) % 7
519  while (!isWeekday(d)) {
520    days += 1
521    d = (d + 6) % 7
522  }
523  return days * 1440 - conf.close + minutes
524}
525
526/** when this market last closed, as a timestamp - minutesSinceClose walks back
527 * over the weekend for us, so this is a real moment on any day of the week */
528function lastCloseAt(now: number, market: MarketId): number {
529  return now - minutesSinceClose(now, market) * 60_000
530}
531
532function hhmm(minutesFromMidnight: number): string {
533  return `${pad2(Math.floor(minutesFromMidnight / 60))}:${pad2(minutesFromMidnight % 60)}`
534}
535
536function sessionNote(now: number, market: MarketId, phase: Phase): string {
537  const conf = MARKETS[market]
538  const zone = MARKETS[market].offset(now) === TAIPEI_OFFSET ? '' : ' ET'
539  if (phase === 'open') return conf.hours
540  const mins = minutesToOpen(now, market)
541  return mins <= 1440 ? `下次開盤 ${hhmm(conf.open)}${zone}` : `下個交易日 ${hhmm(conf.open)}${zone}`
542}
543
544// The person reading this band lives in Taipei, so US hours in ET answer the
545// wrong question: 09:30 ET is 21:30 tonight, and the close lands after midnight.
546// Returns '' for a market already on Taipei time, and the board drops the
547// restatement rather than the clock when the row runs out of room.
548function taipeiNote(now: number, market: MarketId, phase: Phase): string {
549  const conf = MARKETS[market]
550  if (conf.offset(now) === TAIPEI_OFFSET) return ''
551  const shift = (TAIPEI_OFFSET - conf.offset(now)) * 60
552  const at = (minutes: number) => hhmm((((minutes + shift) % 1440) + 1440) % 1440)
553  return phase === 'open' ? `台灣 ${at(conf.open)}-${at(conf.close)}` : `台灣 ${at(conf.open)}`
554}
555
556const PREVIEW_MINS = 60 // how early a market takes the band over before it opens
557
558// auto mode: whichever market is trading. Outside both sessions the band keeps
559// showing the market that closed MOST RECENTLY - its closing prices are the
560// news right after 13:30, not the other side of the world's pre-market - until
561// the other market is within PREVIEW_MINS of its open.
562//
563// Crypto never enters this race on purpose: it is alwaysOpen (see
564// MarketConf), so if it competed here on the same "which one is open"
565// footing it would win every single tick and tw/us would never surface in
566// auto mode again. Auto stays a tw/us pick; crypto only shows up when
567// `market` names it directly or a manual switch lands on it (see mode !==
568// 'auto' below, which is untouched by this - it already returns whatever
569// `mode` says outright).
570function pickMarket(now: number, mode: MarketMode): { market: MarketId; phase: Phase } {
571  if (mode !== 'auto') return { market: mode, phase: phaseOf(now, mode) }
572  if (phaseOf(now, 'tw') === 'open') return { market: 'tw', phase: 'open' }
573  if (phaseOf(now, 'us') === 'open') return { market: 'us', phase: 'open' }
574  const twToOpen = minutesToOpen(now, 'tw')
575  const usToOpen = minutesToOpen(now, 'us')
576  const soonest = Math.min(twToOpen, usToOpen)
577  if (soonest <= PREVIEW_MINS) return { market: twToOpen <= usToOpen ? 'tw' : 'us', phase: 'closed' }
578  const market: MarketId = minutesSinceClose(now, 'tw') <= minutesSinceClose(now, 'us') ? 'tw' : 'us'
579  return { market, phase: 'closed' }
580}
581
582// --- quotes ----------------------------------------------------------------
583type Bar = [number, number, number, number] // open, high, low, close
584type IndexRow = { name: string; value: number; change: number; pct: number }
585
586type QuoteRow = {
587  code: string
588  name: string
589  price: number
590  change: number
591  pct: number
592  prevClose: number
593  bars?: Bar[]
594  /**
595   * 24h turnover in USDT (Pionex's `amount` field, NOT `volume` - `volume`
596   * is the coin's own unit count, which is meaningless to rank one coin
597   * against another; see effectiveSort/the `'volume'` sort branch below).
598   * Crypto only - tw/us never set this.
599   */
600  amount?: number
601  /**
602   * what this row said before the last update; absent when nothing moved.
603   * `code`/`name` are only set when the whole row changed symbol - a page turn -
604   * and they are what makes the board flap the left-hand columns as well.
605   */
606  was?: { price: number; change: number; pct: number; code?: string; name?: string }
607  /**
608   * the market has a live/override snapshot, but it never priced this code -
609   * not the same as "no change" (pct 0). board.tsx draws a dim placeholder
610   * instead of the price/change/pct fields; see buildProps' quotes.map.
611   */
612  noData?: boolean
613}
614
615// PROTOTYPE: a deterministic sine walk off the previous close, so the band
616// moves on its own with no API and no randomness to debug.
617function demoPrice(sym: Ticker, now: number): number {
618  const t = now / 1000
619  const pct =
620    sym.drift +
621    sym.amp * Math.sin((2 * Math.PI * t) / sym.period + sym.phase) +
622    0.35 * sym.amp * Math.sin((2 * Math.PI * t) / (sym.period / 4.7) + sym.phase * 2.3)
623  return Math.round(sym.prevClose * (1 + pct / 100) * 100) / 100
624}
625
626// a bar's high/low needs intra-bar movement the sine walk does not have, so a
627// deterministic wiggle stands in for it
628function demoBars(sym: Ticker, now: number, count: number): Bar[] {
629  const bars: Bar[] = []
630  for (let i = 0; i < count; i++) {
631    const t1 = now - (count - 1 - i) * DEMO_BAR_MS
632    const o = demoPrice(sym, t1 - DEMO_BAR_MS)
633    const c = demoPrice(sym, t1)
634    const mid = (o + c) / 2
635    const span = Math.abs(c - o) / 2 + mid * 0.0008 * (1 + Math.sin((t1 / 1000) * 1.7 + sym.phase) ** 2)
636    bars.push([o, Math.max(o, c) + span, Math.min(o, c) - span, c])
637  }
638  return bars
639}
640
641/**
642 * Rounds a price or a price difference to as many decimals as its own
643 * magnitude needs to stay meaningful, rather than a flat 2 - the same
644 * thresholds board.tsx's quotePriceDecimals() uses for display. A flat 2
645 * decimals is harmless for tw/us (nothing on either watchlist trades under
646 * $1) but silently wrecks a sub-$1 crypto move: DOGE's real 24h change of
647 * $0.00558 rounds to $0.01 at a flat 2 decimals - not a display quirk, an
648 * 80%+ relative error baked into `pct` itself, since pct is computed FROM
649 * this rounded value (see quoteRow below). Scaling by the VALUE being
650 * rounded rather than by market means tw/us (always >= 1) see no behavior
651 * change at all.
652 */
653function roundPrice(v: number): number {
654  const decimals = Math.abs(v) >= 1000 ? 0 : Math.abs(v) >= 1 ? 2 : 4
655  const f = 10 ** decimals
656  return Math.round(v * f) / f
657}
658
659function quoteRow(
660  sym: Ticker,
661  price: number,
662  prevClose: number,
663  bars?: Bar[],
664  wasPrice?: number,
665  amount?: number,
666): QuoteRow {
667  const change = roundPrice(price - prevClose)
668  // the old number measured against the same close, so only the price moved
669  const wasChange = wasPrice === undefined ? 0 : roundPrice(wasPrice - prevClose)
670  return {
671    code: sym.code,
672    name: sym.name,
673    price,
674    change,
675    pct: prevClose ? (change / prevClose) * 100 : 0,
676    prevClose,
677    // a Client's props must not hold undefined: the engine rejects the whole
678    // tree and draws nothing. Rows without K bars omit the key instead.
679    ...(bars ? { bars } : {}),
680    ...(amount !== undefined ? { amount } : {}),
681    // a row that did not move has nothing to turn, and turning it anyway is
682    // noise: a real board only flaps what changed
683    ...(wasPrice !== undefined && wasPrice !== price
684      ? { was: { price: wasPrice, change: wasChange, pct: prevClose ? (wasChange / prevClose) * 100 : 0 } }
685      : {}),
686  }
687}
688
689// --- optional config / quotes files ----------------------------------------
690type FileQuote = { price: number; prevClose?: number; name?: string; bars?: Bar[]; amount?: number }
691
692// A holding as the holdings file or `stock-band.json`'s `holdings` block
693// states it - `price`/`prevClose` are optional because the live feed usually
694// covers them; `pricedHolding` below fills in whatever this leaves out.
695type Holding = { code: string; name: string; qty: number; cost: number; price?: number; prevClose?: number }
696// A holding once register.tsx has resolved a price for it - board.tsx (the
697// 損益 view) only formats these, it never falls back to anything itself.
698type PricedHolding = {
699  code: string
700  name: string
701  qty: number
702  cost: number
703  price: number
704  prevClose: number
705  /**
706   * what this holding said before the last update - same idea as
707   * QuoteRow.was, deliberately just as thin: only `price` carries real old
708   * data, board.tsx recomputes was-side 今日%/今日損益/總損益/損益% from it
709   * using the CURRENT cost/qty/prevClose (assumed stable within a tick),
710   * exactly how quoteRow() derives `was.change`/`was.pct` from `was.price`
711   * alone. `code`/`name` are only set on a page/sort turn (pricedForDisplay
712   * in buildProps), when the row's OCCUPANT changed, not its price.
713   */
714  was?: { price: number; code?: string; name?: string }
715}
716
717/** which pnl column the 損益 view is sorted by - board.tsx only marks the active header */
718type PnlSortKey = 'code' | 'today' | 'todayPnl' | 'totalPnl' | 'totalPnlPct'
719const PNL_SORT_KEYS: PnlSortKey[] = ['code', 'today', 'todayPnl', 'totalPnl', 'totalPnlPct']
720const PNL_SORT_LABELS: Record<PnlSortKey, string> = {
721  code: '代號',
722  today: '今日%',
723  todayPnl: '今日損益',
724  totalPnl: '總損益',
725  totalPnlPct: '損益%',
726}
727
728/**
729 * Sorts the whole priced list by one column - register.tsx does this, not
730 * board.tsx, because the sort has to hold across the page boundary (a
731 * holding on page 2 by rank has to STAY on page 2 after paging back to it,
732 * which only works if the array board.tsx slices is already in final order).
733 */
734function sortHoldings(list: PricedHolding[], key: PnlSortKey, dir: 'asc' | 'desc'): PricedHolding[] {
735  const rank = (h: PricedHolding): number =>
736    key === 'today'
737      ? h.prevClose
738        ? (h.price / h.prevClose - 1) * 100
739        : 0
740      : key === 'todayPnl'
741        ? (h.price - h.prevClose) * h.qty
742        : key === 'totalPnl'
743          ? (h.price - h.cost) * h.qty
744          : h.cost
745            ? (h.price / h.cost - 1) * 100
746            : 0
747  const sign = dir === 'asc' ? 1 : -1
748  return [...list].sort((a, b) => (key === 'code' ? sign * a.code.localeCompare(b.code) : sign * (rank(a) - rank(b))))
749}
750
751type Config = {
752  market: MarketMode
753  refreshMs: number
754  /**
755   * undefined means "no explicit choice" - effectiveSort() then resolves it
756   * off MARKETS[market].defaultSort, so each market keeps its own default
757   * (crypto: marketcap, tw/us: change) instead of one hardcoded global value.
758   * See parseConfigRoot for how an explicit `"sort"` in the config file
759   * overrides this.
760   */
761  sort?: SortKey
762  highlight: boolean
763  /**
764   * how many symbols the table draws per row. `auto` picks off the page size:
765   * 5 or fewer draws the single-column table (代號/名稱/價格/變更$/變更%), 6 or
766   * more draws two symbols a row (代號/名稱/價格/變更% only). The board itself
767   * still falls back to 1 at render time if the terminal is too narrow for a
768   * readable half.
769   */
770  columns: ColumnMode
771  /**
772   * which markets the live feed prices. `auto` follows the band, so only the
773   * market on screen costs a request; `both` keeps the other side warm so a
774   * market switch shows real prices at once. `off` leaves the band on demo
775   * prices.
776   */
777  feed: 'auto' | 'us' | 'tw' | 'both' | 'off'
778  /**
779   * where Taiwan prices come from, in preference order - the band tries the
780   * first entry, and falls through to the next for THIS tick when the first
781   * has nothing fresh (shioaji: the quotes file is stale/absent while the
782   * script logs in, or never spawned at all; yahoo/mis: the request failed).
783   * `yahoo` is one batched request, ~20 minutes behind. `mis` is 證交所's own
784   * real-time snapshot, a backup route for whoever wants exchange-true
785   * intraday without a broker account. `shioaji` and `capital` hand Taiwan to
786   * a broker's own real-time feed instead: the band spawns
787   * `scripts/fetch-quotes-shioaji.py` / `scripts/fetch-quotes-capital.py`
788   * itself (see feedTwFetcher below) and reads back the quotes file it
789   * writes, rather than calling an HTTP endpoint the way the other two do.
790   * Those two are also platform-split, because their SDKs are: 永豐's shioaji
791   * is a POSIX-only Python package and the band spawns it with `nohup`;
792   * 群益's SKCOM is a Windows COM server. Listing the one this machine cannot
793   * run is harmless - it just never produces a fresh file, and the tick falls
794   * through to the next entry.
795   * The shipped default is `["yahoo"]` alone - the rest are opt-in,
796   * and the recommended place to opt in is the user-level
797   * `~/.claude/stock-band.json` (see CONFIG_PATH/USER_CONFIG below), not a
798   * shared project file, since a source order is a personal preference.
799   * A legacy `"twSource": "x"` (singular, a string) is still accepted as an
800   * alias for `["x"]` and nothing else, so an old config keeps working.
801   */
802  twSources: TwSourceName[]
803  /** seconds between feed requests, in ms; clamped to FEED_MS_MIN and up */
804  feedMs: number
805  /** how long one page of the watchlist holds before the board turns; 0 = manual only */
806  pageMs: number
807  /** the indices the footer flaps through on the Taiwan board (MIS route only) */
808  twIndices: TwIndex[]
809  /**
810   * `full` flaps and blinks on a 50 ms frame clock; `off` leaves the board
811   * still and repaints once a second for the countdown (and not at all if the
812   * countdown is off too). See docs: the measured cost of each is in the README.
813   */
814  animation: 'full' | 'off'
815  /** show how many seconds until the next feed request */
816  countdown: boolean
817  lists: Record<MarketId, Ticker[]>
818  /** `twSources` includes `"shioaji"` only - how the band runs the fetcher script itself */
819  shioaji: ShioajiConfig
820  /** `twSources` includes `"capital"` only - how the band runs the 群益 fetcher script itself */
821  capital: CapitalConfig
822  /**
823   * manual holdings, keyed by market - the alternative to
824   * `.claude/stock-holdings.json` (which wins for whichever market it names).
825   * See parseHoldings and the README's 損益 section.
826   */
827  holdings: Record<MarketId, Holding[]>
828  /**
829   * `"config"` makes the `holdings` block above win over the holdings file
830   * the broker script keeps writing - the way to show a demo portfolio on a
831   * band whose Taiwan route is a live brokerage. Default `"file"`.
832   */
833  holdingsSource: 'file' | 'config'
834  /** which of the three market-switch control styles the band draws; default `'select'` - see MarketSwitcher's own comment */
835  marketSwitcher: MarketSwitcher
836}
837
838type ShioajiConfig = {
839  /** interpreter to run the script with, e.g. the project's own venv python */
840  python: string
841  /** env file holding SINOBON_API_KEY / SINOBON_SECRET_KEY; `~` expands to $HOME */
842  env: string
843  /** seconds between snapshots the script writes */
844  interval: number
845}
846
847type CapitalConfig = {
848  /** interpreter to run the script with - must be the same bitness as the registered SKCOM 元件 */
849  python: string
850  /** env file holding CAPITAL_USER_ID / CAPITAL_PASSWORD; `~` expands to the home dir */
851  env: string
852  /**
853   * the registered `SKCOM.dll`, e.g.
854   * `~/CapitalAPI/元件/x64/SKCOM.dll`. There is no sane default: 群益 ships
855   * the SDK as a zip with no install location, and the script refuses to
856   * guess rather than fail three steps later with a COM error.
857   */
858  dll: string
859  /** seconds between snapshots the script writes */
860  interval: number
861  /**
862   * the footer's index rows on this route, as SKCOM 商品代號. 群益's manual
863   * documents no index codes, so the defaults were found by dumping
864   * `SKQuoteLib_RequestStockList` and checked against the exchange's own MIS
865   * feed (2026-09-18: TSEA 47004.27 vs t00 47001.67, OTCA 409.11 vs o00
866   * 409.12). `TSE01` is NOT 加權指數 - it is 水泥類股. The script still
867   * probes each code at startup and drops what does not resolve instead of
868   * writing a zero, and `--check` prints which ones answered. `[]` turns the
869   * index board off for this route.
870   */
871  indices: { code: string; name: string }[]
872}
873
874function asRecord(value: unknown): Record<string, unknown> | undefined {
875  return typeof value === 'object' && value !== null && !Array.isArray(value)
876    ? (value as Record<string, unknown>)
877    : undefined
878}
879
880function num(value: unknown, fallback: number): number {
881  return typeof value === 'number' && Number.isFinite(value) ? value : fallback
882}
883
884function str(value: unknown, fallback: string): string {
885  return typeof value === 'string' && value.length > 0 ? value : fallback
886}
887
888// a config entry only has to carry `code`; everything else falls back to the
889// built-in symbol of the same code, then to a plain default
890function parseList(value: unknown, builtin: Ticker[]): Ticker[] {
891  if (!Array.isArray(value)) return builtin
892  const out: Ticker[] = []
893  for (const raw of value) {
894    const entry = asRecord(raw)
895    if (!entry) continue
896    const code = str(entry.code, '')
897    if (!code) continue
898    const base = builtin.find(s => s.code === code)
899    const ex = entry.ex === 'otc' || entry.ex === 'tse' ? entry.ex : base?.ex
900    out.push({
901      code,
902      ...(ex ? { ex } : {}),
903      name: str(entry.name, base?.name ?? code),
904      prevClose: num(entry.prevClose, base?.prevClose ?? 100),
905      amp: num(entry.amp, base?.amp ?? 0.8),
906      phase: num(entry.phase, base?.phase ?? 0),
907      period: num(entry.period, base?.period ?? 57),
908      drift: num(entry.drift, base?.drift ?? 0),
909    })
910    // a longer list cannot be priced in one batched request, so it is cut here
911    // rather than silently half-fed further down
912    if (out.length >= MAX_SYMBOLS) break
913  }
914  return out.length > 0 ? out : builtin
915}
916
917function defaultConfig(): Config {
918  return {
919    market: 'auto',
920    refreshMs: DEFAULT_REFRESH_MS,
921    // no `sort` key here on purpose - see Config.sort's own comment. Each
922    // market names its own default (MARKETS[id].defaultSort) instead of one
923    // hardcoded value that would be wrong for either crypto or tw/us.
924    highlight: true,
925    columns: 'auto',
926    feed: 'auto',
927    twSources: ['yahoo'],
928    feedMs: FEED_MS_DEFAULT,
929    pageMs: PAGE_MS_DEFAULT,
930    twIndices: TW_INDICES,
931    animation: 'full',
932    countdown: true,
933    lists: { tw: TW_LIST, us: US_LIST, crypto: CRYPTO_LIST },
934    shioaji: { python: 'python3', env: '~/.sinobon.env', interval: 10 },
935    capital: {
936      python: 'python',
937      env: '~/.capital.env',
938      dll: '',
939      interval: 10,
940      indices: [
941        { code: 'TSEA', name: 'TAIEX' },
942        { code: 'OTCA', name: 'TPEx' },
943      ],
944    },
945    // crypto holdings have no broker-fetcher route (see feedCrypto) - the
946    // key only exists so Config.holdings stays a total Record<MarketId, ...>
947    // and a hand-written config can still opt in through the manual
948    // `holdings` block the same way tw/us do.
949    holdings: { tw: [], us: [], crypto: [] },
950    holdingsSource: 'file',
951    // `menu`, a header Button that opens a column of option Buttons below it
952    // (2026-09-19, at the user's request, replacing `select` as the
953    // default): `select`'s dropdown reads great but only the keyboard can
954    // pick an option out of it - the engine's own Select has no click path
955    // on an option, arrows-and-Enter only while it holds focus - and a
956    // mouse-first person had no way to land on a stop directly. `menu`'s
957    // options are plain Buttons, clickable the same way every other Button
958    // in this row already is, and it costs about the same columns closed as
959    // `select` did (see marketControlWidth's own `menu` branch) - only
960    // opening it costs more, and only downward, the same way `select`'s own
961    // dropdown already did.
962    //
963    // NOT `tabs`, measured on a real 100-column terminal (2026-09-19): the
964    // tabs row needs 30 columns, and the header line it shares already spends
965    // about 36 on the session state, the market hours and the Taipei
966    // restatement, on top of RIGHT_BUTTON_GROUP_COLS. That totals ~106, so
967    // tabs fits only a terminal wider than most, and at 100 it silently drops
968    // the Taipei hours instead. `tabs` stays available for a wide terminal,
969    // `cycle` for a narrow one, `select` for a keyboard-first person - and
970    // `cycle` is what `select` falls back to wherever the surface has no
971    // Select element (mobile); `menu` never falls back, since it needs
972    // nothing but Buttons. See MarketSwitcher.
973    marketSwitcher: 'menu',
974  }
975}
976
977/** resolves `"auto"` off the watchlist length; an explicit 1/2 always wins */
978function effectiveColumns(cfg: Config, listLength: number): 1 | 2 {
979  if (cfg.columns === 1 || cfg.columns === 2) return cfg.columns
980  return listLength > PAGE_SIZE_1COL ? 2 : 1
981}
982
983function pageSize(columns: 1 | 2): number {
984  return columns === 2 ? PAGE_SIZE_2COL : PAGE_SIZE_1COL
985}
986
987/** the markets one feed tick prices, given where the band is pointed right now */
988function feedMarkets(cfg: Config, market: MarketId): MarketId[] {
989  if (cfg.feed === 'off') return []
990  // `both` stays tw+us only - it predates crypto and means "keep both
991  // traditional markets warm for an instant switch", not "everything this
992  // config could ever show". Crypto still gets fetched whenever it is
993  // actually on screen, through the `auto` branch right below - `market`
994  // carries whatever pickMarket resolved, which is 'crypto' outright once
995  // `config.market` names it (see pickMarket's mode !== 'auto' branch).
996  if (cfg.feed === 'both') return ['tw', 'us']
997  if (cfg.feed === 'auto') return [market]
998  return [cfg.feed]
999}
1000
1001/** what one market costs per tick, before the chart view's own bar fetch is added */
1002function marketRequests(cfg: Config, market: MarketId): number {
1003  if (market === 'us') return Math.ceil((cfg.lists.us.length + US_INDICES.length) / SPARK_BATCH)
1004  // Pionex's ticker endpoint answers the whole exchange in one request
1005  // whatever the watchlist length - `symbol=A,B` does not batch (verified
1006  // 2026-09-18, see PIONEX_TICKERS_URL) so feedCrypto pulls everything and
1007  // filters locally instead of paying per symbol.
1008  if (market === 'crypto') return 1
1009  // The preferred route's own cost - shioaji costs this module no HTTP
1010  // requests at all, so it is estimated as Yahoo's (the likely fallback,
1011  // and a safe overestimate for the budget floor below).
1012  // MIS answers the whole list plus both indices in one call, whatever the
1013  // list length - there is no sparkline column left to pay Yahoo for on top.
1014  if (cfg.twSources[0] === 'mis') return 1
1015  return Math.ceil((cfg.lists.tw.length + 1) / SPARK_BATCH)
1016}
1017
1018/**
1019 * How many requests one feed tick costs, at its worst. `auto` prices one
1020 * market at a time, so it costs the dearer of the two rather than the sum;
1021 * `both` really does pay for both. Crypto is folded into the `auto`/pinned
1022 * max here for completeness (feedMarkets already routes to it whenever
1023 * `market` names it - see feedMarkets), even though its cost is a fixed 1
1024 * and so never actually changes which side of the max wins.
1025 */
1026function requestsPerTick(cfg: Config): number {
1027  if (cfg.feed === 'off') return 0
1028  const tw = marketRequests(cfg, 'tw')
1029  const us = marketRequests(cfg, 'us')
1030  const crypto = marketRequests(cfg, 'crypto')
1031  return cfg.feed === 'both' ? tw + us : cfg.feed === 'tw' ? tw : cfg.feed === 'us' ? us : Math.max(tw, us, crypto)
1032}
1033
1034/**
1035 * `feedMs` is an interval, and an interval alone does not bound the request
1036 * rate: 15 s with a 20-symbol Yahoo-fed list is 240 requests an hour against a
1037 * ceiling around 360, and `both` doubles that. The floor here turns the
1038 * budget into an interval, so no config can get the host banned.
1039 */
1040function feedInterval(cfg: Config): number {
1041  const budgetFloor = Math.ceil((requestsPerTick(cfg) * 3_600_000) / REQUESTS_PER_HOUR)
1042  return Math.max(cfg.feedMs, FEED_MS_MIN, budgetFloor)
1043}
1044
1045/** reads text as a JSON object, or undefined for anything that is not one - malformed, missing, or a non-object */
1046function parseJsonRecord(text: string | undefined): Record<string, unknown> | undefined {
1047  if (!text) return undefined
1048  try {
1049    return asRecord(JSON.parse(text) as unknown)
1050  } catch {
1051    return undefined
1052  }
1053}
1054
1055/**
1056 * `capital.indices` -> the `{code, name}` rows the 群益 fetcher gets on its
1057 * `--indices` flag. A row with no `code` is dropped rather than passed on as
1058 * an empty symbol the SDK would silently ignore; `name` falls back to the
1059 * code so the footer never flaps a blank drum.
1060 */
1061function parseCapitalIndices(value: unknown[]): { code: string; name: string }[] {
1062  const out: { code: string; name: string }[] = []
1063  for (const raw of value) {
1064    const entry = asRecord(raw)
1065    const code = str(entry?.code, '')
1066    if (!code) continue
1067    out.push({ code, name: str(entry?.name, code) })
1068  }
1069  return out
1070}
1071
1072const TW_SOURCE_NAMES: TwSourceName[] = ['shioaji', 'capital', 'yahoo', 'mis']
1073
1074/**
1075 * `twSources` in preference order, or the legacy singular `twSource` as an
1076 * alias for a one-entry list, or `fallback` (defaultConfig's `["yahoo"]`,
1077 * carried in by an earlier, lower-precedence root) when the current root
1078 * states neither. An array present but empty, or holding nothing valid,
1079 * still counts as "stated" and clears the fallback rather than ignoring it -
1080 * same as every other field here, the most specific root wins outright.
1081 */
1082function parseTwSources(root: Record<string, unknown>, fallback: TwSourceName[]): TwSourceName[] {
1083  const isSourceName = (v: unknown): v is TwSourceName => TW_SOURCE_NAMES.includes(v as TwSourceName)
1084  if (Array.isArray(root.twSources)) return root.twSources.filter(isSourceName)
1085  if (isSourceName(root.twSource)) return [root.twSource]
1086  return fallback
1087}
1088
1089/**
1090 * Turns one parsed config root (a user-level file, a project file, or the
1091 * merged root `poll()` builds from both - see USER_CONFIG_REL and
1092 * CONFIG_PATH) into a `Config`, filling in `defaultConfig()` for every key
1093 * the root does not set.
1094 */
1095function parseConfigRoot(root: Record<string, unknown> | undefined): Config {
1096  const cfg = defaultConfig()
1097  if (!root) return cfg
1098  const market = str(root.market, 'auto')
1099  if (market === 'tw' || market === 'us' || market === 'crypto' || market === 'auto') cfg.market = market
1100  cfg.refreshMs = Math.max(1000, num(root.refreshMs, cfg.refreshMs))
1101  // any of the four is an explicit choice and overrides the per-market
1102  // default outright, same as every other field here - an absent/invalid
1103  // `sort` leaves cfg.sort unset, so effectiveSort() falls through to
1104  // MARKETS[market].defaultSort instead.
1105  if (root.sort === 'change' || root.sort === 'list' || root.sort === 'marketcap' || root.sort === 'volume') {
1106    cfg.sort = root.sort
1107  }
1108  if (root.highlight === false) cfg.highlight = false
1109  if (root.columns === 1 || root.columns === 2 || root.columns === 'auto') cfg.columns = root.columns
1110  const feed = root.feed
1111  if (feed === 'off' || feed === false) cfg.feed = 'off'
1112  else if (feed === 'auto' || feed === 'us' || feed === 'tw' || feed === 'both') cfg.feed = feed
1113  cfg.twSources = parseTwSources(root, cfg.twSources)
1114  const shioaji = asRecord(root.shioaji)
1115  if (shioaji) {
1116    cfg.shioaji = {
1117      python: str(shioaji.python, cfg.shioaji.python),
1118      env: str(shioaji.env, cfg.shioaji.env),
1119      interval: Math.max(0, num(shioaji.interval, cfg.shioaji.interval)),
1120    }
1121  }
1122  const capital = asRecord(root.capital)
1123  if (capital) {
1124    cfg.capital = {
1125      python: str(capital.python, cfg.capital.python),
1126      env: str(capital.env, cfg.capital.env),
1127      dll: str(capital.dll, cfg.capital.dll),
1128      interval: Math.max(0, num(capital.interval, cfg.capital.interval)),
1129      // an array present but empty means "no index rows", the same way an
1130      // empty twSources means "nothing stated but yahoo" - so this only
1131      // falls back to the defaults when the key is absent entirely
1132      indices: Array.isArray(capital.indices) ? parseCapitalIndices(capital.indices) : cfg.capital.indices,
1133    }
1134  }
1135  cfg.feedMs = Math.max(FEED_MS_MIN, num(root.feedMs, cfg.feedMs))
1136  // 0 turns auto-paging off and leaves the `p` button as the only way to page
1137  const pageMs = num(root.pageMs, cfg.pageMs)
1138  cfg.pageMs = pageMs <= 0 ? 0 : Math.max(PAGE_MS_MIN, pageMs)
1139  if (root.animation === 'off' || root.animation === false) cfg.animation = 'off'
1140  if (root.countdown === false) cfg.countdown = false
1141  cfg.lists = {
1142    tw: parseList(root.tw, TW_LIST),
1143    us: parseList(root.us, US_LIST),
1144    crypto: parseList(root.crypto, CRYPTO_LIST),
1145  }
1146  cfg.twIndices = parseTwIndices(root.twIndices)
1147  const holdings = asRecord(root.holdings)
1148  cfg.holdings = {
1149    tw: parseHoldingsList(holdings?.tw),
1150    us: parseHoldingsList(holdings?.us),
1151    crypto: parseHoldingsList(holdings?.crypto),
1152  }
1153  if (root.holdingsSource === 'config') cfg.holdingsSource = 'config'
1154  const marketSwitcher = root.marketSwitcher
1155  if (marketSwitcher === 'tabs' || marketSwitcher === 'select' || marketSwitcher === 'cycle' || marketSwitcher === 'menu') {
1156    cfg.marketSwitcher = marketSwitcher
1157  } // anything else (including the default '貓'-style typo) keeps defaultConfig()'s 'menu'
1158  return cfg
1159}
1160
1161/**
1162 * The manual alternative to `.claude/stock-holdings.json`: a `holdings` block
1163 * in `stock-band.json`, `{ tw: [...], us: [...] }`. `code` and `qty` are the
1164 * only fields that matter for the P&L math; `name` falls back to the code and
1165 * a bad or missing `qty`/`cost` reads as 0 rather than dropping the row, so a
1166 * typo shows up as an obviously wrong number instead of a silently missing
1167 * holding.
1168 */
1169function parseHoldingsList(value: unknown): Holding[] {
1170  if (!Array.isArray(value)) return []
1171  const out: Holding[] = []
1172  for (const raw of value) {
1173    const entry = asRecord(raw)
1174    if (!entry) continue
1175    const code = str(entry.code, '')
1176    if (!code) continue
1177    out.push({
1178      code,
1179      name: str(entry.name, code),
1180      qty: num(entry.qty, 0),
1181      cost: num(entry.cost, 0),
1182      price: typeof entry.price === 'number' ? entry.price : undefined,
1183      prevClose: typeof entry.prevClose === 'number' ? entry.prevClose : undefined,
1184    })
1185  }
1186  return out
1187}
1188
1189/**
1190 * The footer's Taiwan index rows. A channel the exchange does not know simply
1191 * answers nothing and `publish` leaves that row out, so a typo costs one
1192 * missing row rather than the whole footer. `name` has to be Latin: the board
1193 * flaps a row one character at a time and a Chinese character has no drum to
1194 * riffle through, so a Chinese name would sit there unable to turn.
1195 */
1196function parseTwIndices(value: unknown): TwIndex[] {
1197  if (!Array.isArray(value)) return TW_INDICES
1198  const out: TwIndex[] = []
1199  for (const raw of value) {
1200    const entry = asRecord(raw)
hooks/board.tsx 1458 lines
1/* @jsx h */
2import type { ClientElements, ClientSurface, RenderNode } from 'claude-code'
3
4type TextTag = ClientElements['Text']
5
6// The stock band's Client board: pure drawing, on its own frame clock
7// (surface.every), independent of the hooks module. Quotes, market session and
8// the market-local clock all arrive as props from hooks/register.tsx - this
9// file never decides what a price is, it only lays the table out.
10//
11// Column layout, colors, badge scheme and the red/green conventions are ported
12// from prototype/stock-band-demo.py (repo root) - read that first if a number
13// here looks arbitrary. Never name a local variable `h`: every JSX tag in this
14// file compiles to h(...).
15
16export type MarketId = 'tw' | 'us' | 'crypto'
17export type Phase = 'open' | 'closed'
18export type View = 'table' | 'chart' | 'pnl'
19
20/** [open, high, low, close] */
21export type Bar = [number, number, number, number]
22
23export type QuoteRow = {
24  code: string
25  name: string
26  price: number
27  change: number
28  pct: number
29  prevClose: number
30  /** K bars, oldest first; only the focused symbol (the chart view) carries these */
31  bars?: Bar[]
32  /**
33   * what this row said before the last update; absent when nothing moved.
34   * `code`/`name` only appear on a page turn, when the slot changed symbol.
35   */
36  was?: { price: number; change: number; pct: number; code?: string; name?: string }
37  /**
38   * the market has a live/override snapshot, but it never priced this code -
39   * not the same as "no change" (pct 0). Drawn as a dim placeholder instead
40   * of the price/change/pct fields (see drawTwoColQuote and the table loop).
41   */
42  noData?: boolean
43}
44
45/** a holding, already priced by register.tsx - the 損益 view only formats these */
46export type Holding = {
47  code: string
48  name: string
49  qty: number
50  cost: number
51  price: number
52  prevClose: number
53  /**
54   * what this holding said before the last update - same idea as
55   * QuoteRow.was and deliberately as thin: only `price` is real old data;
56   * was-side 今日%/今日損益/總損益/損益% are derived from it using the
57   * CURRENT cost/qty/prevClose, the same way the table derives
58   * `was.change`/`was.pct` from `was.price` alone. `code`/`name` only
59   * appear on a page/sort turn, when the row's occupant changed.
60   */
61  was?: { price: number; code?: string; name?: string }
62}
63
64/** which pnl column `holdings` is sorted by - register.tsx does the actual sort, board only marks the header */
65export type PnlSortKey = 'code' | 'today' | 'todayPnl' | 'totalPnl' | 'totalPnlPct'
66
67export type BoardProps = {
68  market: MarketId
69  marketLabel: string
70  phase: Phase
71  /** trading hours when open, "下次開盤 ..." when closed */
72  sessionNote: string
73  /** the same hours in Taipei time, '' when the market already trades on it */
74  taipeiNote: string
75  /** market-local HH:MM:SS, formatted in the hooks module (the board has no $) */
76  clock: string
77  quotes: QuoteRow[]
78  index: { name: string; value: number; change: number; pct: number }
79  /** what the footer flips through; one entry means it just sits there */
80  indices: { name: string; value: number; change: number; pct: number }[]
81  /** 'demo' = faked prices (no API), 'file' = a quotes file, 'live' = the feed */
82  source: 'demo' | 'file' | 'live'
83  /** what the footer calls the source; '' falls back to naming it from `source` */
84  sourceLabel: string
85  /** the plugin's version, e.g. `v0.4.1`; '' hides it */
86  version: string
87  /** snapshot counter; the live dot flips on it (0 while faking prices) */
88  seq: number
89  highlight: boolean
90  sorted: boolean
91  /**
92   * 1 = the single-column table (代號/名稱/價格/變更$/變更%, up to 5 rows); 2 =
93   * two symbols per row (代號/名稱/價格/變更% only - 變更$ has no room), filled
94   * column-major off the current sort so the left column is the top half of
95   * the page and the right column the bottom half. register.tsx resolves
96   * `"auto"` to one of these before the board ever sees it; the board itself
97   * still falls back to 1 at render time if the terminal is too narrow for a
98   * readable half (see `fitsTwoColumns`).
99   */
100  columns: 1 | 2
101  /** 'table' = the watchlist, 'chart' = one symbol's K bars */
102  view: View
103  /** which row the chart view is showing */
104  focus: number
105  /** what the bars are, e.g. "5 分 K" - the feed decides, so it is a string */
106  barLabel: string
107  /** market-local session bounds, "HH:MM", for the chart's time axis */
108  sessionOpen: string
109  sessionClose: string
110  /** bumped on a new snapshot or a page change; what starts a row turn */
111  turn: number
112  /** which page of the watchlist this is, and how many there are */
113  page: number
114  pageCount: number
115  /** epoch ms of the next feed request; 0 when nothing is fetching */
116  nextFeedAt: number
117  /** 'off' leaves the board still - see the README's cost table */
118  animation: 'full' | 'off'
119  countdown: boolean
120  now: number
121  /**
122   * `view: "pnl"` only - already priced and merged by register.tsx (live
123   * quote first, the holdings file's own price/prevClose otherwise; see
124   * pricedHoldings). board.tsx never looks anything up itself, it only
125   * formats what arrives here and pages through it 5 at a time.
126   */
127  holdings: Holding[]
128  /** what the pnl view's title calls the source, e.g. "永豐 庫存" */
129  holdingsSource: string
130  /** epoch ms the holdings snapshot was taken, or the matching watchlist quote's time when it has none */
131  holdingsAt: number
132  /** already applied to `holdings` by register.tsx - board only marks the active header cell */
133  pnlSortKey: PnlSortKey
134  pnlSortDir: 'asc' | 'desc'
135  /** the first data row on screen, 0-based - a wheel tick moves it, 翻頁 by whole pages; see register.tsx's pnlScroll */
136  holdingsScroll: number
137}
138
139// `turn` is the change the rows last turned for, and `since` is when that turn
140// started. The hook's props say what the numbers are; only the board knows when
141// it noticed them change, which is what the row animation is timed off.
142// `flipMs` and `nextFeedAt` ride along because the frame timer runs outside the
143// render and has nothing else to read them from.
144type State = {
145  frame: string
146  turn: number
147  since: number
148  flipMs: number
149  nextFeedAt: number
150  /**
151   * whether the live dot is on the 500 ms pulse. On real quotes it advances
152   * once per snapshot instead, and counting the pulse into the frame id then
153   * costs two repaints a second to draw a character that did not change.
154   */
155  blinks: boolean
156}
157
158// --- colors (prototype/stock-band-demo.py) ---------------------------------
159const UP_GREEN = '#3fb950'
160const DOWN_RED = '#e5534b'
161const FLAT = '#9aa0a6'
162const GRAY = '#808080'
163const DIM = '#6e7681'
164const HEAD = '#a6aebb'
165const SYMBOL = '#79a8ff' // the blue ticker links in the reference screenshot
166const RULE = '#2d333b'
167const ORANGE = '#d97757'
168const WHITE = '#f0f3f6'
169const SUN = '\u2600'
170const MOON = '\u263d'
171const ROW_HILIGHT = '#1b2436' // selected-row band, like the reference screenshot
172
173const PULSE_MS = 500 // the live dot's on/off period
174
175// --- the index board: a Solari split-flap -----------------------------------
176// A real airport board does not fold a character in half - every flap carries a
177// fixed set of whole characters on a drum, and a flap turning from one to
178// another riffles through everything in between. So every frame here holds real
179// characters, which is exactly what a terminal cell can draw. Flaps further
180// right start later, and that lag is what makes the wave sweep left to right.
181//
182// A character its drum does not carry is painted on a real board, not flapped -
183// the comma, the decimal point, the brackets and the arrow stay put, which is
184// also what keeps digits from riffling through letters.
185const ANIM_TICK_MS = 50 // how often the frame is re-derived, not how often it draws
186const HOLD_MS = 5000
187const FLAP_MS = 28 // one flap step
188const STAGGER = 1 // flaps a column waits behind the column to its left
189const MAX_FLAPS = 12 // longest riffle a single flap takes, so the wave stays brisk
190// The wave front. It covers every column it passes, including the ones whose
191// character does not change: a front drawn only where the text differs comes
192// out full of holes and reads as noise, not as a sweep.
193const EDGE = ['█', '▓']
194const ROW_STAGGER = 2 // flaps each quote row waits behind the row above it
195// A page turn has no sweep inside a row - every column lands together - so the
196// cascade has to come from the rows, and they lag each other further apart.
197const PAGE_ROW_STAGGER = 5
198// A price update turns three numbers and the wave crosses from the price column
199// to the right edge, which takes most of this. A page turn's row lands in
200// EDGE + MAX_FLAPS steps, and the last row starts 4 * PAGE_ROW_STAGGER behind
201// the first: (2 + 12 + 20) * 28 ms, rounded up so the last flap is never cut.
202const FLIP_MS = 1450
203const PAGE_FLIP_MS = 1100
204const CYCLE_MS = HOLD_MS + FLIP_MS
205const RESTING = Number.MAX_SAFE_INTEGER
206// what the band signs itself with, bottom right
207const CREDIT = 'darrell_tw_'
208
209// no blank on the numeric drum: a space riffling through the middle of a price
210// reads as a glitch, not as a flap. The leading pad a shorter number sits in is
211// a space against a digit, which has no journey and simply swaps.
212const NUM_DRUM = '0123456789'
213const TEXT_DRUM = ' ABCDEFGHIJKLMNOPQRSTUVWXYZ&.0123456789'
214
215type Anim = {
216  /** which index the cycle is settling on */
217  slot: number
218  /** how many steps into the turn, or RESTING while the card sits */
219  flap: number
220  /** the live dot's on/off phase */
221  blink: number
222}
223
224function animState(t: number): Anim {
225  const phase = t % CYCLE_MS
226  return {
227    slot: Math.floor(t / CYCLE_MS),
228    flap: phase < FLIP_MS ? Math.floor(phase / FLAP_MS) : RESTING,
229    blink: Math.floor(t / PULSE_MS),
230  }
231}
232
233/**
234 * one flap `step` turns into its journey from `from` to `to`.
235 *
236 * `together` makes every flap take the same number of steps whatever its
237 * journey, so a whole row lands on one frame instead of settling left to right.
238 * That is what a page turn needs: while the symbol column has settled and the
239 * price column has not, the board is showing one company's name against
240 * another's price, and half a second of that is a lie about a stock price.
241 */
242function flapChar(from: string, to: string, drum: string, step: number, together: boolean): string {
243  if (from === to) return to
244  const i = drum.indexOf(from)
245  const j = drum.indexOf(to)
246  if (i < 0 || j < 0) return step >= 0 ? to : from // painted, not flapped
247  if (step < 0) return from
248  const full = (j - i + drum.length) % drum.length
249  // a long journey starts part way round, so one flap cannot hold up the wave
250  const distance = together ? MAX_FLAPS : Math.min(full, MAX_FLAPS)
251  if (step >= distance) return to
252  // `distance` can be longer than the drum - MAX_FLAPS is 12 and the numeric
253  // drum holds 10 - so this has to wrap both ways. A bare `% length` on a
254  // negative start indexes off the front of the string and puts the word
255  // `undefined` on the board.
256  const start = (((j - distance) % drum.length) + drum.length) % drum.length
257  return drum[(start + step) % drum.length]
258}
259
260/** how many steps a field takes to settle, given its per-column lag */
261function flapSpan(width: number, stagger: number): number {
262  return width * stagger + EDGE.length + MAX_FLAPS
263}
264
265/**
266 * a whole field mid-turn; `flap` is already offset by where the field starts.
267 * `stagger` 0 turns the whole field at once - see flapChar's `together`.
268 */
269function flapField(from: string, to: string, drum: string, flap: number, stagger = STAGGER): string {
270  // past the last flap's longest journey the field has settled
271  if (flap >= flapSpan(to.length, stagger)) return to
272  const together = stagger === 0
273  let out = ''
274  for (let i = 0; i < to.length; i++) {
275    const step = flap - i * stagger
276    if (step < 0) out += from[i] ?? ' '
277    else if (step < EDGE.length) out += EDGE[step]
278    else out += flapChar(from[i] ?? ' ', to[i], drum, step - EDGE.length, together)
279  }
280  return out
281}
282
283/**
284 * display columns [from, to) of `text`. A wide character the range cuts in half
285 * cannot be drawn as a half, so it comes back as spaces - which is what keeps a
286 * wipe over Chinese names from shifting every column to its right.
287 */
288function sliceCols(text: string, from: number, to: number): string {
289  let out = ''
290  let col = 0
291  for (const ch of Array.from(text)) {
292    const start = col
293    const end = col + charWidth(ch)
294    col = end
295    if (end <= from || start >= to) continue
296    if (start >= from && end <= to) out += ch
297    else out += ' '.repeat(Math.min(end, to) - Math.max(start, from))
298  }
299  return out
300}
301
302/**
303 * A field that cannot flap, turning by wipe instead: the same block front
304 * sweeps across, the new text is behind it and the old text ahead of it. A
305 * Chinese name has no drum to riffle through - there is no journey from 台 to
306 * 鴻 - so the front is the whole animation, and it still reads as one board
307 * turning because it is the same front the flapped fields use.
308 */
309function wipeField(from: string, to: string, width: number, step: number, span: number): string {
310  if (step >= span) return padRight(to, width)
311  if (step < 0) return padRight(from, width)
312  // The front crosses the field in `span` steps whatever the field is wide, so
313  // a three-letter name does not resolve long before the flaps beside it. The
314  // -1 keeps one column covered until the very last step: a name that clears
315  // early sits a real company against digits still riffling towards its price.
316  const flap = Math.floor((step * (width + EDGE.length - 1)) / span)
317  const settled = Math.min(width, Math.max(0, flap - EDGE.length + 1))
318  let out = sliceCols(padRight(to, width), 0, settled)
319  for (let c = settled; c < Math.min(width, flap + 1); c++) out += EDGE[flap - c]
320  out += sliceCols(padRight(from, width), Math.min(width, Math.max(0, flap + 1)), width)
321  return out
322}
323
324function padRight(text: string, width: number): string {
325  return text + ' '.repeat(Math.max(0, width - dispWidth(text)))
326}
327
328function padLeft(text: string, width: number): string {
329  return ' '.repeat(Math.max(0, width - dispWidth(text))) + text
330}
331
332type IndexRow = { name: string; value: number; change: number; pct: number }
333type Field = { text: string; fg: string; drum: string }
334
335/**
336 * An index card as the fields the board flaps. Every card is laid out to the
337 * same widths, so a comma sits under a comma and a bracket under a bracket:
338 * those are the painted flaps, and they only stay still if they line up.
339 */
340// The parenthetical (+1.16%) came off the index card too - the footer's job
341// is 加權指數 24,329.53 ▲ +277.83 at a glance, and the % duplicated the pct
342// column every quote row already carries.
343function cardFields(idx: IndexRow, w: number[], market: MarketId): Field[] {
344  const arrow = idx.change > 0 ? '▲' : idx.change < 0 ? '▼' : '-'
345  const fg = tone(market, idx.change)
346  return [
347    { text: padRight(idx.name, w[0]), fg: DIM, drum: TEXT_DRUM },
348    { text: padLeft(thousands(idx.value), w[1]), fg: WHITE, drum: NUM_DRUM },
349    { text: arrow, fg, drum: '' },
350    { text: padLeft(signed(idx.change), w[2]), fg, drum: NUM_DRUM },
351  ]
352}
353
354/** the widest each field gets across every card, so the layout never shifts */
355function cardWidths(board: IndexRow[]): number[] {
356  const wide = (f: (i: IndexRow) => string) => Math.max(...board.map(i => dispWidth(f(i))))
357  return [wide(i => i.name), wide(i => thousands(i.value)), wide(i => signed(i.change))]
358}
359
360/**
361 * A right-aligned number mid-turn. Both texts are padded to one width, so the
362 * column cannot jitter while the digits change length. A `turn` past this
363 * field's own journey answers the settled text, which is the path every row
364 * takes between updates.
365 */
366function flapRight(
367  row: Row,
368  right: number,
369  from: string,
370  to: string,
371  fg: string,
372  turn: number,
373  left: number,
374  stagger = STAGGER,
375) {
376  const width = Math.max(dispWidth(from), dispWidth(to))
377  // with no per-column lag the field does not wait for the front to reach it:
378  // the whole row turns as one, which is what keeps the columns consistent
379  const lead = stagger === 0 ? 0 : (right - width - left) * stagger
380  const text = flapField(padLeft(from, width), padLeft(to, width), NUM_DRUM, turn - lead, stagger)
381  row.putRight(right, text, fg)
382}
383
384type TailPiece = { text: string; fg: string }
385
386/** clear columns a footer tail leaves between itself and the index block */
387const TAIL_GAP = 3
388
389/**
390 * The table footer's right-hand end: the clock (bare when the market is open
391 * - 更新 said nothing the position on the row did not already say - 收盤
392 * HH:MM when it is not), its live dot, the feed countdown, the source tag,
393 * and the credit sign-off. `darrell_tw_` is a normal part of this footer now,
394 * not the first thing a narrow terminal drops - so a tight row drops the
395 * countdown first, then the credit, then the clock and dot, and keeps the
396 * source tag as the one thing that never goes. The dot needs its own color
397 * (ORANGE, not DIM), which is why this builds a run of colored pieces rather
398 * than one `putRightIfFits` string the way `signOff` (the chart footer's
399 * simpler tail) still does.
400 */
401function putFooterTail(
402  row: Row,
403  right: number,
404  stampCore: string,
405  dotChar: string,
406  countdownText: string,
407  sourceTag: string,
408  sourceTagShort: string,
409): void {
410  const clockDot: TailPiece[] = [
411    { text: stampCore, fg: DIM },
412    ...(dotChar ? [{ text: ` ${dotChar}`, fg: ORANGE }] : []),
413  ]
414  const withCountdown = (base: TailPiece[]): TailPiece[] => [...base, { text: countdownText, fg: DIM }]
415  const withSource = (base: TailPiece[]): TailPiece[] => [...base, { text: ` ${sourceTag}`, fg: DIM }]
416  const withShortSource = (base: TailPiece[]): TailPiece[] => [...base, { text: ` ${sourceTagShort}`, fg: DIM }]
417  const withCredit = (base: TailPiece[]): TailPiece[] => [...base, { text: ` · ${CREDIT}`, fg: DIM }]
418  // Shorten the source tag before dropping anything, then drop the countdown,
419  // then the credit, then the clock and dot - a source tag is the one thing
420  // left standing on the tightest row. Every rung but the last has to leave
421  // TAIL_GAP columns between the index block and the tail: a tail that lands
422  // one column from the index reads as one crowded run of numbers, which is
423  // the thing the footer was rebuilt to stop.
424  const ladder: TailPiece[][] = [
425    withCredit(withSource(withCountdown(clockDot))),
426    withCredit(withShortSource(withCountdown(clockDot))),
427    withCredit(withSource(clockDot)),
428    withCredit(withShortSource(clockDot)),
429    withShortSource(clockDot),
430    [{ text: sourceTagShort, fg: DIM }],
431  ]
432  for (const [rung, pieces] of ladder.entries()) {
433    const gap = rung === ladder.length - 1 ? 1 : TAIL_GAP
434    const width = pieces.reduce((w, p) => w + dispWidth(p.text), 0)
435    if (right - width < row.width() + gap) continue
436    let col = right - width
437    for (const p of pieces) {
438      row.put(col, p.text, p.fg)
439      col += dispWidth(p.text)
440    }
441    return
442  }
443}
444
445/** how far into its turn the quote rows are; RESTING once they have settled */
446function rowFlap(t: number, since: number, flipMs: number): number {
447  const age = t - since
448  return age >= 0 && age < flipMs ? Math.floor(age / FLAP_MS) : RESTING
449}
450
451/** whole seconds until the next feed request; -1 when nothing is fetching */
452function secondsToFeed(t: number, nextFeedAt: number): number {
453  if (!nextFeedAt) return -1
454  // a clock skew between the hooks module and this surface must not print a
455  // wild number, so the countdown is clamped to something a feed could mean
456  return Math.max(0, Math.min(999, Math.ceil((nextFeedAt - t) / 1000)))
457}
458
459/** what the screen shows right now; equal ids mean there is nothing to repaint */
460function frameId(t: number, st: State): string {
461  const a = animState(t)
462  const blink = st.blinks ? a.blink % 2 : 0
463  return `${a.slot}:${a.flap}:${blink}:${rowFlap(t, st.since, st.flipMs)}:${secondsToFeed(t, st.nextFeedAt)}`
464}
465
466/** the same id with every animation stilled: only the countdown moves it */
467function stillFrameId(t: number, st: State): string {
468  return `still:${secondsToFeed(t, st.nextFeedAt)}`
469}
470
471// The frame timer is per instance, not per module: surface.every lives "until
472// the returned function is called or the instance unmounts", and the instance
473// is dropped whenever the band leaves the tree - pressing 收起 30分 is enough -
474// or when a throw or a time-budget overrun unmounts it. Keeping the "already
475// started" flag in a module-level variable therefore outlived the timer it was
476// tracking: the next instance read tickMs === 50, skipped the install, and ran
477// with no frame clock at all. The board then only redrew when the hooks module
478// pushed new props every 3 s, so a one-second page turn showed one frozen
479// mid-flip frame instead of twenty - the animation looked broken when what was
480// actually missing was the redraws.
481//
482// A WeakMap keyed on the surface ties the flag to the same lifetime as the
483// timer, so a remounted instance starts its own clock and a dropped one takes
484// its entry with it.
485type FrameClock = { ms: number; cancel: () => void }
486const frameClocks = new WeakMap<object, FrameClock>()
487
488// Clicking a quote opens its trend chart. A Client has no Button, so the board
489// hit-tests the pointer itself: the render writes down which quote each cell
490// belongs to, and the listener reads that map. The two are split because they
491// live on different clocks - the listener is installed once per instance and
492// outlives every page turn under it, so it must not close over one render's
493// rows or a click would open the symbol that used to be there.
494/** what a click posts back to register.tsx's ui.message hook - a table row, or a pnl header cell */
495type PickResult = { pick: number } | { sortPnl: PnlSortKey }
496type Picker = { hit: (x: number, y: number) => PickResult | undefined }
497const pickers = new WeakMap<object, Picker>()
498
499// The table view lost its title row: the market name, session state and
500// hours moved into the button row register.tsx draws above this Client (that
501// row also carries the market button now), so the table starts straight at
502// the header. The chart view keeps its own title row - it names the symbol
503// being charted, not the market, so it stays inside the board.
504const TABLE_ROWS = 8 // header, rule, 5 quote rows, footer
505const CHART_ROWS = 8 // title, 5 candle rows, axis, footer - the same height
506const PNL_ROWS = 8 // title, header, 5 holding rows, totals - the same height
507const PNL_PAGE_SIZE = 5
508// as the table, so opening a chart no longer pushes the transcript up a line
509const TABLE_QUOTE_ROWS = 5 // rows in the quote area, in single- or two-column mode
510const MAX_TABLE_QUOTES = TABLE_QUOTE_ROWS * 2 // two-column mode holds 2 symbols a row
511
512// --- display width (east-asian-wide chars count as 2) ----------------------
513function charWidth(ch: string): number {
514  const cp = ch.codePointAt(0) ?? 0
515  const wide =
516    (cp >= 0x1100 && cp <= 0x115f) ||
517    (cp >= 0x2e80 && cp <= 0xa4cf) ||
518    (cp >= 0xac00 && cp <= 0xd7a3) ||
519    (cp >= 0xf900 && cp <= 0xfaff) ||
520    (cp >= 0xff00 && cp <= 0xff60) ||
521    (cp >= 0xffe0 && cp <= 0xffe6)
522  return wide ? 2 : 1
523}
524function dispWidth(s: string): number {
525  let w = 0
526  for (const ch of Array.from(s)) w += charWidth(ch)
527  return w
528}
529
530type Cell = { ch: string; fg?: string; bg?: string }
531
532// --- a row of the band: cells with independent fg/bg, column-addressed -----
533class Row {
534  cells: Cell[] = []
535  width(): number {
536    return this.cells.reduce((w, c) => w + charWidth(c.ch), 0)
537  }
538  padTo(col: number) {
539    while (this.width() < col) this.cells.push({ ch: ' ' })
540  }
541  put(col: number, text: string, fg?: string, bg?: string) {
542    this.padTo(Math.max(0, col))
543    for (const ch of Array.from(text)) this.cells.push({ ch, fg, bg })
544  }
545  putRight(right: number, text: string, fg?: string, bg?: string) {
546    this.put(right - dispWidth(text), text, fg, bg)
547  }
548  /** right-align only if it still fits after what is already on the line */
549  putRightIfFits(right: number, text: string, fg?: string, bg?: string): boolean {
550    if (right - dispWidth(text) < this.width() + 1) return false
551    this.putRight(right, text, fg, bg)
552    return true
553  }
554  putCells(col: number, cells: Cell[]) {
555    this.padTo(Math.max(0, col))
556    for (const c of cells) this.cells.push(c)
557  }
558  // paint the whole line's background (the selected row in the reference
559  // screenshot); cells that carry their own bg - the logo badges - keep it
560  fillBg(bg: string, width: number) {
561    this.padTo(width)
562    this.cells = this.cells.map(c => ({ ...c, bg: c.bg ?? bg }))
563  }
564}
565
566// groups consecutive same-color cells into one span; takes the Text tag as a
567// parameter since Row is built before we are inside the component's JSX scope
568function rowChildren(row: Row, Text: TextTag): RenderNode[] {
569  const out: RenderNode[] = []
570  let run: { text: string; fg?: string; bg?: string } | null = null
571  const flush = () => {
572    if (!run) return
573    out.push(!run.fg && !run.bg ? run.text : <Text color={run.fg} backgroundColor={run.bg}>{run.text}</Text>)
574    run = null
575  }
576  for (const c of row.cells) {
577    if (run && run.fg === c.fg && run.bg === c.bg) run.text += c.ch
578    else {
579      flush()
580      run = { text: c.ch, fg: c.fg, bg: c.bg }
581    }
582  }
583  flush()
584  return out
585}
586
587// --- number formatting (no Intl: the hooks sandbox is not guaranteed to have
588// a full ICU build, and toFixed + a grouping regex is enough here) ----------
589function thousands(value: number, decimals = 2): string {
590  const neg = value < 0
591  const [int, frac] = Math.abs(value).toFixed(decimals).split('.')
592  const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
593  return `${neg ? '-' : ''}${grouped}${frac ? `.${frac}` : ''}`
594}
595function signed(value: number, decimals = 2): string {
596  const sign = value > 0 ? '+' : value < 0 ? '-' : ''
597  return sign + thousands(Math.abs(value), decimals)
598}
599
600/**
601 * How many decimals a watchlist PRICE cell prints with. tw/us stay a fixed
602 * 2 - a table mixing NT$18.65 and NT$6,055 stocks still reads fine at a
603 * flat 2. Crypto has no such range: BTC trades in the tens of thousands
604 * while DOGE trades in cents, and a fixed decimal count either drowns DOGE
605 * in trailing zeros or throws away BTC's only meaningful digits - so this
606 * scales the decimal count to the PRICE's own magnitude instead of the
607 * market's, and only for crypto (tw/us keep the flat 2 they always had).
608 */
609function quotePriceDecimals(market: MarketId, price: number): number {
610  if (market !== 'crypto') return 2
611  if (price >= 1000) return 0
612  if (price >= 1) return 2
613  return 4
614}
615
616// up is red and down is green on the Taiwan board, the other way round on
617// the US board - the whole reason this mod tracks which market it is
618// showing. Crypto follows the US convention (green up / red down) - that is
619// the crypto-market norm, not a "not Taiwan" default, and the `!== 'tw'`
620// shape below already reads that way for free.
621function tone(market: MarketId, value: number): string {
622  if (value === 0) return FLAT
623  const up = market === 'tw' ? DOWN_RED : UP_GREEN
624  const down = market === 'tw' ? UP_GREEN : DOWN_RED
625  return value > 0 ? up : down
626}
627
628type Layout = {
629  badgeCol: number
630  symCol: number
631  nameCol: number
632  priceCol: number
633  priceRight: number
634  chgRight: number
635  pctRight: number
636  showName: boolean
637}
638
639// right-anchored numeric columns, capped at 74 so the table does not stretch
640// across a very wide terminal. Price is the widest and brightest column - it
641// is the number this band is for. Under ~46 columns the name goes.
642function layout(width: number): Layout {
643  const w = Math.max(46, width)
644  const pctRight = Math.min(w - 1, 74)
645  const chgRight = pctRight - 9
646  const priceRight = chgRight - 11
647  const priceCol = priceRight - 10 // reserved for the widest price, e.g. 1,396.14
648  const nameCol = 9
649  return {
650    badgeCol: 1,
651    symCol: 1,
652    nameCol,
653    priceCol,
654    priceRight,
655    chgRight,
656    pctRight,
657    showName: priceCol - nameCol >= 8,
658  }
659}
660
661// Two symbols per row, when the page holds more than 5 (register.tsx decides
662// when; see BoardProps.columns). 變更$ has no room next to a second symbol,
663// so each half only carries 代號/名稱/價格/變更%, right-anchored the same way
664// the single-column table anchors them.
665type HalfLayout = {
666  symCol: number
667  nameCol: number
668  priceCol: number
669  priceRight: number
670  pctRight: number
671  showName: boolean
672}
673
674const TWO_COL_MAX = 104 // two halves need more room than one table's 74-column cap
675const TWO_COL_GUTTER = 6 // clear columns between the halves, so 變更% and the
676// next 代號 do not read as one run of digits
677// Half-width floor, left to right: 代號 up to 6 chars + 1 gap (7) + a
678// 4-character name + 1 gap (9 - CJK counts double, so 4 characters is 8
679// columns) + the widest price, e.g. "1,396.14", + 1 gap (9) + the widest
680// 變更% field, e.g. "▼ -100.00%" (10). Below this a half cannot hold
681// 代號 + a name + 價格 + 變更% without cutting one of them, so the two-column
682// table falls back to the single-column one instead of squeezing:
683// 7 + 9 + 9 + 10 = 35 per half, twice that plus the gutter = 76. `layout2`
684// spends that 76 out of `width - 1`, the same one column short of the raw
685// terminal width the single-column `layout` reserves - so the terminal
686// itself needs to be 77 columns or wider, not 76, before two columns fit.
687const MIN_HALF_WIDTH = 35
688const MIN_TWO_COL_WIDTH = MIN_HALF_WIDTH * 2 + TWO_COL_GUTTER // 76, out of `width - 1`
689
690function layout2(width: number): [HalfLayout, HalfLayout] {
691  const cap = Math.min(width - 1, TWO_COL_MAX)
692  const halfW = Math.floor((cap - TWO_COL_GUTTER) / 2)
693  const mkHalf = (leftEdge: number): HalfLayout => {
694    const pctRight = leftEdge + halfW
695    const priceRight = pctRight - 11
696    const priceCol = priceRight - 9
697    const nameCol = leftEdge + 7
698    return { symCol: leftEdge, nameCol, priceCol, priceRight, pctRight, showName: priceCol - nameCol >= 8 }
699  }
700  const left = mkHalf(1)
701  const right = mkHalf(left.pctRight + 1 + TWO_COL_GUTTER)
702  return [left, right]
703}
704
705/** whether a terminal this wide can lay out two readable halves - see MIN_TWO_COL_WIDTH */
706function fitsTwoColumns(width: number): boolean {
707  return Math.min(width - 1, TWO_COL_MAX) >= MIN_TWO_COL_WIDTH
708}
709
710// --- pnl (損益) layout -------------------------------------------------------
711// One column of right-anchored numeric fields: 張數/成本/現價/今日%/今日損益/
712// 總損益/損益%. The same right-to-left reservation style as `layout` above,
713// capped so the table does not stretch across a very wide terminal.
714type PnlLayout = {
715  symCol: number
716  nameCol: number
717  qtyRight: number
718  costRight: number
719  priceRight: number
720  todayPctRight: number
721  todayPnlRight: number
722  totalPnlRight: number
723  totalPnlPctRight: number
724  showName: boolean
725}
726
727// Field-width budget, right to left (each gap is the field's own width + one
728// column of air before the next field starts): 損益% 8 ("+100.00%"), 總損益
729// 12 ("+9,999,999" plus room), 今日損益 12 (same shape as 總損益), 今日% 8,
730// 現價 9 ("99,999.00" - 成本/現價 always carry 2 decimals, see priceDecimals
731// in the pnl branch), 成本 9, 張數 6 ("999.9" - qty/1000, at most one decimal
732// - see qtyLabel), name 12 (up to ~6 CJK characters), sym 6. Without the
733// name column this is 77 of an 80-column band (name needs another 13, which
734// an 80-column band does not have) - `showName` drops it there the same way
735// the watchlist table's own `showName` does, rather than let it collide with
736// 張數 the way it once did (元大台灣50 10,000 -> "元大台灣5010,000").
737function pnlLayout(width: number): PnlLayout {
738  const w = Math.max(60, width)
739  const totalPnlPctRight = Math.min(w - 1, 96)
740  const totalPnlRight = totalPnlPctRight - 9
741  const todayPnlRight = totalPnlRight - 13
742  const todayPctRight = todayPnlRight - 13
743  const priceRight = todayPctRight - 9
744  const costRight = priceRight - 10
745  const qtyRight = costRight - 10
746  const nameCol = 8
747  return {
748    symCol: 1,
749    nameCol,
750    qtyRight,
751    costRight,
752    priceRight,
753    todayPctRight,
754    todayPnlRight,
755    totalPnlRight,
756    totalPnlPctRight,
757    showName: qtyRight - nameCol >= 13,
758  }
759}
760
761/**
762 * `張數`: qty/1000 with only the decimals it needs: `2`, `0.5`, and `0.01` for
763 * an odd lot of 10 shares (one decimal turned 10 shares into `0.0`).
764 */
765function qtyLabel(qty: number): string {
766  const lots = qty / 1000
767  return Number.isInteger(lots) ? String(lots) : lots.toFixed(3).replace(/0+$/, '')
768}
769
770function hhmmLocal(ms: number): string {
771  if (!ms) return '--:--'
772  const d = new Date(ms)
773  const two = (n: number) => String(n).padStart(2, '0')
774  return `${two(d.getHours())}:${two(d.getMinutes())}`
775}
776
777/** `+11.11%` / `-11.11%` / `0.00%` */
778function pct(value: number): string {
779  const sign = value > 0 ? '+' : value < 0 ? '-' : ''
780  return `${sign}${Math.abs(value).toFixed(2)}%`
781}
782
783// --- candle panel ----------------------------------------------------------
784// Real candles: a body character and a wick character, each owning a whole
785// terminal row. The first version packed both into half-block cells to buy ten
786// levels of vertical resolution instead of five - and lost the candle. A cell
787// is either "top half lit" or "bottom half lit", so a one-row body and the
788// wick above it landed in the same cell and merged into one blob; the user's
789// verdict was "看不出來那是 K 棒", and he was right. Five levels that read as
790// candles beat ten that read as noise.
791//
792// The wick shares the body's column rather than sitting beside it: with a
793// one-column body they line up exactly, which is what the user picked over
794// wider bodies ("那條線不能置中,看起來好煩" - a 2-column body puts the wick
795// on one side of it).
796const CHART_PLOT_ROWS = 5 // -> 10 pixel rows of vertical resolution. One row
797// fewer than the table's five quotes plus header and rule, so that the chart's
798// own title row (which names the symbol) fits without the band growing.
799const AXIS_W = 10
800const BAR_STRIDE = 2 // one candle column + one gap column, so bodies stay distinct
801
802function darken(hex: string): string {
803  const v = hex.replace('#', '')
804  const parts = [0, 2, 4].map(i => Math.max(0, parseInt(v.slice(i, i + 2), 16) - 0x45))
805  return `#${parts.map(p => p.toString(16).padStart(2, '0')).join('')}`
806}
807
808type Candles = { rows: Cell[][]; hi: number; lo: number }
809
810function candleCells(bars: Bar[], market: MarketId, prevClose: number, width: number, dim: boolean): Candles {
811  const empty: Candles = { rows: Array.from({ length: CHART_PLOT_ROWS }, () => []), hi: 0, lo: 0 }
812  if (bars.length === 0 || width <= 0) return empty
813
814  // One candle every BAR_STRIDE columns, so the number of candles follows the
815  // terminal's width rather than the feed's bar count: a day of 5-minute bars
816  // is ~79 of them, and drawing 79 into 31 slots is what made the old panel a
817  // solid block. Each slot is a real OHLC merge of the bars it covers, so the
818  // highs and lows survive the aggregation.
819  const slots = Math.max(1, Math.min(bars.length, Math.floor((width + 1) / BAR_STRIDE)))
820  const merged: Bar[] = []
821  for (let i = 0; i < slots; i++) {
822    const from = Math.floor((i * bars.length) / slots)
823    const to = Math.max(from + 1, Math.floor(((i + 1) * bars.length) / slots))
824    let [o, h, l, c] = bars[from]
825    for (let j = from; j < to; j++) {
826      const [, bh, bl, bc] = bars[j]
827      if (bh > h) h = bh
828      if (bl < l) l = bl
829      c = bc
830    }
831    merged.push([o, h, l, c])
832  }
833
834  let hi = prevClose
835  let lo = prevClose
836  for (const [, h, l] of merged) {
837    if (h > hi) hi = h
838    if (l < lo) lo = l
839  }
840  const span = hi - lo || 1
841  const toRow = (price: number) =>
842    Math.min(CHART_PLOT_ROWS - 1, Math.max(0, Math.round(((hi - price) / span) * (CHART_PLOT_ROWS - 1))))
843
844  const rows: Cell[][] = Array.from({ length: CHART_PLOT_ROWS }, () =>
845    Array.from({ length: width }, () => ({ ch: ' ' }) as Cell),
846  )
847  // No reference line across the candles. The candles sit every second column,
848  // so a line drawn through them alternates with the bodies - `█┈█┈█┈` - and
849  // reads as noise rather than as a level. The previous close is already named
850  // on the price axis at the right, on its own row and in its own color.
851
852  for (let i = 0; i < merged.length; i++) {
853    const c = i * BAR_STRIDE
854    if (c >= width) break
855    const [o, h, l, cl] = merged[i]
856    const body = dim ? GRAY : cl === o ? FLAT : tone(market, cl - o)
857    const wick = darken(body)
858    for (let r = toRow(h); r <= toRow(l); r++) rows[r][c] = { ch: '│', fg: wick }
859    for (let r = toRow(Math.max(o, cl)); r <= toRow(Math.min(o, cl)); r++) rows[r][c] = { ch: '█', fg: body }
860  }
861
862  for (const row of rows) row.push({ ch: ' ' })
863  return { rows, hi, lo }
864}
865
866// halfway between two "HH:MM" strings, for the chart's middle axis tick
867function midTime(from: string, to: string): string {
868  const mins = (hm: string) => {
869    const [h, m] = hm.split(':')
870    return Number(h) * 60 + Number(m)
871  }
872  const mid = Math.floor((mins(from) + mins(to)) / 2)
873  return `${String(Math.floor(mid / 60)).padStart(2, '0')}:${String(mid % 60).padStart(2, '0')}`
874}
875
876/**
877 * A quote row's 代號 (and 名稱, if there is room): flapped on a page turn -
878 * ticker codes are all on the text drum, so they riffle properly - or wiped,
879 * since a Chinese name has no drum to riffle through. Shared by the
880 * single-column loop and each half of the two-column one, so a page turn
881 * animates the same way in both.
882 */
883function drawSymbolCell(
884  r: Row,
885  symCol: number,
886  nameCol: number,
887  showName: boolean,
888  q: QuoteRow,
889  rowStart: number,
890  turned: boolean,
891): void {
892  if (turned && rowStart !== RESTING) {
893    const wasCode = q.was?.code ?? q.code
894    const codeW = Math.max(dispWidth(q.code), dispWidth(wasCode))
895    r.put(symCol, flapField(padRight(wasCode, codeW), padRight(q.code, codeW), TEXT_DRUM, rowStart, 0), SYMBOL)
896    if (showName) {
897      const wasName = q.was?.name ?? q.name
898      const nameW = Math.max(dispWidth(q.name), dispWidth(wasName))
899      // It is paced to land with the flaps rather than ahead of them.
900      r.put(nameCol, wipeField(wasName, q.name, nameW, rowStart, flapSpan(1, 0)), DIM)
901    }
902  } else {
903    r.put(symCol, q.code, SYMBOL)
904    if (showName) r.put(nameCol, q.name, DIM)
905  }
906}
907
908/**
909 * One symbol inside a two-column table row: 代號/名稱/價格/變更% only, at the
910 * given half's own columns. `slot` is this quote's position within its half
911 * (0..4), which is what the row-turn stagger below is offset by - each half
912 * turns independently, since a two-column row can hold two symbols whose
913 * prices moved on different ticks, or one that did not move at all.
914 */
915function drawTwoColQuote(r: Row, half: HalfLayout, q: QuoteRow, market: MarketId, rowTurn: number, slot: number): void {
916  const turned = q.was?.code !== undefined
917  const rowStart = turned ? rowTurn - slot * PAGE_ROW_STAGGER : RESTING
918  drawSymbolCell(r, half.symCol, half.nameCol, half.showName, q, rowStart, turned)
919
920  if (q.noData) {
921    r.putRight(half.priceRight, '—', DIM)
922    r.putRight(half.pctRight, '—', DIM)
923    return
924  }
925
926  const color = tone(market, q.pct)
927  const pctText = (v: number) => `${v > 0 ? '▲' : v < 0 ? '▼' : '-'} ${signed(v)}%`
928  const turn = q.was ? rowTurn - slot * (turned ? PAGE_ROW_STAGGER : ROW_STAGGER) : RESTING
929  const was = q.was ?? q
930  const stagger = turned ? 0 : STAGGER
931  // decimals off the CURRENT price so a flap does not change digit count
932  // mid-turn (was.price and q.price share the new price's own magnitude)
933  const priceDecimals = quotePriceDecimals(market, q.price)
934  flapRight(r, half.priceRight, thousands(was.price, priceDecimals), thousands(q.price, priceDecimals), WHITE, turn, half.priceCol, stagger)
935  flapRight(r, half.pctRight, pctText(was.pct), pctText(q.pct), color, turn, half.priceCol, stagger)
936}
937
938export default function StockBandBoard(props: BoardProps | undefined, surface: ClientSurface<State>) {
939  const { Box, Text } = surface.elements
940
941  if (!props || !props.quotes || props.quotes.length === 0) {
942    return <Text dimColor>stock-band: waiting for quotes</Text>
943  }
944
945  // One listener per instance, for the same lifetime reason the frame clock
946  // has one: onPointer keeps a single listener, and a remounted board gets a
947  // fresh entry. Only a left press picks - a drag, a right button and the
948  // hover moves all fall through, so the row under the pointer is the row the
949  // user aimed at.
950  let picker = pickers.get(surface)
951  if (!picker) {
952    const own: Picker = { hit: () => undefined }
953    pickers.set(surface, own)
954    picker = own
955    surface.onPointer(e => {
956      if (e.type !== 'down' || e.button !== 'left') return
957      const result = own.hit(e.x, e.y)
958      if (result !== undefined) surface.post(result)
959    })
960  }
961
962  // A page turn changes the symbols as well as the numbers, so its wave has the
963  // whole row to cross and gets the longer budget.
964  const isPageTurn = props.quotes.some(q => q.was?.code !== undefined)
965  const still = props.animation === 'off'
966  // the dot only pulses on demo prices; on real ones it steps per snapshot
967  const blinks = props.phase === 'open' && props.source === 'demo'
968
969  // The animation is driven off the wall clock, not off a tick count, so a
970  // late timer callback cannot drift the flip. The timer only asks for a
971  // repaint when the visible frame actually changes: a 5-second hold costs no
972  // frames at all, which keeps this at about the same repaint rate as the
973  // blink alone used to be.
974  if (!surface.state) {
975    // the first snapshot is not an update, so the rows do not turn for it
976    const seed: State = {
977      frame: '',
978      turn: props.turn,
979      since: 0,
980      flipMs: FLIP_MS,
981      nextFeedAt: props.nextFeedAt,
982      blinks,
983    }
984    surface.setState({ ...seed, frame: still ? stillFrameId(Date.now(), seed) : frameId(Date.now(), seed) })
985  }
986
987  // A still board only repaints for the countdown, so it wants one frame a
988  // second; with the countdown off as well it wants no timer at all and simply
989  // redraws when the hooks module says the prices changed.
990  const wanted = still ? (props.countdown && props.nextFeedAt ? 1000 : 0) : ANIM_TICK_MS
991  const running = frameClocks.get(surface)
992  if (!running || running.ms !== wanted) {
993    running?.cancel()
994    // the entry is written even for `wanted === 0`, so a board that wants no
995    // timer does not try to install one on every single render
996    const cancel =
997      wanted > 0
998        ? surface.every(wanted, () => {
999            const s = surface.state
1000            if (!s) return
1001            const id = wanted === 1000 ? stillFrameId(Date.now(), s) : frameId(Date.now(), s)
1002            if (s.frame !== id) surface.setState({ ...s, frame: id })
1003          })
1004        : () => {}
1005    frameClocks.set(surface, { ms: wanted, cancel })
1006  }
1007
1008  // a new snapshot or a page change starts the rows turning; the next render
1009  // sees turn matched and leaves the state alone, so this cannot loop
1010  // The turn the rows are drawn against has to be THIS pass's turn, not the one
1011  // the last pass left behind. Reading the old state here drew the frame that
1012  // starts a turn with every row already settled on its new values, and the
1013  // flip only began on the next frame - the new page arrived first and the
1014  // animation played afterwards, which reads as a jump followed by noise
1015  // rather than as a turn.
1016  const st = surface.state
1017  let turning = st
1018  if (st && props.turn !== st.turn) {
1019    turning = {
1020      ...st,
1021      turn: props.turn,
1022      since: Date.now(),
1023      flipMs: isPageTurn ? PAGE_FLIP_MS : FLIP_MS,
1024      nextFeedAt: props.nextFeedAt,
1025      blinks,
1026    }
1027    surface.setState(turning)
1028  } else if (st && (props.nextFeedAt !== st.nextFeedAt || blinks !== st.blinks)) {
1029    turning = { ...st, nextFeedAt: props.nextFeedAt, blinks }
1030    surface.setState(turning)
1031  }
1032  const anim = still ? { slot: 0, flap: RESTING, blink: 0 } : animState(Date.now())
1033  const rowTurn = !still && turning ? rowFlap(Date.now(), turning.since, turning.flipMs) : RESTING
1034  const ticks = anim.blink
1035  const tillFeed = props.countdown ? secondsToFeed(Date.now(), props.nextFeedAt) : -1
1036
1037  const lay = layout(surface.columns || 80)
1038  const open = props.phase === 'open'
1039  const rows = Array.from(
1040    { length: props.view === 'chart' ? CHART_ROWS : props.view === 'pnl' ? PNL_ROWS : TABLE_ROWS },
1041    () => new Row(),
1042  )
1043  const quotes = props.quotes.slice(0, MAX_TABLE_QUOTES)
1044  // the feed names itself - 證交所 即時 and Yahoo 即時 are not the same claim -
1045  // and only a source that did not say falls back to a generic label
1046  const sourceName =
1047    props.source === 'demo'
1048      ? '示範資料(未接 API)'
1049      : props.sourceLabel || (props.source === 'live' ? '即時報價' : '報價檔')
1050  // The version rides with the source tag so a wide band answers "which build
1051  // is this" without being asked, and a narrow one drops it first: which
1052  // prices you are looking at still matters more than which build drew them.
1053  const sourceTag = props.version ? `${sourceName} · ${props.version}` : sourceName
1054  // The demo tag is the longest thing this row ever carries, and it is the one
1055  // whose full form is optional: `示範資料` already says the prices are fake,
1056  // the parenthetical only says why. Shortening it buys eight columns of air
1057  // between the index block and the tail before anything has to be dropped.
1058  const sourceTagShort = props.source === 'demo' ? '示範資料' : sourceName
1059  // The band signs itself in the bottom-right corner. On a row too tight for
1060  // both, the data source wins: which prices you are looking at matters more
1061  // than who wrote the thing drawing them.
1062  const signOff = (row: Row, right: number) => {
1063    for (const tail of [
1064      `${sourceTag} · ${CREDIT}`,
1065      `${sourceTagShort} · ${CREDIT}`,
1066      sourceTag,
1067      sourceTagShort,
1068    ]) {
1069      if (row.putRightIfFits(right, tail, DIM)) return
1070    }
1071  }
1072
1073  if (props.view === 'chart') {
1074    const focus = Math.max(0, Math.min(quotes.length - 1, props.focus))
1075    const q = quotes[focus]
1076    const color = tone(props.market, q.pct)
1077    const plotW = Math.max(10, lay.pctRight - 2 - AXIS_W)
1078
1079    // row 0: which symbol this is, its price, what the bars are
1080    const title = rows[0]
1081    title.put(lay.symCol, q.code, SYMBOL)
1082    title.put(title.width() + 1, q.name, DIM)
1083    title.put(title.width() + 2, thousands(q.price), WHITE)
1084    const arrow = q.pct > 0 ? '▲' : q.pct < 0 ? '▼' : '-'
1085    title.put(title.width() + 1, `${arrow} ${signed(q.change)} (${signed(q.pct)}%)`, color)
1086    title.putRightIfFits(lay.pctRight, `${props.barLabel} · ${props.marketLabel} ${open ? `${SUN} 盤中` : `${MOON} 休市`}`, DIM)
1087
1088    // rows 1..6: the candles, with a price axis on the right
1089    const candles = candleCells(q.bars ?? [], props.market, q.prevClose, plotW, false)
1090    for (let j = 0; j < CHART_PLOT_ROWS; j++) rows[1 + j].putCells(lay.badgeCol, candles.rows[j])
1091    if ((q.bars?.length ?? 0) === 0) {
1092      rows[1 + Math.floor(CHART_PLOT_ROWS / 2)].put(lay.badgeCol + 2, '沒有 K 棒資料(報價檔未提供 bars)', DIM)
1093    } else {
1094      rows[1].putRight(lay.pctRight, thousands(candles.hi), DIM)
1095      rows[1 + Math.floor(CHART_PLOT_ROWS / 2)].putRight(lay.pctRight, thousands(q.prevClose), '#5a6370')
1096      rows[CHART_PLOT_ROWS].putRight(lay.pctRight, thousands(candles.lo), DIM)
1097    }
1098
1099    // row 6: the session's time axis. Row.put only appends, so the axis is
1100    // composed left to right rather than written at absolute columns.
1101    const axis = rows[6]
1102    axis.put(lay.badgeCol, props.sessionOpen, DIM)
1103    axis.put(axis.width(), '─'.repeat(Math.max(1, Math.floor(plotW / 2) - 7)), RULE)
1104    axis.put(axis.width(), midTime(props.sessionOpen, props.sessionClose), DIM)
1105    axis.put(axis.width(), '─'.repeat(Math.max(1, lay.badgeCol + plotW - 5 - axis.width())), RULE)
1106    axis.put(axis.width(), props.sessionClose, DIM)
1107
1108    // row 7: where you are in the list, how to move, and the data source
1109    const foot = rows[7]
1110    foot.put(lay.symCol, `${quotes.length} 檔中第 ${focus + 1} 檔`, DIM)
1111    // The chart view's own buttons now sit in the button row above, left-
1112    // aligned and named for what they do, so this line no longer has to
1113    // explain that one button means three things.
1114    signOff(foot, lay.pctRight)
1115    // the table is not on screen here, so there is nothing under the pointer
1116    // to pick; the named buttons above the band move between symbols instead
1117    picker.hit = () => undefined
1118  } else if (props.view === 'pnl') {
1119    const lay = pnlLayout(surface.columns || 80)
1120    // 成本/現價 reuse the table's own price formatter - `thousands()` with no
1121    // decimals argument, i.e. always 2, the same call the watchlist table's
1122    // price column makes regardless of market (e.g. 2,436.04). `decimals`
1123    // stays market-dependent for money that is NOT a price - 今日損益/總損益
1124    // and the totals row read as integer TWD, the same way the rest of the
1125    // band's TWD figures do (US keeps cents throughout).
1126    const priceDecimals = 2
1127    // integer TWD only for tw - us and crypto (both USD-family, cents-
1128    // denominated) keep 2 decimals the same way
1129    const decimals = props.market === 'tw' ? 0 : 2
1130    // register.tsx has already sorted the full list by props.pnlSortKey/Dir
1131    // and clamped holdingsScroll to it - this only slices the window and
1132    // marks which header cell is active.
1133    const holdings = props.holdings
1134    const scroll = props.holdingsScroll
1135    const page = holdings.slice(scroll, scroll + PNL_PAGE_SIZE)
1136
1137    // row 0: title - what this is, where the numbers came from, how many
1138    // positions, and when the snapshot was taken. A demo price anywhere on
1139    // the band means these are demo prices too, so the title says so instead
1140    // of reading as a real portfolio.
1141    const title = rows[0]
1142    const demoTag = props.source === 'demo' ? ' · 示範價格' : ''
1143    title.put(
1144      lay.symCol,
1145      `庫存損益 · ${props.holdingsSource || '沒有庫存資料'} · ${holdings.length} 檔 · 更新 ${hhmmLocal(props.holdingsAt)}${demoTag}`,
1146      DIM,
1147    )
1148
1149    // row 1: column headers, right-anchored the same way the watchlist
1150    // table's are. Five of them are sortable - the active one carries an
1151    // arrow (↓/↑) the way the watchlist header marks `↓變更%`, and this
1152    // records each sortable cell's own column span so the picker below can
1153    // hit-test a click against it without duplicating this layout a second
1154    // time.
1155    const head = rows[1]
1156    const sortHits: { key: PnlSortKey; x0: number; x1: number }[] = []
1157    const arrow = props.pnlSortDir === 'desc' ? '↓' : '↑'
1158    const sortable = (key: PnlSortKey, label: string) => (props.pnlSortKey === key ? `${arrow}${label}` : label)
1159    const putLeftSortable = (col: number, key: PnlSortKey, label: string) => {
1160      const text = sortable(key, label)
1161      head.put(col, text, HEAD)
1162      sortHits.push({ key, x0: col, x1: col + dispWidth(text) })
1163    }
1164    const putRightSortable = (right: number, key: PnlSortKey, label: string) => {
1165      const text = sortable(key, label)
1166      head.putRight(right, text, HEAD)
1167      sortHits.push({ key, x0: right - dispWidth(text), x1: right })
1168    }
1169    putLeftSortable(lay.symCol, 'code', '代號')
1170    if (lay.showName) head.put(lay.nameCol, '名稱', HEAD)
1171    head.putRight(lay.qtyRight, '張數', HEAD)
1172    head.putRight(lay.costRight, '成本', HEAD)
1173    head.putRight(lay.priceRight, '現價', HEAD)
1174    putRightSortable(lay.todayPctRight, 'today', '今日%')
1175    putRightSortable(lay.todayPnlRight, 'todayPnl', '今日損益')
1176    putRightSortable(lay.totalPnlRight, 'totalPnl', '總損益')
1177    putRightSortable(lay.totalPnlPctRight, 'totalPnlPct', '損益%')
1178    // header cells only - no row is a click target yet (item 8 of the
1179    // original spec still holds for the data rows themselves)
1180    picker.hit = (x, y) => {
1181      if (y !== 1) return undefined
1182      const hit = sortHits.find(h => x >= h.x0 && x < h.x1)
1183      return hit ? { sortPnl: hit.key } : undefined
1184    }
1185
1186    if (holdings.length === 0) {
1187      rows[2].put(
1188        lay.symCol,
1189        '沒有庫存資料:寫 .claude/stock-holdings.json,或在 stock-band.json 加 holdings(見 README)',
1190        DIM,
1191      )
1192    } else {
1193      // rows 2..6: one holding a row, 5 per page - fewer than 5 on the last
1194      // page just leaves the remaining rows blank (filled with a
1195      // non-breaking space below, same as every other view). 現價/今日%/
1196      // 今日損益/總損益/損益% flap the same way the watchlist table's price/
1197      // change/pct do - same `rowTurn`, same `flapRight`/`flapField`, no
1198      // second animation system: a mount/page/sort turn flaps every visible
1199      // row (h.was.code set, see buildProps' pricedForDisplay), a live
1200      // price tick flaps only the rows that actually moved (h.was.price