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…

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.

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.
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.)
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 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)。
Four different causes produce the exact same symptom — no band, and no error message anywhere — so check all four in order:
claude --version needs to be 2.1.269 or later.! 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)./reload-plugins does not re-read env.--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.)
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_
台股 ▾ / 美股 ▾ 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.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.↓變更% 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.休市 下次開盤 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.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. 台股 ▾ ◀ 上一檔 下一檔 ▶ 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.
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.
| key | default | meaning |
|---|---|---|
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 |
refreshMs | 3000 | how 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 |
highlight | true | highlight 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) |
feedMs | 30000 | seconds between feed requests, in ms (floor 15000 — below that Yahoo answers 429; the request budget can widen it further) |
pageMs | 10000 | how 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 / us | built-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.
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 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.
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."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.twSources route prices the table.User-Agent gets 429 on the first try, and the ban lasts minutes. Every non-2xx doubles the wait, up to 5 minutes._=<timestamp> and no-cache headers.^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.hooks/register.tsx 3889 lines1/* @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 lines1/* @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