Live Vietnam stock-market heatmap and price board (SSI iBoard) in a Claude Code pane

<img src="docs/desktop_preview.png" width="560" alt="VN Stock Watch in the Claude Code desktop app">
Live Vietnam stock-market heatmap and price board (SSI iBoard data) in a Claude Code pane, with a VN30 table, watchlist and portfolio. Works in the terminal (claude) and the Claude Code desktop app. No API key, no dependencies, no build step.
/plugin (marketplace)In Claude Code (terminal or desktop):
/plugin marketplace add NgoTuong12345/claude-mod-vn-stock-watch
/plugin install vn-stockmarket-heatmap@vn-stock-watch
/reload-plugins
Then run /vn-stock-watch. State is still saved to ~/.claude/state/. Update with /plugin marketplace update vn-stock-watch.
Manual clone: the mod must live at ~/.claude/mods/vn-stockmarket-heatmap (on Windows: %USERPROFILE%\.claude\mods\vn-stockmarket-heatmap). The folder name matters: state is saved to ~/.claude/state/, two levels up from the mod.
git clone https://github.com/NgoTuong12345/claude-mod-vn-stock-watch.git ~/.claude/mods/vn-stockmarket-heatmap
Paste this into Claude Code (terminal or desktop), or any coding agent with shell access:
Clone
https://github.com/NgoTuong12345/claude-mod-vn-stock-watch.gitinto~/.claude/mods/vn-stockmarket-heatmap(create~/.claude/modsif missing; on Windows use%USERPROFILE%\.claude\mods). Keep that exact folder name. Do not run a build, there is none. Then runclaude plugin validate ~/.claude/mods/vn-stockmarket-heatmapand report the result. Finally tell me to run/reload-pluginsand then/vn-stock-watch.
To update later: git -C ~/.claude/mods/vn-stockmarket-heatmap pull, then /reload-plugins.
/vn-stock-watchA mod in ~/.claude/mods/ is picked up by Claude Code, but a running session only loads it on request:
/reload-plugins. A new session picks it up by itself./vn-stock-watch. The pane opens and starts polling SSI iBoard.Re-run /reload-plugins after every code change or git pull. If /vn-stock-watch is not found, run /reload-plugins again and check that claude plugin validate ~/.claude/mods/vn-stockmarket-heatmap passes and the folder name is exact.
To try it without installing into mods/: claude --plugin-dir /path/to/vn-stockmarket-heatmap.
Both surfaces: sector/exchange views (desktop: "Group by" and multi-select "Filter" dropdowns: each pick toggles a sector, chips remove them), sector filter, index strip (VN-Index, HNX-Index, UPCOM-Index), VN30 table, watchlist table with 1M/3M/YTD returns, foreign buy/sell volume and remaining room (F.Buy, F.Sell, Room), and a Remove row with one button per watchlist ticker. Price changes flash the whole row until the next poll.
FPT in the search box and press Enter (a partial code like FP takes the top suggestion). Remove: -FPT, or press its button in the Remove row.FPT 1000 62.4 (quantity, cost in thousand VND). It shows value, P&L and a total row.~/.claude/state/vn-heatmap.json.Terminal: hotkeys s/e (sector/exchange), f (cycle sector filter), d (reset). Mouse wheel over the treemap zooms it.
Desktop: the treemap is an SVG sized to the pane.
⟲ resets to auto. (The desktop host gives plugins no pointer drag, so it is click-to-set, not draggable.)⟲ resets). Zoom in far enough and the VN30 table splits into two side-by-side tables..claude-plugin/plugin.json plugin manifest
hooks/ mod source (index.tsx, watch.ts, treemap.ts, svgmap.ts, types.d.ts, hooks.json)
tests/ self-checks (watch.check.ts, watch.test.tsx)
docs/ssi-iboard-api.md data-source API notes
bun tests/watch.check.ts # parse/apply/suggest logic; also hits live SSI
claude plugin validate ~/.claude/mods/vn-stockmarket-heatmap
(.claude-plugin/types/ is generated by Claude Code for type-checking and is not committed.)
~/.claude/state/vn-heatmap.err.log and the dim ui.render (Pane) refused: line in the transcript.SSI error: SSI iBoard rate-limits (HTTP 429). It retries on the next poll.MIT
hooks/index.tsx 738 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Idx, Lists, Meta, Quote, Tab, View, WRow } from './types'
5import { buildGrid, runs, ZOOMS } from './treemap'
6import { buildSvg, tableSvg, TABLE_FS, TABLE_FS_DEF } from './svgmap'
7import type { TRow } from './svgmap'
8import type { Group } from './treemap'
9import { apply, EXCHANGE, HEADERS, MAX, parse, rowsOf, sectorsOf, SSI, STATS, suggest, toQuote, toRow, toTicker } from './watch'
10import type { Ticker } from './watch'
11
12const PANE = 'vn-stock-watch'
13const OPEN_MS = 15_000 // 3 exchange snapshots (~2 MB) per poll; don't go much lower
14const CLOSED_EVERY = 8 // ticks per real fetch while the market is closed (~2 min)
15
16const quotes = atom({ plugin: 'vn-stockmarket-heatmap', key: 'quotes' } as const, [] as Quote[])
17const meta = atom({ plugin: 'vn-stockmarket-heatmap', key: 'meta' } as const, { at: 0, status: 'idle', n: 0 } as Meta)
18const only = atom({ plugin: 'vn-stockmarket-heatmap', key: 'only' } as const, '')
19const idx = atom({ plugin: 'vn-stockmarket-heatmap', key: 'idx' } as const, [] as Idx[])
20const view = atom({ plugin: 'vn-stockmarket-heatmap', key: 'view' } as const, 'sector' as View)
21const tab = atom({ plugin: 'vn-stockmarket-heatmap', key: 'tab' } as const, 'vn30' as Tab)
22const lists = atom({ plugin: 'vn-stockmarket-heatmap', key: 'lists' } as const, { watch: [], port: {} } as Lists)
23const wrows = atom({ plugin: 'vn-stockmarket-heatmap', key: 'wrows' } as const, [] as WRow[])
24const wlrows = atom({ plugin: 'vn-stockmarket-heatmap', key: 'wlrows' } as const, [] as WRow[])
25const wmeta = atom({ plugin: 'vn-stockmarket-heatmap', key: 'wmeta' } as const, { at: 0, status: 'idle', n: 0 } as Meta)
26const zoom = atom({ plugin: 'vn-stockmarket-heatmap', key: 'zoom' } as const, 0)
27const wq = atom({ plugin: 'vn-stockmarket-heatmap', key: 'wq' } as const, '')
28const mapadj = atom({ plugin: 'vn-stockmarket-heatmap', key: 'mapadj' } as const, 0)
29const tzoom = atom({ plugin: 'vn-stockmarket-heatmap', key: 'tzoom' } as const, -1)
30const wmsg = atom({ plugin: 'vn-stockmarket-heatmap', key: 'wmsg' } as const, '')
31
32const WATCH_MS = 3_000
33const SL_N = 28 // map-size slider: segments, the auto-size one, px per segment
34const SL_AUTO = 8
35const SL_STEP = 40
36// Outside the mod dir so a save never hot-reloads the module: ~/.claude/state/vn-heatmap.json
37// ~/.claude/state, whether loaded from ~/.claude/mods/<name> or installed under ~/.claude/plugins/cache/...
38const stateDir = ($: any) => {
39 const r = String($.plugin.root).split(String.fromCharCode(92)).join('/')
40 const i = r.indexOf('/plugins/')
41 return i >= 0 ? `${r.slice(0, i)}/state` : `${r}/../../state`
42}
43const listsFile = ($: any) => `${stateDir($)}/vn-heatmap.json`
44let universe: Ticker[] = [] // ~2k rows; module-level, not an atom
45let wtick = 0
46
47const ssi = async ($: any, path: string, init?: any) => {
48 const r = await $.http.fetch(`${SSI}${path}`, { ...init, headers: { ...HEADERS, ...init?.headers } })
49 return rowsOf(r.status, r.ok, r.text)
50}
51const fetchGroup = async ($: any, g: string) => (await ssi($, `/stock/group/${g}`)).map(toRow)
52const fetchMany = async ($: any, syms: string[]) =>
53 syms.length
54 ? (await ssi($, '/stock/multiple', {
55 method: 'POST',
56 headers: { 'content-type': 'application/json' },
57 body: JSON.stringify({ stocks: syms.slice(0, MAX) }),
58 })).map(toRow)
59 : []
60// 1M / 3M / YTD return vs daily closes (iboard-api chart history, close in thousand VND). Cached 30 min per symbol.
61const retCache = new Map<string, { at: number; v: WRow['rt'] }>()
62const getRets = async ($: any, sym: string, price: number): Promise<WRow['rt']> => {
63 let e = retCache.get(sym)
64 if (!e || Date.now() - e.at > 1_800_000) {
65 const y = new Date().getUTCFullYear()
66 const from = Math.floor(Date.UTC(y - 1, 11, 1) / 1000)
67 const to = Math.floor(Date.now() / 1000) + 86_400
68 const r = await $.http.fetch(`https://iboard-api.ssi.com.vn/statistics/charts/history?resolution=1D&symbol=${sym}&from=${from}&to=${to}`, { headers: HEADERS })
69 const d = JSON.parse(r.text).data as { t: number[]; c: number[] }
70 const at = (ts: number) => { let b: number | undefined; for (let i = 0; i < d.t.length; i++) if (d.t[i]! <= ts) b = d.c[i]; return b } // last close on/before ts
71 const m = (n: number) => { const o = new Date(); o.setUTCMonth(o.getUTCMonth() - n); return Math.floor(o.getTime() / 1000) }
72 e = { at: Date.now(), v: { m1: at(m(1)) ?? 0, m3: at(m(3)) ?? 0, y: at(Math.floor(Date.UTC(y, 0, 1) / 1000) - 1) ?? 0 } }
73 retCache.set(sym, e)
74 }
75 const b = e.v!
76 const f = (base: number) => (base ? (price / 1000 / base - 1) * 100 : null)
77 return { m1: f(b.m1!), m3: f(b.m3!), y: f(b.y!) }
78}
79const fetchUniverse = async ($: any) => (await ssi($, '/stock/stock-info')).map(toTicker)
80
81const refreshWatch = async ($: any) => {
82 try {
83 const t = await read($, tab)
84 const l = await read($, lists)
85 const r = t === 'vn30' ? await fetchGroup($, 'VN30') : await fetchMany($, t === 'watch' ? l.watch : Object.keys(l.port))
86 if (t === 'port') await Promise.all(r.map(async x => { x.rt = await getRets($, x.s, x.p).catch(() => undefined) }))
87 const order = t === 'vn30' ? [] : t === 'watch' ? l.watch : Object.keys(l.port)
88 if (order.length) r.sort((a, b) => order.indexOf(a.s) - order.indexOf(b.s))
89 else r.sort((a, b) => a.s.localeCompare(b.s))
90 if ((await read($, tab)) !== t) return // tab switched mid-flight
91 const prev = new Map((await read($, wrows)).map(x => [x.s, x.p]))
92 for (const x of r) { const o = prev.get(x.s); x.fl = o && o !== x.p ? (x.p > o ? 1 : -1) : 0 } // flash until next poll
93 await update($, wrows, () => r)
94 const wl = l.watch.length ? await fetchMany($, l.watch) : []
95 wl.sort((a, b) => l.watch.indexOf(a.s) - l.watch.indexOf(b.s))
96 await Promise.all(wl.map(async x => { x.rt = await getRets($, x.s, x.p).catch(() => undefined) }))
97 const pw = new Map((await read($, wlrows)).map(x => [x.s, x.p]))
98 for (const x of wl) { const o = pw.get(x.s); x.fl = o && o !== x.p ? (x.p > o ? 1 : -1) : 0 }
99 await update($, wlrows, () => wl)
100 await update($, wmeta, () => ({ at: Date.now(), status: 'live', n: r.length }))
101 } catch (err) {
102 await update($, wmeta, m => ({ ...m, status: `SSI error: ${String((err as Error).message ?? err).slice(0, 50)}` }))
103 }
104}
105
106const setTab = async ($: any, t: Tab) => {
107 await update($, tab, () => t)
108 await update($, wrows, () => [])
109 void refreshWatch($)
110}
111
112const submit = async ($: any, text: string) => {
113 const t = await read($, tab)
114 const port = t === 'port'
115 if (!universe.length) universe = await fetchUniverse($).catch(() => [])
116 const known = new Set(universe.map(x => x.code))
117 const first = suggest(universe, text)[0]?.code
118 const typed = text.trim().toUpperCase()
119 // Enter on a partial code takes the top suggestion ("FP" -> FPT), keeping any qty/cost after it.
120 const head = typed.split(/\s+/)[0] ?? ''
121 const fixed = !head.startsWith('-') && !known.has(head) && first ? typed.replace(head, first) : typed
122 const cmd = parse(fixed, s => known.has(s), port)
123 if (cmd.op === 'bad') return update($, wmsg, () => cmd.why)
124 const next = apply(await read($, lists), cmd, port)
125 await update($, lists, () => next)
126 const saved = await $.fs.write(listsFile($), JSON.stringify(next, null, 1)).then(() => '', (err: Error) => ` (save failed: ${String(err?.message ?? err).slice(0, 40)})`)
127 await update($, wq, () => '')
128 await update($, wmsg, () => `${cmd.op === 'del' ? 'removed' : 'added'} ${cmd.s}${port ? ' (portfolio)' : t === 'vn30' ? ' to watchlist' : ''}${saved}`)
129 void refreshWatch($)
130}
131
132const k1 = (v: number) => (v / 1000).toFixed(2)
133const sgn = (v: number, d = 2) => `${v > 0 ? '+' : ''}${v.toFixed(d)}`
134const vol = (v: number) => (v >= 1e6 ? `${(v / 1e6).toFixed(2)}M` : v >= 1e3 ? `${(v / 1e3).toFixed(1)}K` : String(v))
135const money = (v: number) => (Math.abs(v) >= 1e9 ? `${(v / 1e9).toFixed(2)}B` : `${(v / 1e6).toFixed(1)}M`)
136const tone = (r: WRow) => (r.k === 'c' ? '#9B59D0' : r.k === 'f' ? '#17A2B8' : r.ch > 0 ? '#2FA55A' : r.ch < 0 ? '#C0392B' : '#C9A227')
137
138const IDX: [string, string][] = [['VNINDEX', 'VN-Index'], ['HNXIndex', 'HNX-Index'], ['HNXUpcomIndex', 'UPCOM-Index']]
139const DAY = 86_400_000
140// Outside the mod dir (no hot reload on save): listed shares per symbol, refreshed weekly.
141const capsFile = ($: any) => `${stateDir($)}/vn-heatmap-shares.json`
142let sector = new Map<string, string>()
143let sectorAt = 0
144let shares: Record<string, number> = {}
145let sharesAt = -1 // -1 = cache file not read yet
146let filling = false
147let capsHave = 0
148let capsOf = 0
149let isOpen = false
150let tick = 0
151let gridTop = 0 // treemap rows in the pane tree, set by the last render; the wheel zooms over them
152let gridLen = 0
153let scrollOff = 0
154const refetchAll = () => sharesAt > 0 && Date.now() - sharesAt > 7 * DAY
155
156const ssiGet = async ($: any, url: string) => {
157 const r = await $.http.fetch(url, { headers: HEADERS })
158 if (r.status === 429) throw new Error('SSI 429')
159 if (!r.ok) throw new Error(`SSI ${r.status}`)
160 return JSON.parse(r.text).data
161}
162
163const pullIdx = async ($: any): Promise<Idx[]> =>
164 Promise.all(IDX.map(async ([id, name]) => {
165 const d = await ssiGet($, `${SSI}/exchange-index/${id}`)
166 return { name, price: d.indexValue, change: d.change, pct: d.changePercent }
167 }))
168
169// No bulk share-count endpoint. /stock/{sym}.listedShare is 0 for VCB/HPG/VIC/ACB, so use SSMI
170// finance-indicator sharesOutstanding (per symbol; SSI 429s past ~2 req/s, so one at a time).
171// ponytail: ~1.5k symbols x ~0.75s = ~20 min first fill; tiles appear as caps land (biggest traders first).
172const fillShares = async ($: any, syms: string[]) => {
173 if (filling) return
174 filling = true
175 try {
176 const all = refetchAll()
177 const todo = all ? syms : syms.filter(s => !(s in shares))
178 for (let i = 0; i < todo.length; i++) {
179 try {
180 const d = await ssiGet($, `${STATS}/company/ssmi/finance-indicator?symbol=${todo[i]}`)
181 if (!Array.isArray(d)) throw new Error('SSI bad body') // retry, don't cache
182 const n = Number(d[0]?.sharesOutstanding) // newest report first
183 shares[todo[i]!] = n > 0 ? n : 0 // 0 = no report (no tile, not retried until weekly refresh)
184 } catch {
185 await $.clock.sleep(30_000) // 429, throttle body or network: back off, retry same symbol
186 i--
187 continue
188 }
189 await $.clock.sleep(400)
190 if (i % 50 === 49) await $.fs.write(capsFile($), JSON.stringify({ at: sharesAt, shares })).catch(() => {})
191 }
192 if (all || !sharesAt) sharesAt = Date.now()
193 await $.fs.write(capsFile($), JSON.stringify({ at: sharesAt, shares })).catch(() => {})
194 } finally {
195 filling = false
196 }
197}
198
199const pull = async ($: any): Promise<Quote[]> => {
200 if (sharesAt < 0) {
201 sharesAt = 0
202 try { ({ at: sharesAt, shares } = JSON.parse(await $.fs.read(capsFile($)))) } catch {} // first run: no file yet
203 }
204 if (Date.now() - sectorAt > DAY) {
205 sector = sectorsOf(await $.http.fetch(`${STATS}/company/sectors-data`, { headers: HEADERS }).then((r: any) => JSON.parse(r.text).data))
206 sectorAt = Date.now()
207 }
208 const raw = (await Promise.all(['hose', 'hnx', 'upcom'].map(e => ssiGet($, `${SSI}/stock/type/s/${e}`)))).flat() as any[]
209 const syms = [...raw].sort((a, b) => (b.nmTotalTradedValue ?? 0) - (a.nmTotalTradedValue ?? 0)).map(x => x.stockSymbol as string)
210 if (refetchAll() || syms.some(s => !(s in shares))) void fillShares($, syms)
211 capsHave = syms.filter(s => s in shares).length
212 capsOf = syms.length
213 return raw.map(x => toQuote(x, sector, shares)).filter((x): x is Quote => !!x).sort((a, b) => b.m - a.m)
214}
215
216// VN market hours: Mon-Fri 09:00-15:00 (UTC+7)
217const isMarketOpen = () => {
218 const t = new Date(Date.now() + 7 * 3600_000)
219 const d = t.getUTCDay()
220 const h = t.getUTCHours() + t.getUTCMinutes() / 60
221 return d >= 1 && d <= 5 && h >= 9 && h < 15.1
222}
223// Status label from the exchange schedule (HOSE: ATO 9:00-9:15, ATC 14:30-14:45, put-through to 15:00; UPCOM trades to 15:00)
224const session = (): { t: string; c: string } => {
225 const x = new Date(Date.now() + 7 * 3600_000)
226 const d = x.getUTCDay()
227 const h = x.getUTCHours() + x.getUTCMinutes() / 60
228 const G = '#2ECC71', A = '#F5B041', R = '#FF5A4D'
229 if (d < 1 || d > 5) return { t: 'Closed', c: R }
230 if (h >= 9 && h < 9.25) return { t: 'ATO', c: A }
231 if (h >= 9.25 && h < 11.5) return { t: 'Open', c: G }
232 if (h >= 11.5 && h < 13) return { t: 'Lunch break', c: R }
233 if (h >= 13 && h < 14.5) return { t: 'Open', c: G }
234 if (h >= 14.5 && h < 14.75) return { t: 'ATC', c: A }
235 if (h >= 14.75 && h < 15) return { t: 'Post-close', c: A }
236 return { t: 'Closed', c: R }
237}
238
239const refresh = async ($: any) => {
240 try {
241 const q = await pull($)
242 await update($, quotes, () => q)
243 await pullIdx($).then(ix => update($, idx, () => ix), () => {}) // indices are best-effort
244 await update($, meta, () => ({ at: Date.now(), status: 'live', n: q.length }))
245 const up = q.filter(x => x.p > 0).length
246 const dn = q.filter(x => x.p < 0).length
247 $.ui.status(`VN ▲${up} ■${q.length - up - dn} ▼${dn}`)
248 } catch (err) {
249 void $.fs.write(`${stateDir($)}/vn-heatmap.err.log`, `${new Date().toISOString()} ${String((err as Error)?.stack ?? err)}
250`).catch(() => {})
251 await update($, meta, m => ({ ...m, status: `error: ${String((err as Error).message ?? err).slice(0, 60)}` }))
252 }
253}
254
255const COLOR = (x: Quote) => {
256 if (x.k === 'c') return '#9B59D0'
257 if (x.k === 'f') return '#17A2B8'
258 if (x.p >= 3) return '#0E8A3E'
259 if (x.p >= 1) return '#2FA55A'
260 if (x.p > 0) return '#5BBE7F'
261 if (x.p === 0) return '#C9A227'
262 if (x.p > -1) return '#D9706B'
263 if (x.p > -3) return '#C0392B'
264 return '#8E1B12'
265}
266const fmt = (p: number) => `${p > 0 ? '+' : ''}${p.toFixed(1)}`
267const wavg = (xs: Quote[]) => xs.reduce((a, x) => a + x.p * x.m, 0) / (xs.reduce((a, x) => a + x.m, 0) || 1)
268
269let timersOn = false // module-level: session.start does not re-run after a hot reload, so start lazily
270const startTimers = ($: any) => {
271 if (timersOn) return
272 timersOn = true
273 $.clock.every(OPEN_MS, async () => {
274 if (!isOpen) return
275 tick++
276 if (!isMarketOpen() && tick % CLOSED_EVERY !== 1) return
277 await refresh($)
278 })
279 $.clock.every(WATCH_MS, async () => {
280 if (!isOpen) return
281 wtick++
282 if (!isMarketOpen() && wtick % 40 !== 1) return // ~2 min while closed
283 await refreshWatch($)
284 })
285}
286
287export const register: Register = on => {
288 on('session.start', async ($, e, next) => {
289 await $.command.register({ name: 'vn-stock-watch', description: 'Live Vietnam stock-market heatmap and price board pane' })
290 try {
291 const saved = JSON.parse(await $.fs.read(listsFile($))) as Lists
292 if (Array.isArray(saved.watch) && saved.port) await update($, lists, () => saved)
293 } catch {} // first run: no file yet
294 return next(e)
295 })
296
297 on('command.run', { command: 'vn-stock-watch' }, async $ => {
298 isOpen = true
299 startTimers($)
300 tick = 0
301 wtick = 0
302 await $.ui.open({ id: PANE, title: 'VN Stock Watch' })
303 void refresh($)
304 void refreshWatch($)
305 if (!universe.length) void fetchUniverse($).then(u => { universe = u }, () => {})
306 return { text: 'VN Stock Watch opened (polls SSI iBoard every 15s while the market is open).' }
307 })
308
309 // Wheel over the treemap zooms (up = in: more, tighter labels; down = out: fewer, roomier); elsewhere it scrolls as usual.
310 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
311 const r = e.pointer ? e.pointer.row + scrollOff - gridTop : -1
312 if (e.origin.kind !== 'person' || !e.pointer || r < 0 || r >= gridLen) return next(e)
313 await update($, zoom, z => Math.max(0, Math.min(ZOOMS.length - 1, z - Math.sign(e.by))))
314 return {}
315 })
316
317 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
318 if (!isOpen) { // pane survived a /reload-plugins: module state reset, command.run not re-run
319 isOpen = true
320 startTimers($)
321 void refresh($)
322 void refreshWatch($)
323 }
324 try {
325 const { Box, Text, Button, Input, Select, Svg } = $.ui.resolve(e) as any
326 const desktop = e.surface === 'desktop'
327 const q = await read($, quotes)
328 const m = await read($, meta)
329 const v = await read($, view)
330 const sel = await read($, only)
331 const z = await read($, zoom)
332 const ix = await read($, idx)
333 const cols = Math.max(30, (e.props as any)?.bodyColumns ?? e.viewport?.columns ?? 80)
334 const rows = (e.viewport?.rows ?? 30) - 6
335
336 const age = m.at ? Math.round((Date.now() - m.at) / 1000) : 0
337
338 const sectors = [...new Set(q.map(x => x.g))].sort()
339 const picked = sel ? sel.split('|') : []
340 const pool = picked.length ? q.filter(y => picked.includes(y.g)) : q
341 const by = new Map<string, Quote[]>()
342 for (const x of pool) {
343 const key = v === 'sector' ? x.g : EXCHANGE[x.x] || '?'
344 by.set(key, [...(by.get(key) ?? []), x])
345 }
346 const groups: Group[] = [...by.entries()].map(([name, xs]) => {
347 const avg = wavg(xs)
348 const s = SHORT[name] ?? name.split(' ')[0] ?? name
349 return {
350 heads: [`${name} ${fmt(avg)}`, `${s} ${fmt(avg)}`, s, ...[TINY[name]].filter((x): x is string => !!x)],
351 tone: avg >= 0 ? '#7CE0A0' : '#FF8A80',
352 v: xs.reduce((s, x) => s + x.m, 0),
353 tiles: xs.map(x => ({ label: x.s, sub: fmt(x.p), bg: COLOR(x), v: x.m })),
354 }
355 }).sort((a, b) => b.v - a.v)
356 const n2 = (v: number) => v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
357 const ixs = ix.map(x => ({
358 key: x.name,
359 head: `${x.name} ${n2(x.price)} `,
360 chg: `${x.change > 0 ? '▲' : x.change < 0 ? '▼' : '■'}${n2(x.change)} (${n2(x.pct)}%)`,
361 color: x.change > 0 ? '#2FA55A' : x.change < 0 ? '#C0392B' : '#C9A227',
362 }))
363 const ixLen = ixs.reduce((n, x) => n + x.head.length + x.chg.length + 3, 0)
364 const ixRows = (ixs.length ? Math.ceil(ixLen / cols) : 0) + (sel && !desktop ? 1 : 0) // + chips row
365 const W = Math.max(30, cols - 1)
366 const narrow = W < 62 // short labels so no control row wraps
367 const bodyRows = (e.props as any)?.scroll?.bodyRows ?? (e.viewport?.rows ?? 30) - 3
368 const t = await read($, tab)
369 const l = await read($, lists)
370 const wr = await read($, wrows)
371 const wm = await read($, wmeta)
372 const wl = (await read($, wlrows)).slice(0, 8)
373 const q0 = await read($, wq)
374 const msg = await read($, wmsg)
375 const cap = Math.max(1, Math.floor(bodyRows * 0.3) - 4)
376 const kind = t === 'port' ? 'port' : 'vol'
377 // Two side-by-side tables only when the list overflows one AND each half still fits (dropping at most Buy/Sell);
378 // otherwise one table, dropping low-priority columns until it fits. Never wraps.
379 const dualCols = t !== 'port' && wr.length > cap ? fit(kind, Math.floor((W - 2) / 2), kind === 'vol' ? 0 : 2) : undefined
380 const dual = !!dualCols
381 const cs = dualCols ?? fit(kind, W, kind === 'vol' ? 0 : 99) ?? (kind === 'vol' ? COLS[kind] : COLS[kind].filter(c => !DROP[kind].includes(c.h)))
382 const shown = wr // pane scrolls: show every row instead of truncating
383 const half = dual ? Math.ceil(shown.length / 2) : shown.length
384 const H = Math.max(8, Math.floor(bodyRows * 0.55) - 3 - ixRows) // fixed share; tables below scroll
385 const grid = q.length && !desktop ? buildGrid(groups, W, H, z) : []
386 gridTop = 3 + ixRows // clock, status, buttons, index row(s)
387 gridLen = grid.length
388 scrollOff = (e.props as any)?.scroll?.offset ?? 0
389 const cut = (s: string) => (s.length > W ? s.slice(0, W) : s)
390 const head = cs.map(c => (c.left ? c.h.padEnd(c.w) : c.h.padStart(c.w))).join('')
391
392 const rowCell = (r: WRow) => {
393 const pos = l.port[r.s]
394 const bg = r.fl > 0 ? UP_BG : r.fl < 0 ? DN_BG : undefined
395 return (
396 <Text>
397 {cs.map(c => {
398 const v = c.cell(r, pos)
399 return <Text key={c.h} bold={v.b} dimColor={v.dim} color={v.c} backgroundColor={bg}>{c.left ? v.s.padEnd(c.w) : v.s.padStart(c.w)}</Text>
400 })}
401 </Text>
402 )
403 }
404
405 if (desktop) {
406 // Fill the pane: ~8px per column. Table zoom (A-/A+) scales the SVG text; once two tables fit side by side, it shows two.
407 const mapW = Math.max(480, Math.round(cols * 8))
408 const tz0 = await read($, tzoom)
409 const adj = await read($, mapadj)
410 const slIdx = Math.max(0, Math.min(SL_N - 1, Math.round(adj / SL_STEP) + SL_AUTO))
411 const table = (rs: WRow[], k: 'vol' | 'port' | 'wl', fs: number, pl = false) => {
412 const lh = Math.round(fs * 1.65)
413 const pxCells = (px: number) => Math.floor(px / (fs * 0.62))
414 const two = rs.length > 1 ? fit(k, pxCells((mapW - 24) / 2), k === 'vol' ? 3 : 2) : undefined // VN30 (vol): drops Room/F.Sell/F.Buy to fit two tables; Vol/Buy/Sell stay
415 const cs = two ?? fit(k, pxCells(mapW), k === 'vol' ? 0 : 99) ?? (k === 'vol' ? COLS[k] : COLS[k].filter(c => !DROP[k].includes(c.h)))
416 const h = ((two ? Math.ceil(rs.length / 2) : rs.length) + 1) * lh + 4
417 const svg = () => tableSvg(cs, rs.map(r => ({ cells: cs.map(c => c.cell(r, pl ? l.port[r.s] : undefined)), bg: r.fl > 0 ? '#2ECC71' : r.fl < 0 ? '#FF5A4D' : undefined } as TRow)), mapW, fs, !!two)
418 return { svg, h }
419 }
420 const tblH = (fs: number) => (wr.length ? table(wr, kind, fs, t === 'port').h : 40) + (wl.length ? table(wl, 'wl', fs).h : 0)
421 // ~19px per row; fixed chrome (header, selects, index row, 2 sliders, tab, total, search, hint, watchlist title + remove row) ≈ 400px.
422 const total = (e.viewport?.rows ?? 60) * 19 - 400
423 const clampMap = (h: number) => Math.max(200, Math.min(1100, Math.round(h)))
424 // Auto text size (tz0 < 0): the map keeps the space it gets at the default size (+ the map-size slider), and the tables take whatever height is left, so the biggest text that still fits wins. Smaller map => bigger text; bigger map => smaller text.
425 const refFs = TABLE_FS[tz0 >= 0 ? Math.min(TABLE_FS.length - 1, tz0) : TABLE_FS_DEF]!
426 const mapH = Math.max(160, Math.min(2400, clampMap(total - tblH(refFs)) + adj))
427 const autoIdx = TABLE_FS.map((_, i) => i).reverse().find(i => tblH(TABLE_FS[i]!) <= total - mapH) ?? 0
428 const tz = tz0 >= 0 ? Math.min(TABLE_FS.length - 1, tz0) : autoIdx
429 const fs = TABLE_FS[tz]!
430 const mainT = wr.length ? table(wr, kind, fs, t === 'port') : undefined
431 const wlT = wl.length ? table(wl, 'wl', fs) : undefined
432 return (
433 <Box flexDirection="column" backgroundColor="#202226" flexGrow={1}>
434 <Text bold>HCMC {new Date(Date.now() + 7 * 3600_000).toISOString().slice(11, 19)} <Text dimColor>{m.status === 'live' ? `${age}s ago` : m.status}<Text color={session().c}> · {session().t}</Text>{capsHave < capsOf ? ` · caps ${capsHave}/${capsOf}` : ''} · Source: SSI API</Text></Text>
435 <Box flexWrap="wrap">
436 <Select
437 key="sel-view"
438 label="Group by "
439 value={v}
440 options={[{ value: 'sector', label: 'Sector' }, { value: 'exchange', label: 'Exchange' }]}
441 onSelect={(x: string) => { void update($, view, () => x as View) }}
442 />
443 <Select
444 key="sel-filter"
445 label="Filter "
446 value="__sum"
447 options={[
448 { value: '__sum', label: picked.length ? picked.join(', ').slice(0, 40) : 'All sectors' },
449 { value: '__all', label: 'Clear filter (all sectors)' },
450 ...sectors.map(x => ({ value: x, label: `${picked.includes(x) ? '✓ ' : ' '}${x}` })),
451 ]}
452 onSelect={(x: string) => {
453 if (x === '__sum') return
454 void update($, only, cur => {
455 if (x === '__all') return ''
456 const c = cur ? cur.split('|') : []
457 return (c.includes(x) ? c.filter(y => y !== x) : [...c, x]).join('|')
458 })
459 }}
460 />
461 </Box>
462 {picked.length > 0 && (
463 <Box flexWrap="wrap">
464 <Text dimColor>Showing </Text>
465 {picked.map(x => <Button key={`chip-${x}`} label={`${x} ✕`} onPress={() => update($, only, cur => cur.split('|').filter(y => y !== x).join('|'))} />)}
466 </Box>
467 )}
468 {ixs.length > 0 && <Box flexWrap="wrap">{ixs.map(x => <Text key={x.key}><Text bold>{x.head}</Text><Text color={x.color}>{x.chg}{' '}</Text></Text>)}</Box>}
469 {q.length === 0 ? <Text dimColor>Loading…</Text> : <Svg source={buildSvg(groups, mapW, mapH)} alt="Vietnam stock-market treemap by market cap, coloured by daily change" />}
470 <Box>
471 <Text dimColor>Map size </Text>
472 {Array.from({ length: SL_N }, (_, k) => (
473 <Button key={`ms${k}`} plain label={k <= slIdx ? '█' : '░'} onPress={() => update($, mapadj, () => (k - SL_AUTO) * SL_STEP)} />
474 ))}
475 <Button key="map-auto" label="⟲" onPress={() => update($, mapadj, () => 0)} />
476 </Box>
477 <Box>
478 <Text dimColor>Text size </Text>
479 {TABLE_FS.map((_, k) => (
480 <Button key={`ts${k}`} plain label={k <= tz ? '█' : '░'} onPress={() => update($, tzoom, () => k)} />
481 ))}
482 <Button key="ts-auto" label="⟲" onPress={() => update($, tzoom, () => -1)} />
483 </Box>
484 <Box>
485 <Button key="tab-vn30" label="VN30" variant={t === 'vn30' ? 'primary' : undefined} onPress={() => setTab($, 'vn30')} />
486 </Box>
487 {!mainT ? <Text dimColor>{t === 'vn30' ? 'Loading VN30…' : 'Empty. Add a ticker below.'}</Text> : <Svg source={mainT.svg()} alt="Stock table" />}
488 {t === 'port' && wr.length > 0 && (() => {
489 const cost = wr.reduce((a, r) => a + (l.port[r.s] ? l.port[r.s].c * 1000 * l.port[r.s].q : 0), 0)
490 const val = wr.reduce((a, r) => a + (l.port[r.s] ? r.p * l.port[r.s].q : 0), 0)
491 const pl = val - cost
492 return <Text bold>Total Value {money(val)} <Text color={plc(pl)}>P&L {money(pl)} ({sgn(cost ? (pl / cost) * 100 : 0)}%)</Text></Text>
493 })()}
494 <Input
495 key="ticker"
496 label="Search "
497 placeholder={t === 'port' ? 'FPT 1000 62.4 (sym qty cost-in-k) · -FPT removes' : 'ticker, Enter adds · -FPT removes'}
498 value={q0}
499 submitLabel="add"
500 onInput={(v: string) => { void update($, wq, () => v) }}
501 onSubmit={(v: string) => { void submit($, v) }}
502 />
503 <Text dimColor>{q0.trim() ? suggest(universe, q0).map(x => `${x.code} (${x.name}.${x.ex === 'HOSE' ? 'HSX' : x.ex})`).join(' · ') || (universe.length ? 'no match' : 'loading tickers…') : msg || ' '}</Text>
504 {wlT && (
505 <Box flexDirection="column">
506 <Text bold>My Watchlist</Text>
507 <Svg source={wlT.svg()} alt="Watchlist table" />
508 <Box flexWrap="wrap">
509 <Text dimColor>Remove </Text>
510 {l.watch.map(sym => <Button key={`rm-${sym}`} label={`${sym} −`} onPress={() => { void submit($, `-${sym}`) }} />)}
511 </Box>
512 </Box>
513 )}
514 </Box>
515 )
516 }
517
518 return (
519 <Box flexDirection="column">
520 <Text bold>HCMC {new Date(Date.now() + 7 * 3600_000).toISOString().slice(11, 19)}</Text>
521 <Text dimColor>
522 {m.status === 'live' ? `${age}s ago` : m.status}
523 {` · ${session().t}`}
524 {capsHave < capsOf ? ` · caps ${capsHave}/${capsOf}` : ''}
525 {' · Source: SSI API'}
526 </Text>
527 <Box>
528 <Button key="by-sector" label="Sector" hotkey="s" variant={v === 'sector' ? 'primary' : undefined} onPress={() => update($, view, () => 'sector' as View)} />
529 <Button key="by-exchange" label={narrow ? 'Exch' : 'Exchange'} hotkey="e" variant={v === 'exchange' ? 'primary' : undefined} onPress={() => update($, view, () => 'exchange' as View)} />
530 <Select
531 key="sel-filter"
532 label={narrow ? '' : 'Filter '}
533 value="__sum"
534 options={[
535 { value: '__sum', label: picked.length ? (picked.length > 1 ? `${picked.length} sectors` : picked[0]!).slice(0, narrow ? 10 : 24) : narrow ? 'All' : 'All sectors' },
536 { value: '__all', label: 'Clear filter (all sectors)' },
537 ...sectors.map(x => ({ value: x, label: `${picked.includes(x) ? '✓ ' : ' '}${x}` })),
538 ]}
539 onSelect={(x: string) => {
540 if (x === '__sum') return
541 void update($, only, cur => {
542 if (x === '__all') return ''
543 const c = cur ? cur.split('|') : []
544 return (c.includes(x) ? c.filter(y => y !== x) : [...c, x]).join('|')
545 })
546 }}
547 />
548 <Button key="filter-default" label={narrow ? 'Reset' : 'Default'} hotkey="d" variant={sel ? undefined : 'primary'} onPress={() => update($, only, () => '')} />
549 </Box>
550 {picked.length > 0 && (
551 <Box flexWrap="wrap">
552 <Text dimColor>Showing </Text>
553 {picked.map(x => <Button key={`chip-${x}`} label={`${x} ✕`} onPress={() => update($, only, cur => cur.split('|').filter(y => y !== x).join('|'))} />)}
554 </Box>
555 )}
556 {ixs.length > 0 && (
557 <Box flexWrap="wrap">
558 {ixs.map(x => (
559 <Text key={x.key}>
560 <Text bold>{x.head}</Text>
561 <Text color={x.color}>{x.chg}{' '}</Text>
562 </Text>
563 ))}
564 </Box>
565 )}
566 {q.length === 0 && <Text dimColor>Loading…</Text>}
567 {grid.map((row, r) => (
568 <Box key={r}>
569 {runs(row).map((s, i) => (
570 <Text key={i} backgroundColor={s.bg} color={s.fg} bold={s.b}>{s.t}</Text>
571 ))}
572 </Box>
573 ))}
574 <Box>
575 <Button key="tab-vn30" label="VN30" variant={t === 'vn30' ? 'primary' : undefined} onPress={() => setTab($, 'vn30')} />
576 </Box>
577 <Text bold>{head}{dual && shown.length > half ? ` ${head}` : ''}</Text>
578 {shown.length === 0 && (
579 <Text dimColor>{t === 'vn30' ? 'Loading VN30…' : 'Empty. Add a ticker below.'}</Text>
580 )}
581 {Array.from({ length: half }, (_, i) => (
582 <Box key={i}>
583 {rowCell(shown[i]!)}
584 {dual && shown[i + half] && <Text>{' '}</Text>}
585 {dual && shown[i + half] && rowCell(shown[i + half]!)}
586 </Box>
587 ))}
588 {t === 'port' && wr.length > 0 && (() => {
589 const cost = wr.reduce((a, r) => a + (l.port[r.s] ? l.port[r.s].c * 1000 * l.port[r.s].q : 0), 0)
590 const val = wr.reduce((a, r) => a + (l.port[r.s] ? r.p * l.port[r.s].q : 0), 0)
591 const pl = val - cost
592 const tot: Record<string, string> = { Sym: 'Total', Value: money(val), 'P&L': money(pl), 'P&L%': `${sgn(cost ? (pl / cost) * 100 : 0)}%` }
593 return (
594 <Text bold>
595 {cs.map(c => {
596 const s = tot[c.h] ?? ''
597 return <Text key={c.h} color={c.h.startsWith('P&L') ? plc(pl) : undefined}>{c.left ? s.padEnd(c.w) : s.padStart(c.w)}</Text>
598 })}
599 </Text>
600 )
601 })()}
602 <Box>
603 <Button key="tab-port" label="My Watchlist" variant="primary" onPress={() => setTab($, 'port')} />
604 </Box>
605 <Box>
606 <Input
607 key="ticker"
608 label="Search "
609 placeholder={(t === 'port' ? 'FPT 1000 62.4 (sym qty cost-in-k) · -FPT removes' : t === 'vn30' ? 'ticker, Enter adds to watchlist' : 'ticker, Enter adds · -FPT removes').slice(0, Math.max(10, W - 12))}
610 value={q0}
611 submitLabel="add"
612 onInput={(v: string) => { void update($, wq, () => v) }}
613 onSubmit={(v: string) => { void submit($, v) }}
614 />
615 <Text dimColor>{' '}
616 {cut(q0.trim()
617 ? suggest(universe, q0).map(x => `${x.code} (${x.name}.${x.ex === 'HOSE' ? 'HSX' : x.ex})`).join(' · ') || (universe.length ? 'no match' : 'loading tickers…')
618 : msg || ' ').slice(0, Math.max(10, W - 24))}
619 </Text>
620 </Box>
621 {wl.length > 0 && (() => {
622 const wc = fit('wl', W, 99) ?? COLS.wl.filter(c => !DROP.wl.includes(c.h))
623 return (
624 <Box flexDirection="column">
625 <Text bold>My Watchlist</Text>
626 <Text bold>{wc.map(c => (c.left ? c.h.padEnd(c.w) : c.h.padStart(c.w))).join('')}</Text>
627 {wl.map(r => (
628 <Text key={r.s}>
629 {wc.map(c => {
630 const v = c.cell(r, undefined)
631 return <Text key={c.h} bold={v.b} dimColor={v.dim} color={v.c} backgroundColor={r.fl > 0 ? UP_BG : r.fl < 0 ? DN_BG : undefined}>{c.left ? v.s.padEnd(c.w) : v.s.padStart(c.w)}</Text>
632 })}
633 </Text>
634 ))}
635 <Box flexWrap="wrap">
636 <Text dimColor>Remove </Text>
637 {l.watch.map(sym => <Button key={`rm-${sym}`} label={`${sym} -`} onPress={() => { void submit($, `-${sym}`) }} />)}
638 </Box>
639 </Box>
640 )
641 })()}
642 </Box>
643 )
644 } catch (err) {
645 const msg = String((err as Error)?.stack ?? err)
646 void $.fs.write(`${stateDir($)}/vn-heatmap.err.log`, `${new Date().toISOString()} render ${msg}
647`).catch(() => {})
648 const { Box, Text } = $.ui.resolve(e) as any
649 return <Box flexDirection="column"><Text color="#FF8A80">render error: {msg.slice(0, 300)}</Text></Box>
650 }
651 })
652}
653
654const UP_BG = '#244D38'
655const DN_BG = '#583030'
656const plc = (v: number) => (v > 0 ? '#2FA55A' : v < 0 ? '#C0392B' : '#C9A227')
657
658// Sector header fallbacks (SSI ICB sectors), tried after the full name when a treemap group is narrow.
659const SHORT: Record<string, string> = {
660 'Oil & Gas': 'Oil&Gas', Chemicals: 'Chem', 'Basic Resources': 'Resourc', 'Construction & Materials': 'Constr',
661 'Industrial Goods & Services': 'Indust', 'Automobiles & Parts': 'Auto', 'Food & Beverage': 'Food',
662 'Personal & Household Goods': 'Househ', 'Health Care': 'Health', Retail: 'Retail', Media: 'Media',
663 'Travel & Leisure': 'Travel', Telecommunications: 'Telecom', Utilities: 'Util', Banks: 'Banks',
664 Insurance: 'Insur', 'Real Estate': 'Real Est', 'Financial Services': 'FinSvc', Technology: 'Tech',
665}
666const TINY: Record<string, string> = {
667 'Oil & Gas': 'O&G', Chemicals: 'Chm', 'Basic Resources': 'Res', 'Construction & Materials': 'Con',
668 'Industrial Goods & Services': 'Ind', 'Automobiles & Parts': 'Aut', 'Food & Beverage': 'F&B',
669 'Personal & Household Goods': 'HH', 'Health Care': 'HC', Retail: 'Ret', Media: 'Med', 'Travel & Leisure': 'Trv',
670 Telecommunications: 'Tel', Utilities: 'Utl', Banks: 'Bnk', Insurance: 'Ins', 'Real Estate': 'RE',
671 'Financial Services': 'FS', Technology: 'IT',
672}
673
674// Watch-table columns at minimum width (each value + 1 space); `fit` stretches them to the pane.
675type Cellv = { s: string; c?: string; dim?: boolean; b?: boolean }
676type Col = { h: string; w: number; left?: boolean; cell: (r: WRow, pos?: { q: number; c: number }) => Cellv }
677const LEAD: Col[] = [
678 { h: 'Sym', w: 5, left: true, cell: r => ({ s: r.s, c: tone(r), b: true }) },
679 { h: 'Price', w: 8, cell: r => ({ s: k1(r.p), c: tone(r) }) },
680 { h: 'Chg', w: 7, cell: r => ({ s: sgn(r.ch / 1000), c: tone(r) }) },
681 { h: '%', w: 8, cell: r => ({ s: `${sgn(r.pct)}%`, c: tone(r) }) },
682]
683const rc = (v?: number | null) => (v == null ? { s: '' } : { s: `${sgn(v)}%`, c: plc(v) })
684const COLS: Record<'vol' | 'port' | 'wl', Col[]> = {
685 vol: [
686 ...LEAD,
687 { h: 'Vol', w: 8, cell: r => ({ s: vol(r.vol), dim: true }) },
688 { h: 'Buy', w: 8, cell: r => ({ s: vol(r.bu ?? 0), c: '#2FA55A' }) },
689 { h: 'Sell', w: 8, cell: r => ({ s: vol(r.sd ?? 0), c: '#C0392B' }) },
690 { h: 'F.Buy', w: 8, cell: r => ({ s: vol(r.fb ?? 0), c: '#2FA55A' }) },
691 { h: 'F.Sell', w: 8, cell: r => ({ s: vol(r.fs ?? 0), c: '#C0392B' }) },
692 { h: 'Room', w: 10, cell: r => { const v = r.fr ?? 0; return v < 0 ? { s: `-${vol(-v)}`, c: '#C0392B' } : { s: vol(v), dim: true } } },
693 ],
694 wl: [
695 ...LEAD,
696 { h: 'Vol', w: 8, cell: r => ({ s: vol(r.vol), dim: true }) },
697 { h: 'Buy', w: 8, cell: r => ({ s: vol(r.bu ?? 0), c: '#2FA55A' }) },
698 { h: 'Sell', w: 8, cell: r => ({ s: vol(r.sd ?? 0), c: '#C0392B' }) },
699 { h: 'F.Buy', w: 8, cell: r => ({ s: vol(r.fb ?? 0), c: '#2FA55A' }) },
700 { h: 'F.Sell', w: 8, cell: r => ({ s: vol(r.fs ?? 0), c: '#C0392B' }) },
701 { h: 'Room', w: 10, cell: r => { const v = r.fr ?? 0; return v < 0 ? { s: `-${vol(-v)}`, c: '#C0392B' } : { s: vol(v), dim: true } } },
702 { h: '1M', w: 8, cell: r => rc(r.rt?.m1) },
703 { h: '3M', w: 8, cell: r => rc(r.rt?.m3) },
704 { h: 'YTD', w: 8, cell: r => rc(r.rt?.y) },
705 ],
706 port: [
707 ...LEAD,
708 { h: 'Vol', w: 8, cell: r => ({ s: vol(r.vol), dim: true }) },
709 { h: 'Buy', w: 8, cell: r => ({ s: vol(r.bu ?? 0), c: '#2FA55A' }) },
710 { h: 'Sell', w: 8, cell: r => ({ s: vol(r.sd ?? 0), c: '#C0392B' }) },
711 { h: 'F.Buy', w: 8, cell: r => ({ s: vol(r.fb ?? 0), c: '#2FA55A' }) },
712 { h: 'F.Sell', w: 8, cell: r => ({ s: vol(r.fs ?? 0), c: '#C0392B' }) },
713 { h: 'Room', w: 10, cell: r => { const v = r.fr ?? 0; return v < 0 ? { s: `-${vol(-v)}`, c: '#C0392B' } : { s: vol(v), dim: true } } },
714 { h: '1M', w: 8, cell: r => rc(r.rt?.m1) },
715 { h: '3M', w: 8, cell: r => rc(r.rt?.m3) },
716 { h: 'YTD', w: 8, cell: r => rc(r.rt?.y) },
717 { h: 'Qty', w: 8, cell: (r, p) => ({ s: p ? String(p.q) : '', c: tone(r) }) },
718 { h: 'Cost', w: 8, cell: (r, p) => ({ s: p ? p.c.toFixed(2) : '', c: tone(r) }) },
719 { h: 'Value', w: 9, cell: (r, p) => ({ s: p ? money(r.p * p.q) : '', c: tone(r) }) },
720 { h: 'P&L', w: 9, cell: (r, p) => { const v = p ? (r.p - p.c * 1000) * p.q : 0; return { s: p ? money(v) : '', c: plc(v) } } },
721 { h: 'P&L%', w: 9, cell: (r, p) => { const v = p ? (r.p / (p.c * 1000) - 1) * 100 : 0; return { s: p ? `${sgn(v)}%` : '', c: plc(v) } } },
722 ],
723}
724// least useful first: dropped one by one until the table fits
725const DROP: Record<'vol' | 'port' | 'wl', string[]> = { vol: ['Room', 'F.Sell', 'F.Buy', 'Sell', 'Buy', 'Chg', 'Vol'], wl: ['Room', 'F.Sell', 'F.Buy', 'Sell', 'Buy', 'Vol', 'Chg'], port: ['Room', 'F.Sell', 'F.Buy', 'Sell', 'Buy', 'Vol', 'Chg', 'Cost', 'Qty', '%', 'Value', 'P&L%'] }
726const fit = (k: 'vol' | 'port' | 'wl', room: number, maxDrop: number): Col[] | undefined => {
727 let cs = COLS[k]
728 for (let i = 0; ; i++) {
729 const need = cs.reduce((a, c) => a + c.w, 0)
730 if (need <= room) {
731 const ex = Math.floor((room - need) / cs.length)
732 return cs.map(c => ({ ...c, w: c.w + ex }))
733 }
734 if (i >= Math.min(maxDrop, DROP[k].length)) return undefined
735 cs = cs.filter(c => c.h !== DROP[k][i])
736 }
737}
738hooks/treemap.ts 142 lines1// Squarified treemap rasterised onto a character grid (cells are ~2x taller than wide).
2export type Tile = { label: string; sub: string; bg: string; v: number }
3export type Group = { heads: string[]; tone: string; v: number; tiles: Tile[] } // heads: longest first, first that fits wins
4export type Cell = { ch: string; bg: string; fg: string; b: boolean }
5
6const GAP = '#1e1e1e'
7const HEAD = '#2b2b2b'
8const MIN = 12 // subcells (cols x half-rows) a tile needs to be drawn on its own
9const BLANK: Cell = { ch: ' ', bg: GAP, fg: '#FFFFFF', b: false }
10
11const sum = (a: number[]) => a.reduce((s, x) => s + x, 0)
12
13export const squarify = (vals: number[], x: number, y: number, w: number, h: number): number[][] => {
14 const total = sum(vals)
15 if (!vals.length || total <= 0 || w <= 0 || h <= 0) return vals.map(() => [x, y, 0, 0])
16 const k = (w * h) / total
17 const areas = vals.map(v => v * k)
18 const worst = (r: number[], s: number) => {
19 const m = sum(r)
20 return Math.max(...r.map(a => Math.max((s * s * a) / (m * m), (m * m) / (s * s * a))))
21 }
22 const out: number[][] = []
23 let i = 0
24 while (i < areas.length) {
25 const s = Math.min(w, h)
26 const row = [areas[i]]
27 let j = i + 1
28 while (j < areas.length && worst([...row, areas[j]], s) <= worst(row, s)) row.push(areas[j++])
29 const m = sum(row)
30 if (w >= h) {
31 const cw = m / h
32 let cy = y
33 for (const a of row) { out.push([x, cy, cw, a / cw]); cy += a / cw }
34 x += cw; w -= cw
35 } else {
36 const rh = m / w
37 let cx = x
38 for (const a of row) { out.push([cx, y, a / rh, rh]); cx += a / rh }
39 y += rh; h -= rh
40 }
41 i = j
42 }
43 return out
44}
45
46// Half-block raster: each cell is two square "subrows" drawn as ▀ (fg = top half, bg = bottom half), so tile
47// edges land on half rows and thin tiles still show. Text needs a whole cell, so labels sit on full rows only.
48// Terminal text can't shrink, so a zoom level is "how much room a label needs": pad = blank cols around the
49// ticker, minH = text rows a tile must have before it is labelled. Every tile is drawn; small ones stay colour only.
50export const ZOOMS = [
51 { pad: 2, minH: 2 }, // big: only roomy tiles labelled, ticker over change
52 { pad: 0, minH: 2 },
53 { pad: 1, minH: 1 },
54 { pad: 0, minH: 1 }, // small: every tile that fits a bare ticker
55]
56export const buildGrid = (groups: Group[], W: number, H: number, zoom = 0): Cell[][] => {
57 const { pad, minH } = ZOOMS[Math.max(0, Math.min(ZOOMS.length - 1, zoom))]!
58 const S = H * 2
59 const px: string[][] = Array.from({ length: S }, () => Array.from({ length: W }, () => GAP))
60 const tx: (Cell | undefined)[][] = Array.from({ length: H }, () => Array.from({ length: W }, () => undefined))
61 const fill = (x0: number, y0: number, x1: number, y1: number, bg: string) => {
62 for (let r = Math.max(0, y0); r < Math.min(S, y1); r++) for (let c = Math.max(0, x0); c < Math.min(W, x1); c++) px[r][c] = bg
63 }
64 const text = (r: number, c: number, s: string, fg: string, bg: string, b: boolean) => {
65 for (let i = 0; i < s.length; i++) if (r >= 0 && r < H && c + i >= 0 && c + i < W) tx[r][c + i] = { ch: s[i], bg, fg, b }
66 }
67 const box = (q: number[]) => {
68 const x0 = Math.round(q[0]), y0 = Math.round(q[1])
69 return [x0, y0, Math.round(q[0] + q[2]), Math.round(q[1] + q[3])]
70 }
71
72 // a cell is ~2x taller than wide, so a subrow is roughly square: lay out directly in (col, subrow) units
73 const gr = squarify(groups.map(x => x.v), 0, 0, W, S)
74 groups.forEach((grp, i) => {
75 const [x0, y0, x1, y1] = box(gr[i])
76 const w = x1 - x0, h = y1 - y0
77 if (w < 3 || h < 2) return
78 let ty0 = y0
79 const head = h >= 6
80 if (head) ty0 = Math.ceil(y0 / 2) * 2 + 2
81 // drop the small-cap tail (tiles under ~3x2 cells read as confetti) and let the rest fill the group;
82 // then drop trailing slivers (thinner than 2 cols or 2 subrows) until the layout is clean
83 const area = w * (y1 - ty0), tot = grp.tiles.reduce((s, t) => s + t.v, 0)
84 let tiles = [...grp.tiles].sort((p, q) => q.v - p.v).filter((t, n) => n === 0 || (t.v / tot) * area >= MIN)
85 let tr = squarify(tiles.map(t => t.v), x0, ty0, w, y1 - ty0)
86 const thin = (q: number[]) => { const [a0, b0, a1, b1] = box(q); return a1 - a0 < 3 || b1 - b0 < 3 }
87 while (tiles.length > 1 && tr.some(thin)) {
88 tiles = tiles.slice(0, -1)
89 tr = squarify(tiles.map(t => t.v), x0, ty0, w, y1 - ty0)
90 }
91 if (head) {
92 const hr = ty0 / 2 - 1 // first whole row inside the group carries the header text
93 fill(x0, y0, x1 - 1, ty0, HEAD) // last col stays GAP so adjacent headers don't merge
94 const hid = grp.tiles.length - tiles.length
95 const heads = hid ? [...grp.heads.map(x => `${x} · +${hid}`), ...grp.heads] : grp.heads
96 const label = heads.find(x => x.length <= w - 2) // never cut mid-word: shorter form or nothing
97 if (label) text(hr, x0 + 1, label, grp.tone, HEAD, true)
98 }
99 tiles.forEach((t, j) => {
100 const [a0, b0, a1, b1] = box(tr[j])
101 const tw = a1 - a0, th = b1 - b0
102 if (tw < 1 || th < 1) return
103 // gutters: 1 col right, 1 subrow (half a row) bottom, so gaps look equal in both axes
104 const iw = tw >= 2 ? tw - 1 : tw
105 const ib1 = th >= 2 ? b1 - 1 : b1
106 fill(a0, b0, a0 + iw, ib1, t.bg)
107 const r0 = Math.ceil(b0 / 2), r1 = Math.floor(ib1 / 2) // whole rows [r0, r1) fully inside the tile
108 const nr = r1 - r0
109 const ok = (s: string) => s.length <= iw - pad
110 const label = t.label // never truncated: "T1" for T11 would name the wrong ticker
111 if (nr < minH || !ok(label)) return // too small to name: colour only
112 const mid = (s: string) => a0 + Math.floor((iw - s.length) / 2)
113 const sub = t.sub && ok(t.sub) ? t.sub : undefined
114 if (nr >= 2) {
115 const top = r0 + Math.floor((nr - (sub ? 2 : 1)) / 2)
116 text(top, mid(label), label, '#FFFFFF', t.bg, true)
117 if (sub) text(top + 1, mid(sub), sub, '#FFFFFF', t.bg, false)
118 } else {
119 const s = sub && ok(`${label} ${sub}`) ? `${label} ${sub}` : label
120 text(r0, mid(s), s, '#FFFFFF', t.bg, true)
121 }
122 })
123 })
124 return Array.from({ length: H }, (_, r) =>
125 Array.from({ length: W }, (_, c) => {
126 const t = tx[r][c]
127 if (t) return t
128 const top = px[r * 2][c], bot = px[r * 2 + 1][c]
129 return top === bot ? { ...BLANK, bg: top } : { ch: '▀', bg: bot, fg: top, b: false }
130 }))
131}
132
133export const runs = (row: Cell[]) => {
134 const o: { t: string; bg: string; fg: string; b: boolean }[] = []
135 for (const c of row) {
136 const l = o[o.length - 1]
137 if (l && l.bg === c.bg && l.fg === c.fg && l.b === c.b) l.t += c.ch
138 else o.push({ t: c.ch, bg: c.bg, fg: c.fg, b: c.b })
139 }
140 return o
141}
142hooks/svgmap.ts 89 lines1// Pixel treemap as an SVG string for the desktop surface (terminal uses the cell grid in treemap.ts).
2import { squarify } from './treemap'
3import type { Group } from './treemap'
4
5const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
6
7const r = (n: number) => Math.round(n * 10) / 10
8
9const build = (groups: Group[], W: number, H: number, minTile: number): string => {
10 const out: string[] = [`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="system-ui,sans-serif"><rect width="${W}" height="${H}" fill="#202226"/>`]
11 // sector headers scale with the map: ~11px on a small pane up to 20px on a wide one
12 const hf = Math.round(Math.max(11, Math.min(20, W / 70, H / 28)))
13 const HEAD_H = hf + 6
14 const gr = squarify(groups.map(g => g.v), 0, 0, W, H)
15 groups.forEach((g, i) => {
16 const [gx, gy, gw, gh] = gr[i]!
17 if (gw < 4 || gh < 4) return
18 const head = gh > HEAD_H * 2.5 && gw > 40
19 out.push(`<rect x="${gx + 1}" y="${gy + 1}" width="${gw - 2}" height="${gh - 2}" fill="#2a2c31"/>`)
20 if (head) {
21 const label = g.heads.find(s => s.length * hf * 0.6 < gw - 8)
22 if (label) out.push(`<text x="${gx + 5}" y="${r(gy + hf + 1)}" font-size="${hf}" font-weight="700" fill="${g.tone}">${esc(label)}</text>`)
23 }
24 const ty = gy + (head ? HEAD_H : 1)
25 const th = gh - (head ? HEAD_H : 1) - 1
26 if (th < 4) return
27 const tr = squarify(g.tiles.map(t => t.v), gx + 1, ty, gw - 2, th)
28 g.tiles.forEach((t, j) => {
29 const [x, y, w, h] = tr[j]!
30 if (w < minTile || h < minTile) return
31 out.push(`<rect x="${r(x + 0.5)}" y="${r(y + 0.5)}" width="${r(Math.max(0, w - 1))}" height="${r(Math.max(0, h - 1))}" fill="${t.bg}"/>`)
32 const fs = Math.max(8, Math.min(18, w / (t.label.length * 0.75), h / 2.4))
33 if (w < t.label.length * fs * 0.62 || h < fs + 2) return // can't name it: colour only
34 const cx = x + w / 2
35 const two = h >= fs * 2.2
36 out.push(`<text x="${r(cx)}" y="${r(y + h / 2 + (two ? -1 : fs * 0.35))}" font-size="${fs.toFixed(1)}" font-weight="700" fill="#fff" text-anchor="middle">${esc(t.label)}</text>`)
37 if (two) out.push(`<text x="${r(cx)}" y="${r(y + h / 2 + fs * 0.95)}" font-size="${(fs * 0.75).toFixed(1)}" fill="#fff" text-anchor="middle">${esc(t.sub)}</text>`)
38 })
39 })
40 out.push('</svg>')
41 return out.join('')
42}
43
44// Svg source is capped at 131072 chars: raise the smallest drawn tile until it fits.
45export const buildSvg = (groups: Group[], W: number, H: number): string => {
46 let out = ''
47 for (const min of [3, 5, 8, 12, 20, 40]) {
48 out = build(groups, W, H, min)
49 if (out.length < 120_000) break
50 }
51 return out
52}
53
54// Table as SVG so zoom can scale the text (desktop Text has no size). `dual` = two side-by-side halves.
55export type TCell = { s: string; c?: string; dim?: boolean; b?: boolean }
56export type THead = { h: string; w: number; left?: boolean }
57export type TRow = { cells: TCell[]; bg?: string }
58export const TABLE_FS = Array.from({ length: 16 }, (_, i) => 7 + i) // px, 1px steps 7-22 (text-size slider)
59export const TABLE_FS_DEF = 6 // 13px
60
61export const tableSvg = (heads: THead[], rows: TRow[], W: number, fs: number, dual: boolean): string => {
62 const lh = Math.round(fs * 1.65)
63 const gap = dual ? 24 : 0
64 const bw = dual ? (W - gap) / 2 : W
65 const per = dual ? Math.ceil(rows.length / 2) : rows.length
66 const H = (per + 1) * lh + 4
67 const total = heads.reduce((a, c) => a + c.w, 0)
68 const out: string[] = [`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="system-ui,sans-serif" font-size="${fs}">`]
69 const block = (x0: number, rs: TRow[]) => {
70 let cx = x0
71 const cols = heads.map(c => { const w = (bw * c.w) / total; const o = { c, x: cx, w }; cx += w; return o })
72 const put = (o: { c: THead; x: number; w: number }, y: number, t: TCell | string) => {
73 const cell = typeof t === 'string' ? { s: t, b: true } : t
74 const left = !!o.c.left
75 out.push(`<text x="${r(left ? o.x + 4 : o.x + o.w - 4)}" y="${r(y + lh / 2 + fs * 0.35)}" text-anchor="${left ? 'start' : 'end'}" fill="${cell.c ?? '#e6e6e6'}"${cell.b ? ' font-weight="700"' : ''}${cell.dim ? ' opacity=".6"' : ''}>${esc(cell.s)}</text>`)
76 }
77 cols.forEach(o => put(o, 0, o.c.h))
78 rs.forEach((row, i) => {
79 const y = (i + 1) * lh
80 if (row.bg) out.push(`<rect x="${r(x0)}" y="${y}" width="${r(bw)}" height="${lh}" fill="${row.bg}" fill-opacity=".25"/>`)
81 cols.forEach((o, k) => put(o, y, row.cells[k]!))
82 })
83 }
84 block(0, rows.slice(0, per))
85 if (dual) block(bw + gap, rows.slice(per))
86 out.push('</svg>')
87 return out.join('')
88}
89hooks/watch.ts 81 lines1// SSI iBoard quotes for the watchlist strip (public REST, see docs/ssi-iboard-api.md).
2// ponytail: 3s REST polling; the real push feed is MQTT+protobuf at
3// wss://price-streaming.ssi.com.vn/mqtt, needs a spawned helper since mods have no WebSocket.
4import type { Lists, Quote, WRow } from './types'
5
6export const SSI = 'https://iboard-query.ssi.com.vn'
7export const MAX = 200 // CONFIG_LIMIT_STOCK_MULTIPLE_QUERY in the iBoard bundle
8export const HEADERS = { 'user-agent': 'Mozilla/5.0', referer: 'https://iboard.ssi.com.vn/', accept: 'application/json' }
9
10// matchedPrice is 0 before the first match; during ATO/ATC the board shows the expected price.
11export const toRow = (x: any): WRow => {
12 const live = x.matchedPrice > 0
13 const p = live ? x.matchedPrice : x.expectedMatchedPrice > 0 ? x.expectedMatchedPrice : x.refPrice
14 const ch = live ? x.priceChange : x.expectedMatchedPrice > 0 ? x.expectedPriceChange : 0
15 const pct = live ? x.priceChangePercent : x.expectedMatchedPrice > 0 ? x.expectedPriceChangePercent : 0
16 return {
17 s: x.stockSymbol, ex: String(x.exchange ?? '').toUpperCase(), p, ref: x.refPrice, ch: ch ?? 0, pct: pct ?? 0,
18 vol: x.nmTotalTradedQty ?? 0, hi: x.highest ?? 0, lo: x.lowest ?? 0,
19 bu: x.stockBUVol ?? 0, sd: x.stockSDVol ?? 0, fb: x.buyForeignQtty ?? 0, fs: x.sellForeignQtty ?? 0, fr: x.remainForeignQtty ?? 0, fl: 0, // active buy/sell volume; fb/fs/fr = foreign buy/sell qty and remaining room; fl = price flash vs last poll (set by caller)
20 k: p > 0 && p >= x.ceiling ? 'c' : p > 0 && p <= x.floor ? 'f' : '',
21 }
22}
23
24export const STATS = 'https://iboard-api.ssi.com.vn/statistics'
25export const EXCHANGE = ['', 'HOSE', 'HNX', 'UPCOM']
26
27// Heatmap tile from an SSI quote: size = listed shares x price (market cap). No shares yet = no tile.
28export const toQuote = (x: any, sector: Map<string, string>, shares: Record<string, number>): Quote | undefined => {
29 const r = toRow(x)
30 const n = shares[r.s]
31 if (!n || !(r.p > 0)) return
32 return { s: r.s, p: r.pct, m: n * r.p, g: sector.get(r.s) ?? 'Other', x: EXCHANGE.indexOf(r.ex), k: r.k }
33}
34
35// /company/sectors-data -> symbol => ICB sector (English)
36export const sectorsOf = (data: any[]) =>
37 new Map<string, string>(data.flatMap(i => (i.listCompany ?? []).map((c: any) => [c.symbol, i.industryName?.en ?? 'Other'])))
38
39export type Ticker = { code: string; name: string; ex: string }
40export const toTicker = (x: any): Ticker => ({ code: x.code, name: x.clientNameEn || x.clientName || '', ex: x.exchange })
41
42// Unwraps SSI's { code, message, data } envelope.
43export const rowsOf = (status: number, ok: boolean, text: string) => {
44 if (!ok) throw new Error(`SSI ${status}`)
45 const d = JSON.parse(text)
46 if (!Array.isArray(d.data)) throw new Error(`SSI ${d.message ?? 'bad shape'}`)
47 return d.data as any[]
48}
49
50export const suggest = (all: Ticker[], q: string, n = 6) => {
51 const t = q.trim().toUpperCase().split(/\s+/)[0] ?? ''
52 if (!t) return []
53 const pre = all.filter(x => x.code.startsWith(t))
54 return pre.sort((a, b) => a.code.length - b.code.length || a.code.localeCompare(b.code)).slice(0, n)
55}
56
57// Input grammar: "FPT" adds, "-FPT" removes; on the portfolio tab "FPT 1000 62.4" sets qty and
58// cost (cost in thousand VND, as the board shows prices).
59export type Cmd = { op: 'add'; s: string; q?: number; c?: number } | { op: 'del'; s: string } | { op: 'bad'; why: string }
60export const parse = (text: string, known: (s: string) => boolean, port: boolean): Cmd => {
61 const [a = '', q, c] = text.trim().toUpperCase().split(/\s+/)
62 if (a.startsWith('-')) return { op: 'del', s: a.slice(1) }
63 if (!known(a)) return { op: 'bad', why: `unknown ticker ${a || '(empty)'}` }
64 if (!port) return { op: 'add', s: a }
65 const qty = Number(q), cost = Number(c)
66 if (!(qty > 0) || !(cost > 0)) return { op: 'bad', why: 'portfolio: SYM QTY COST, e.g. FPT 1000 62.4' }
67 return { op: 'add', s: a, q: qty, c: cost }
68}
69
70export const apply = (l: Lists, cmd: Cmd, port: boolean): Lists => {
71 if (cmd.op === 'bad') return l
72 if (port) {
73 const p = { ...l.port }
74 if (cmd.op === 'del') delete p[cmd.s]
75 else p[cmd.s] = { q: cmd.q!, c: cmd.c! }
76 return { ...l, port: p }
77 }
78 const w = l.watch.filter(s => s !== cmd.s)
79 return { ...l, watch: cmd.op === 'add' ? [...w, cmd.s].slice(-MAX) : w }
80}
81hooks/types.d.ts 17 lines1export type Quote = { s: string; p: number; m: number; g: string; x: number; k: 'c' | 'f' | '' }
2export type Meta = { at: number; status: string; n: number }
3export type Idx = { name: string; price: number; change: number; pct: number }
4export type View = 'sector' | 'exchange'
5export type WRow = { s: string; ex: string; p: number; ref: number; ch: number; pct: number; vol: number; hi: number; lo: number; bu: number; sd: number; fb: number; fs: number; fr: number; fl: number; rt?: { m1: number | null; m3: number | null; y: number | null }; k: 'c' | 'f' | '' }
6export type Tab = 'vn30' | 'watch' | 'port'
7export type Lists = { watch: string[]; port: Record<string, { q: number; c: number }> }
8
9declare module 'claude-code' {
10 interface PluginState {
11 'vn-stockmarket-heatmap': {
12 quotes: Quote[]; meta: Meta; view: View; only: string; idx: Idx[]
13 tab: Tab; lists: Lists; wrows: WRow[]; wlrows: WRow[]; wmeta: Meta; wq: string; wmsg: string; zoom: number; tzoom: number; mapadj: number
14 }
15 }
16}
17