SLOPSHOPPER

stock-watch

/stock: watch tickers and get a chime when one reaches the low or high you set. Works any time, in any session.

newpanecommandtoaststatusprompt
★ 1v0.3.0no licenseupdated 2026-10-13TroyJLorents-GH/mod-squad/mods/stock-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stock-watch
│ ┃ Stock watch ✕ › fix the failing auth test and add an audit log call │ ┃ Add: AAPL or AAPL 180 200 or NVDA hi │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Check now ] [ Test sound ] [ Mute ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ │ Nothing watched yet. Type a ticker above, ⏺ Bash(bun test) │ ┃ │ example AAPL to watch the price, or AAPL 1 ⎿ 3 pass, 1 fail │ ┃ │ alert at a low and a high. │ ┃ ╰─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Checked every 60s while a session is open. P │ ┃ from Yahoo Finance and can lag. ✻ Worked for 42s · done 4:20 PM │ │ › /stock │ ⎿ stock-watch: Stock watch: nothing watched yet. Usage: /stock add │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Stock watch
Add: AAPL or AAPL 180 200 or NVDA high 140 [ Check now ] [ Test sound ] [ Mute ] ╭──────────────────────────────────────────────────────╮ │ Nothing watched yet. Type a ticker above, for │ │ example AAPL to watch the price, or AAPL 180 200 to │ │ alert at a low and a high. │ ╰──────────────────────────────────────────────────────╯ Checked every 60s while a session is open. Prices come from Yahoo Finance and can lag.
README

stock-watch

Watch stock and crypto prices from inside Claude Code, and get a chime and a toast when one reaches a low or a high you set. Or set no limits at all and just keep an eye on the price. It's a mod: a plugin whose code runs inside Claude Code.

It shows your tickers on one small line under the prompt:

The stock-watch status line under the prompt

stock-watch  AAPL 342.46 ▲ · NVDA 227.35 ▼ · BTC-USD 83,094 ▲ +2
  • ▲ / ▼: the price is above or below today's open. (Until the open is known, it compares with the previous close.)
  • +2: two more tickers are watched but not shown. The /stock pane shows all of them.
  • HIGH / LOW: appears after a ticker once it has hit one of your limits, for example AAPL 351.20 ▲ HIGH.

The pictures in this README are illustrations drawn to match what the mod prints. They are not screenshots of a live session, and the prices in them are examples.

Quick start

/stock add AAPL                        just watch the price, no alerts
/stock add AAPL low 335 high 350       alert at 335 or below, and at 350 or above
/stock NVDA 120 140                    shorthand: low then high
/stock add BTC-USD high 90000          one limit is fine

Each command answers with what it did:

> /stock add AAPL
Watching AAPL: price only, no alerts. Add a limit any time with /stock add AAPL low 180 high 200.

> /stock add AAPL low 335 high 350
Watching AAPL, alert at or below 335.00, alert at or above 350.00.

> /stock add BTC-USD high 90000
Watching BTC-USD, alert at or above 90,000.

What an alert looks like

When a price reaches a limit you get a toast that stays for 30 seconds, and a chime. A rising chime means your high was reached, a falling chime means your low was reached.

Two alert toasts, one for a high and one for a low

📈 AAPL reached your high: 350.40 USD (limit 350.00)
📉 NVDA fell to your low: 118.90 USD (limit 120.00)
  • Each limit alerts once. It re-arms when the price comes back inside your low and high, or when you change the limit.
  • A price already past a limit when you add it alerts on the first check.
  • A ticker with no limits never alerts. It only shows its price.

High and low

Say the limits with words or with two prices:

You typeMeaning
/stock add AAPL low 335 high 350Alert at or below 335, and at or above 350.
/stock add AAPL high 350Only the high.
/stock add AAPL below 335below and under mean low.
/stock add AAPL above 350above and over mean high.
/stock AAPL 350 335Two prices: the smaller is the low, the larger is the high.
/stock AAPL 350One bare price is ambiguous, so it asks you to say low or high.

The low must be below the high. Run the same command again to change a limit. It keeps the ticker and replaces only what you give it.

Chime on and off

The chime is the sound. The toast always shows, so you never miss an alert.

> /stock chime off
Stock watch: chime off for every ticker. Toasts still show.

> /stock chime on
Stock watch: chime on.

> /stock AAPL chime off
Stock watch: AAPL chime off. Its toast still shows.

> /stock AAPL chime on
Stock watch: AAPL chime on.
  • /stock chime off and /stock chime on set it for every ticker. mute and unmute do the same thing.
  • /stock AAPL chime off and /stock AAPL chime on set it for one ticker. A ticker with its chime off shows chime off on its row in the pane.
  • The Alert sound setting can also turn the chime off for good, or add a spoken ticker on macOS.

Removing a ticker

> /stock rm AAPL
Stopped watching AAPL.
  • Quick: /stock rm AAPL, or the Remove button on its row in the pane.
  • Permanently: a ticker in the Watch list setting comes back whenever that setting's text changes, because every entry in it is re-added. To remove it for good, run /stock rm AAPL and also delete its entry from the Watch list.

The pane

/stock opens a pane with a box to add tickers, and one pill per ticker. Each pill has a border that is green when the price is above today's open and red when it is below. The colours don't show in text, but this is the layout:

The /stock pane with a green AAPL pill, a red NVDA pill and a BTC-USD pill

 Add  [ AAPL   or   AAPL 180 200   or   NVDA high 140          ]

 [Check now]  [Test sound]  [Mute]

 ╭──────────────────────────────────────────────────────────╮
 │ ╭──────────────────────────────────────────────────────╮ │
 │ │ AAPL     342.46 ▲0.63%                     [Remove]  │ │   green border
 │ │ low 335.00  [·······●········]  high 350.00          │ │
 │ ╰──────────────────────────────────────────────────────╯ │
 │ ╭──────────────────────────────────────────────────────╮ │
 │ │ NVDA     118.90 ▼2.10%  chime off  LOW HIT [Remove]  │ │   red border
 │ │ low 120.00  [●···············]  high 140.00          │ │
 │ ╰──────────────────────────────────────────────────────╯ │
 │ ╭──────────────────────────────────────────────────────╮ │
 │ │ BTC-USD  83,094.31 ▲1.40%                  [Remove]  │ │   green border, no limits
 │ ╰──────────────────────────────────────────────────────╯ │
 ╰──────────────────────────────────────────────────────────╯
 Checked every 60s while a session is open. Prices come from Yahoo Finance and can lag.
  • The percentage is the change since today's open.
  • The bar shows where the price sits between your low and high. ! marks a price at a limit. A ticker with no limits has no bar.
  • LOW HIT and HIGH HIT show after a limit alerts.
  • If a price can't be fetched, the reason shows in amber on that row, and the other tickers keep updating.

Commands

CommandDoes
/stockOpens the pane.
/stock add AAPLWatches the price only. No limits, so no alerts and no chime.
/stock add AAPL low 180 high 200Watches a ticker with limits. Either limit alone is fine.
/stock AAPL 180 200Shorthand: the two prices become the low and the high, in either order.
/stock rm AAPLStops watching it and removes its pill.
/stock chime off / onSilences or restores the chime for every ticker. Toasts always show.
/stock AAPL chime off / onSilences or restores the chime for one ticker. Its toast still shows.
/stock list / checkPrints the list, or fetches prices now.
/stock testPlays both chimes, high then low.

A bare /stock AAPL does nothing but explain itself. Use /stock add AAPL, so a mistyped command is never mistaken for a ticker.

Tickers use Yahoo Finance symbols: AAPL, BRK-B, BTC-USD, ^GSPC, EURUSD=X, 7203.T. Up to 20 at a time.

Install

/plugin marketplace add troyjlorents-gh/mod-squad
/plugin install stock-watch@mod-squad

Start a new session after installing, because mods load when a session starts.

Settings

Open the plugin's settings to change these. Tickers themselves go in the Watch list, or are added with /stock.

SettingDefaultMeaning
watchlistblankTickers to watch, separated by semicolons: AAPL 180 200; NVDA high 140; MSFT (a ticker alone is price only). Applied when a session starts and whenever the text changes. /stock changes made later are not undone.
pollSeconds60How often prices are fetched, never under 30.
soundchimechime, chime and voice (also says the ticker aloud, macOS), or off.
statusLineshowshow puts the tickers under the prompt. hide leaves that line empty. The pane and the alerts still work.
statusCount3How many tickers the status line shows, 1 to 5. The rest show as +N.

Limits

  • It only watches while a session is open. There is no background service. Close every session and the alerts stop.
  • Prices come from Yahoo Finance's public chart endpoint. It is unofficial and has no key. Prices can lag by up to 15 minutes on some exchanges, outside trading hours the last price repeats, and Yahoo may rate-limit or change the endpoint. Only the ticker symbol is sent.
  • Watches are saved on the machine. Every session you open there sees them. If several sessions are open, only the first one to notice a crossing sounds the alert.
  • Sound depends on the surface. macOS plays it through the engine. A Windows terminal plays it through PowerShell. The Claude Desktop app on Windows may have no way to run a player, so it can be silent there, and the 30-second toast is the alert. Run /stock test to hear what your setup does.
  • It is a notifier. It does not place trades and is not financial advice.
Source 3 files
hooks/register.tsx 326 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Watch } from '../types'
5import { CHIME_USAGE, USAGE, alertText, evaluate, fmtChange, fmtPrice, parseCommand, parseQuote, quoteUrl, rangeBar, statusText } from './watch'
6import type { Side } from './watch'
7
8const PANE = 'stock-watch'
9const MAX_WATCHES = 20
10const MIN_POLL_S = 30
11
12const watchesRef = atom({ plugin: 'stock-watch', key: 'watches' } as const, [] as Watch[])
13const mutedRef = atom({ plugin: 'stock-watch', key: 'muted' } as const, false)
14const pollRef = atom({ plugin: 'stock-watch', key: 'poll' } as const, { isRunning: false, at: 0 })
15
16type Options = { pollSeconds?: number; sound?: string; watchlist?: string; statusLine?: string; statusCount?: number }
17type Cfg = { pollMs: number; sound: 'chime' | 'voice' | 'off'; watchlist: string; statusLine: boolean; statusCount: number; isPolling: boolean; isBusy: boolean }
18
19function config(options: unknown): Cfg {
20  const o = (options ?? {}) as Options
21  const s = Number(o.pollSeconds)
22  return {
23    pollMs: Math.max(MIN_POLL_S, Number.isFinite(s) && s > 0 ? s : 60) * 1000,
24    sound: o.sound === 'off' ? 'off' : o.sound === 'chime and voice' ? 'voice' : 'chime',
25    watchlist: (o.watchlist ?? '').trim(),
26    statusLine: o.statusLine !== 'hide',
27    statusCount: Math.min(5, Math.max(1, Math.round(Number(o.statusCount)) || 3)),
28    isPolling: false,
29    isBusy: false,
30  }
31}
32
33/* ----------------------------------------------------------------------- state */
34
35// The store is shared by every session on this machine, so each change reads it fresh and writes it back.
36async function stored($: EngineInterface): Promise<Watch[]> {
37  const v = await $.store.get('watches').catch(() => undefined)
38  return Array.isArray(v) ? (v as Watch[]) : []
39}
40
41// The status line settings, read by save() and load(), which are called from places that do not carry the config.
42let statusView = { show: true, count: 3 }
43
44async function save($: EngineInterface, ws: Watch[]) {
45  await $.store.set('watches', ws).catch(() => undefined)
46  await update($, watchesRef, () => ws)
47  if (statusView.show) $.ui.status(statusText(ws, statusView.count))
48}
49
50/** Loads the store into the pane's state (a session that just started, or another session's change). */
51async function load($: EngineInterface) {
52  const ws = await stored($)
53  await update($, watchesRef, () => ws)
54  await update($, mutedRef, () => false)
55  if ((await $.store.get('muted').catch(() => undefined)) === true) await update($, mutedRef, () => true)
56  const line = statusView.show ? statusText(ws, statusView.count) : undefined
57  if (line !== undefined) $.ui.status(line)
58}
59
60/* ----------------------------------------------------------------------- sound */
61
62/** Plays a clip. macOS plays through the engine; Windows has no engine player, so PowerShell plays it. */
63async function chime($: EngineInterface, side: Side) {
64  const asset = side === 'high' ? 'sounds/high.wav' : 'sounds/low.wav'
65  const isWindows = (await $.env.get('OS').catch(() => undefined)) === 'Windows_NT'
66  if (isWindows) {
67    try {
68      const path = `${$.plugin.root}/${asset}`.replace(/\//g, '\\').replace(/'/g, "''")
69      const r = await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', `(New-Object Media.SoundPlayer '${path}').PlaySync()`], { timeoutMs: 15_000 })
70      if (r.exitCode === 0) return
71    } catch {
72      // No process (the desktop app): fall through to the engine's player.
73    }
74  }
75  try {
76    await $.audio.play({ asset })
77  } catch {
78    // No player here: the toast still shows.
79  }
80}
81
82async function announce($: EngineInterface, cfg: Cfg, w: Watch, side: Side, price: number) {
83  $.ui.toast(alertText(w, side, price), { timeoutMs: 30_000 })
84  if (cfg.sound === 'off' || w.quiet === true || (await read($, mutedRef))) return
85  await chime($, side)
86  if (cfg.sound === 'voice') await $.audio.speak(`${w.sym.replace(/[^A-Z0-9]/g, ' ')} ${side === 'high' ? 'reached your high' : 'fell to your low'}`).catch(() => undefined)
87}
88
89/* ------------------------------------------------------------------------ poll */
90
91async function fetchQuote($: EngineInterface, sym: string) {
92  try {
93    const r = await $.http.fetch(quoteUrl(sym), { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; stock-watch)', Accept: 'application/json' } })
94    if (r.status === 429) return { error: 'rate-limited by the price service; will retry' }
95    if (!r.ok && r.text.trim() === '') return { error: `price service returned ${r.status}` }
96    return parseQuote(r.text)
97  } catch (e) {
98    return { error: e instanceof Error ? e.message : 'could not reach the price service' }
99  }
100}
101
102/** Fetches every watched ticker once, records the price, and alerts on a fresh crossing. */
103async function check($: EngineInterface, cfg: Cfg) {
104  if (cfg.isBusy) return
105  cfg.isBusy = true
106  try {
107    await update($, pollRef, p => ({ ...p, isRunning: true }))
108    for (const { sym } of await stored($)) {
109      const q = await fetchQuote($, sym)
110      // Another session may have changed or alerted since the loop began: re-read before deciding.
111      const ws = await stored($)
112      const i = ws.findIndex(w => w.sym === sym)
113      const w = ws[i]
114      if (w === undefined) continue
115      if ('error' in q) {
116        ws[i] = { ...w, error: q.error }
117        await save($, ws)
118        continue
119      }
120      const { side, alert } = evaluate(w, q.price)
121      ws[i] = { ...w, price: q.price, prev: q.prev, open: q.open ?? w.open, name: q.name ?? w.name, currency: q.currency ?? w.currency, at: await $.clock.now(), error: undefined, fired: side ?? undefined }
122      // Written before the sound plays, so a second session polling right now sees it already fired.
123      await save($, ws)
124      if (alert !== null) await announce($, cfg, ws[i] as Watch, alert, q.price)
125    }
126  } finally {
127    await update($, pollRef, () => ({ isRunning: false, at: 0 })).catch(() => undefined)
128    cfg.isBusy = false
129  }
130}
131
132function tick($: EngineInterface, cfg: Cfg) {
133  $.clock.after(cfg.pollMs, () => {
134    void check($, cfg)
135      .catch(() => undefined)
136      .then(() => tick($, cfg))
137  })
138}
139
140/** Starts the checking loop once per session. */
141function start($: EngineInterface, cfg: Cfg) {
142  if (cfg.isPolling) return
143  cfg.isPolling = true
144  $.clock.after(2_000, () => void check($, cfg).catch(() => undefined))
145  tick($, cfg)
146}
147
148/* ------------------------------------------------------------------ watch list */
149
150/**
151 * Adds or updates every ticker in the settings' watch list. Applied only when the text changed since it was last
152 * applied, so a later /stock change is not undone by the next session. Entries are never removed from here.
153 */
154async function applyWatchlist($: EngineInterface, cfg: Cfg) {
155  if (cfg.watchlist === '') return
156  if ((await $.store.get('watchlistApplied').catch(() => undefined)) === cfg.watchlist) return
157  const ws = await stored($)
158  const bad: string[] = []
159  for (const entry of cfg.watchlist.split(/[;\n]+/).map(x => x.trim()).filter(Boolean)) {
160    const cmd = parseCommand(`add ${entry}`)
161    if (cmd.kind !== 'add') {
162      bad.push(entry)
163      continue
164    }
165    const i = ws.findIndex(w => w.sym === cmd.sym)
166    const prior = i >= 0 ? ws[i] : undefined
167    const next: Watch = { ...(prior ?? { sym: cmd.sym }), low: cmd.low ?? prior?.low, high: cmd.high ?? prior?.high }
168    // A changed limit re-arms; an unchanged one keeps its state.
169    if (prior === undefined || prior.low !== next.low || prior.high !== next.high) next.fired = undefined
170    if (i >= 0) ws[i] = next
171    else if (ws.length < MAX_WATCHES) ws.push(next)
172  }
173  await save($, ws)
174  await $.store.set('watchlistApplied', cfg.watchlist).catch(() => undefined)
175  if (bad.length > 0) $.ui.toast(`stock-watch: could not read ${bad.map(b => `"${b}"`).join(', ')} in the watch list. Use: AAPL 180 200; NVDA high 140`, { timeoutMs: 15_000 })
176}
177
178/* -------------------------------------------------------------------- commands */
179
180async function run($: EngineInterface, cfg: Cfg, args: string): Promise<string> {
181  const cmd = parseCommand(args)
182  switch (cmd.kind) {
183    case 'error':
184      return cmd.text
185    case 'open':
186    case 'list': {
187      const ws = await stored($)
188      await update($, watchesRef, () => ws)
189      if (cmd.kind === 'open') return ws.length === 0 ? `Stock watch: nothing watched yet. ${USAGE}` : `Stock watch: ${ws.length} ticker${ws.length === 1 ? '' : 's'}.`
190      return ws.length === 0 ? 'Stock watch: nothing watched yet.' : ws.map(w => `${w.sym} ${fmtPrice(w.price)}  low ${fmtPrice(w.low)}  high ${fmtPrice(w.high)}${w.error ? `  (${w.error})` : ''}`).join('\n')
191    }
192    case 'add': {
193      const ws = await stored($)
194      const i = ws.findIndex(w => w.sym === cmd.sym)
195      if (i < 0 && ws.length >= MAX_WATCHES) return `Stock watch: up to ${MAX_WATCHES} tickers.`
196      const prior = i >= 0 ? ws[i] : undefined
197      // A changed limit starts fresh: the price may already be past it, and that should alert.
198      const next: Watch = { ...(prior ?? { sym: cmd.sym }), low: cmd.low ?? prior?.low, high: cmd.high ?? prior?.high, fired: undefined }
199      if (next.low !== undefined && next.high !== undefined && next.low >= next.high) return `The low (${next.low}) must be below the high (${next.high}).`
200      if (i >= 0) ws[i] = next
201      else ws.push(next)
202      await save($, ws)
203      $.clock.after(1, () => void check($, cfg).catch(() => undefined))
204      if (next.low === undefined && next.high === undefined) return `Watching ${next.sym}: price only, no alerts. Add a limit any time with /stock add ${next.sym} low 180 high 200.`
205      return `Watching ${next.sym}${next.low !== undefined ? `, alert at or below ${fmtPrice(next.low)}` : ''}${next.high !== undefined ? `, alert at or above ${fmtPrice(next.high)}` : ''}.`
206    }
207    case 'rm': {
208      const ws = await stored($)
209      if (!ws.some(w => w.sym === cmd.sym)) return `Stock watch: ${cmd.sym} is not on the list.`
210      await save($, ws.filter(w => w.sym !== cmd.sym))
211      return `Stopped watching ${cmd.sym}.`
212    }
213    case 'check':
214      $.clock.after(1, () => void check($, cfg).catch(() => undefined))
215      return 'Stock watch: checking prices now.'
216    case 'test':
217      await chime($, 'high')
218      await chime($, 'low')
219      return 'Stock watch: played the high chime, then the low chime. Silent? That surface has no player; the toast still shows.'
220    case 'mute':
221    case 'unmute':
222    case 'chime': {
223      const m = cmd.kind === 'mute' || (cmd.kind === 'chime' && !cmd.on)
224      await update($, mutedRef, () => m)
225      await $.store.set('muted', m).catch(() => undefined)
226      return m ? 'Stock watch: chime off for every ticker. Toasts still show.' : 'Stock watch: chime on.'
227    }
228    case 'quiet': {
229      const ws = await stored($)
230      if (!ws.some(w => w.sym === cmd.sym)) return `Stock watch: ${cmd.sym} is not on the list.`
231      await save($, ws.map(w => (w.sym === cmd.sym ? { ...w, quiet: cmd.quiet } : w)))
232      return cmd.quiet ? `Stock watch: ${cmd.sym} chime off. Its toast still shows.` : `Stock watch: ${cmd.sym} chime on.`
233    }
234  }
235}
236
237const OPEN = { id: PANE, title: 'Stock watch', focus: true, closeOnEscape: true, rows: 22, columns: 84 } as const
238
239export const register: Register = (on, options) => {
240  const cfg = config(options)
241  statusView = { show: cfg.statusLine, count: cfg.statusCount }
242
243  on('session.start', async ($, e, next) => {
244    await $.command.register({
245      name: 'stock',
246      description: 'Stock watch: alert me when a ticker hits a low or high',
247      argumentHint: 'add AAPL low 180 high 200 | rm AAPL | list | check | test | mute',
248      immediate: true,
249    })
250    await load($)
251    await applyWatchlist($, cfg)
252    start($, cfg)
253    return next(e)
254  }).catch(($, e, next) => next(e))
255
256  // A session that missed session.start still begins polling on its first prompt.
257  on('prompt.submit', async ($, e, next) => {
258    start($, cfg)
259    return next(e)
260  }).catch(($, e, next) => next(e))
261
262  on('command.run', { command: 'stock' }, async ($, e) => {
263    start($, cfg)
264    const text = await run($, cfg, e.args)
265    if (parseCommand(e.args).kind === 'open') await $.ui.open(OPEN).catch(() => undefined)
266    return { text }
267  })
268
269  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
270    const { Box, Text, Button, Input } = $.ui.resolve(e)
271    const ws = await read($, watchesRef)
272    const muted = await read($, mutedRef)
273    const cols = Math.max(20, e.props.bodyColumns)
274
275    return (
276      <Box flexDirection="column" width={cols}>
277        <Input key="add" label="Add" placeholder="AAPL   or   AAPL 180 200   or   NVDA high 140" onSubmit={value => void run($, cfg, `add ${value}`).then(t => $.ui.toast(t))} />
278        <Box columnGap={1} marginTop={1}>
279          <Button key="check" label="Check now" hotkey="c" variant="secondary" onPress={() => void run($, cfg, 'check')} />
280          <Button key="test" label="Test sound" hotkey="t" variant="secondary" onPress={() => void run($, cfg, 'test')} />
281          <Button key="mute" label={muted ? 'Unmute' : 'Mute'} hotkey="m" variant={muted ? 'primary' : 'secondary'} onPress={() => void run($, cfg, muted ? 'unmute' : 'mute')} />
282        </Box>
283        <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor="#22c55e" paddingX={1} width={cols}>
284          {ws.length === 0 && (
285            <Text dimColor wrap="wrap">
286              Nothing watched yet. Type a ticker above, for example AAPL to watch the price, or AAPL 180 200 to alert at a low and a high.
287            </Text>
288          )}
289          {ws.map(w => {
290            // Up or down from today's open (the previous close when the open is not known yet).
291            const ch = fmtChange(w.price, w.open ?? w.prev)
292            const tone = ch.dir > 0 ? '#22c55e' : ch.dir < 0 ? '#ef4444' : undefined
293            return (
294              <Box key={w.sym} flexDirection="column" marginBottom={1} borderStyle="round" borderColor={tone} paddingX={1} width={cols - 2}>
295                <Box columnGap={1}>
296                  <Text bold>{w.sym.padEnd(8)}</Text>
297                  <Text bold>{fmtPrice(w.price)}</Text>
298                  <Text color={tone} dimColor={ch.dir === 0}>
299                    {ch.text}
300                  </Text>
301                  {w.quiet === true && <Text dimColor>chime off</Text>}
302                  {w.fired !== undefined && <Text color={w.fired === 'high' ? '#22c55e' : '#ef4444'} bold>{w.fired === 'high' ? 'HIGH HIT' : 'LOW HIT'}</Text>}
303                  <Button key={`rm-${w.sym}`} label="Remove" variant="secondary" onPress={() => void run($, cfg, `rm ${w.sym}`)} />
304                </Box>
305                {(w.low !== undefined || w.high !== undefined) && (
306                  <Text dimColor wrap="truncate">
307                    {`low ${fmtPrice(w.low)}  ${rangeBar(w)}  high ${fmtPrice(w.high)}`}
308                  </Text>
309                )}
310                {w.error !== undefined && (
311                  <Text color="#f59e0b" wrap="truncate">
312                    {w.error}
313                  </Text>
314                )}
315              </Box>
316            )
317          })}
318        </Box>
319        <Text dimColor wrap="wrap">
320          {`Checked every ${Math.round(cfg.pollMs / 1000)}s while a session is open. Prices come from Yahoo Finance and can lag.`}
321        </Text>
322      </Box>
323    )
324  })
325}
326
hooks/watch.ts 156 lines
1import type { Watch } from '../types'
2
3export type Side = 'low' | 'high'
4
5export type Cmd =
6  | { kind: 'open' }
7  | { kind: 'add'; sym: string; low?: number; high?: number }
8  | { kind: 'rm'; sym: string }
9  | { kind: 'chime'; on: boolean }
10  | { kind: 'quiet'; sym: string; quiet: boolean }
11  | { kind: 'list' | 'check' | 'test' | 'mute' | 'unmute' }
12  | { kind: 'error'; text: string }
13
14export const CHIME_USAGE = 'Usage: /stock chime on | off  (every ticker)  ·  /stock AAPL chime on | off  (one ticker)'
15
16export const USAGE =
17  'Usage: /stock add AAPL low 180 high 200  ·  /stock AAPL 180 200 (low, high)  ·  /stock add AAPL (price only)  ·  /stock rm AAPL  ·  /stock list | check | test | mute | unmute'
18
19const VERBS = new Set(['add', 'rm', 'remove', 'delete', 'list', 'check', 'refresh', 'test', 'mute', 'unmute'])
20const LOW_WORDS = new Set(['low', 'below', 'under', '<', '<=', 'lo'])
21const HIGH_WORDS = new Set(['high', 'above', 'over', '>', '>=', 'hi'])
22// AAPL, BRK-B, BTC-USD, ^GSPC, EURUSD=X, 7203.T
23const SYMBOL = /^[A-Z0-9^][A-Z0-9.=^-]{0,11}$/
24const NUMBER = /^\$?\d[\d,]*\.?\d*$/
25
26export const toNumber = (t: string): number | null => (NUMBER.test(t) ? Number(t.replace(/[$,]/g, '')) : null)
27
28/** Parses what follows `/stock` (or what is typed in the pane's box). */
29export function parseCommand(args: string): Cmd {
30  const tokens = args.trim().split(/\s+/).filter(Boolean)
31  if (tokens.length === 0) return { kind: 'open' }
32  const verb = (tokens[0] ?? '').toLowerCase()
33  if (verb === 'chime') {
34    const v = (tokens[1] ?? '').toLowerCase()
35    return v === 'on' || v === 'off' ? { kind: 'chime', on: v === 'on' } : { kind: 'error', text: CHIME_USAGE }
36  }
37  // /stock AAPL chime off: silences one ticker's chime; its toast still shows.
38  if (tokens.length === 3 && (tokens[1] ?? '').toLowerCase() === 'chime') {
39    const sym = verb.toUpperCase()
40    const v = (tokens[2] ?? '').toLowerCase()
41    if (!SYMBOL.test(sym) || (v !== 'on' && v !== 'off')) return { kind: 'error', text: CHIME_USAGE }
42    return { kind: 'quiet', sym, quiet: v === 'off' }
43  }
44  if (verb === 'list' || verb === 'check' || verb === 'test' || verb === 'mute' || verb === 'unmute') return { kind: verb }
45  if (verb === 'refresh') return { kind: 'check' }
46
47  const rest = VERBS.has(verb) ? tokens.slice(1) : tokens
48  const sym = (rest[0] ?? '').toUpperCase()
49  if (verb === 'rm' || verb === 'remove' || verb === 'delete') return SYMBOL.test(sym) ? { kind: 'rm', sym } : { kind: 'error', text: USAGE }
50  if (!SYMBOL.test(sym) || toNumber(sym) !== null) return { kind: 'error', text: USAGE }
51
52  let low: number | undefined
53  let high: number | undefined
54  const bare: number[] = []
55  for (let i = 1; i < rest.length; i++) {
56    const t = (rest[i] ?? '').toLowerCase()
57    const side = LOW_WORDS.has(t) ? 'low' : HIGH_WORDS.has(t) ? 'high' : null
58    if (side !== null) {
59      const n = toNumber(rest[i + 1] ?? '')
60      if (n === null) return { kind: 'error', text: `"${t}" needs a price after it. ${USAGE}` }
61      if (side === 'low') low = n
62      else high = n
63      i++
64      continue
65    }
66    const n = toNumber(t)
67    if (n === null) return { kind: 'error', text: `Did not understand "${rest[i]}". ${USAGE}` }
68    bare.push(n)
69  }
70  if (bare.length > 2) return { kind: 'error', text: USAGE }
71  if (bare.length === 1) return { kind: 'error', text: `One bare price is ambiguous: say "low ${bare[0]}" or "high ${bare[0]}".` }
72  if (bare.length === 2 && low === undefined && high === undefined) [low, high] = [Math.min(...bare), Math.max(...bare)]
73  // Price-only needs the explicit `add`, so a mistyped command is not mistaken for a ticker.
74  if (low === undefined && high === undefined && verb !== 'add') return { kind: 'error', text: `To just watch ${sym}'s price, use /stock add ${sym}. ${USAGE}` }
75  if (low !== undefined && high !== undefined && low >= high) return { kind: 'error', text: `The low (${low}) must be below the high (${high}).` }
76  return { kind: 'add', sym, low, high }
77}
78
79/* -------------------------------------------------------------------- quotes */
80
81export type Quote = { price: number; prev?: number; open?: number; name?: string; currency?: string }
82
83/** Reads Yahoo Finance's chart response. Defensive: any other shape is an error message, never a throw. */
84export function parseQuote(text: string): Quote | { error: string } {
85  let j: any
86  try {
87    j = JSON.parse(text)
88  } catch {
89    return { error: 'the price service did not answer with data (it may be rate-limiting)' }
90  }
91  const err = j?.chart?.error
92  if (err) return { error: String(err.description ?? err.code ?? 'unknown symbol') }
93  const m = j?.chart?.result?.[0]?.meta
94  const price = m?.regularMarketPrice
95  if (typeof price !== 'number' || !Number.isFinite(price)) return { error: 'no price in the response' }
96  const prev = typeof m.chartPreviousClose === 'number' ? m.chartPreviousClose : typeof m.previousClose === 'number' ? m.previousClose : undefined
97  // Today's open is the latest daily candle's open; the previous close is the fallback baseline.
98  const opens: unknown[] = j.chart.result[0].indicators?.quote?.[0]?.open ?? []
99  const open = [...opens].reverse().find((v): v is number => typeof v === 'number' && Number.isFinite(v))
100  return { price, prev, open, name: m.longName ?? m.shortName, currency: m.currency }
101}
102
103export const quoteUrl = (sym: string) => `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(sym)}?interval=1d&range=1d`
104
105/* ------------------------------------------------------------------- alerting */
106
107/**
108 * What a new price does to a watch: which limit it is past (null inside the band), and whether that is a
109 * fresh crossing worth an alert. A limit alerts once; it re-arms when the price comes back inside the band.
110 */
111export function evaluate(w: Watch, price: number): { side: Side | null; alert: Side | null } {
112  const side: Side | null = w.low !== undefined && price <= w.low ? 'low' : w.high !== undefined && price >= w.high ? 'high' : null
113  return { side, alert: side !== null && w.fired !== side ? side : null }
114}
115
116/* ----------------------------------------------------------------- formatting */
117
118export const fmtPrice = (n: number | undefined): string =>
119  n === undefined ? '-' : n >= 1000 ? n.toLocaleString('en-US', { maximumFractionDigits: 2 }) : n.toFixed(n >= 1 ? 2 : 4)
120
121export function fmtChange(price: number | undefined, prev: number | undefined): { text: string; dir: 1 | -1 | 0 } {
122  if (price === undefined || prev === undefined || prev === 0) return { text: '', dir: 0 }
123  const pct = ((price - prev) / prev) * 100
124  return { text: `${pct >= 0 ? '▲' : '▼'}${Math.abs(pct).toFixed(2)}%`, dir: pct > 0 ? 1 : pct < 0 ? -1 : 0 }
125}
126
127/** `[···●······]` with the price's place between the low and the high; `!` at a limit. */
128export function rangeBar(w: Watch, width = 16): string {
129  if (w.price === undefined || w.low === undefined || w.high === undefined) return ''
130  const t = Math.min(1, Math.max(0, (w.price - w.low) / (w.high - w.low)))
131  const at = Math.round(t * (width - 1))
132  return `[${Array.from({ length: width }, (_, i) => (i === at ? (t === 0 || t === 1 ? '!' : '●') : '·')).join('')}]`
133}
134
135/** Prices without cents once they are large (83,094 not 83,094.31): the status line is narrow. */
136export const fmtShort = (n: number | undefined): string => (n !== undefined && n >= 1000 ? n.toLocaleString('en-US', { maximumFractionDigits: 0 }) : fmtPrice(n))
137
138/**
139 * The pinned line under the prompt: the first few tickers with ▲ or ▼ from today's open (the previous close
140 * until the open is known), HIGH or LOW once a limit has been hit, and +N for the ones not shown.
141 */
142export function statusText(ws: Watch[], count = 3): string | undefined {
143  const shown = ws.filter(w => w.price !== undefined).slice(0, count)
144  if (shown.length === 0) return undefined
145  const parts = shown.map(w => {
146    const dir = fmtChange(w.price, w.open ?? w.prev).dir
147    return `${w.sym} ${fmtShort(w.price)}${dir > 0 ? ' ▲' : dir < 0 ? ' ▼' : ''}${w.fired === undefined ? '' : w.fired === 'high' ? ' HIGH' : ' LOW'}`
148  })
149  return `${parts.join(' · ')}${ws.length > shown.length ? ` +${ws.length - shown.length}` : ''}`
150}
151
152export function alertText(w: Watch, side: Side, price: number): string {
153  const limit = side === 'high' ? w.high : w.low
154  return `${side === 'high' ? '📈' : '📉'} ${w.sym} ${side === 'high' ? 'reached your high' : 'fell to your low'}: ${fmtPrice(price)}${w.currency ? ` ${w.currency}` : ''} (limit ${fmtPrice(limit)})`
155}
156
types/index.d.ts 32 lines
1/** One ticker being watched. Stored in the plugin's store, shared by every session on the machine. */
2export type Watch = {
3  sym: string
4  low?: number
5  high?: number
6  /** Which limit already alerted. Cleared when the price is back inside the band, which re-arms it. */
7  fired?: 'low' | 'high'
8  price?: number
9  /** Today's open, the baseline for the up/down colour. Falls back to the previous close when absent. */
10  open?: number
11  prev?: number
12  /** This ticker's chime is off. Its toast still shows. */
13  quiet?: boolean
14  name?: string
15  currency?: string
16  /** When the price was last fetched (ms since epoch). */
17  at?: number
18  /** Why the last fetch failed, if it did. */
19  error?: string
20}
21
22declare module 'claude-code' {
23  interface PluginState {
24    'stock-watch': {
25      watches: Watch[]
26      muted: boolean
27      /** The last poll: when it ended, and whether one is running now. */
28      poll: { isRunning: boolean; at: number }
29    }
30  }
31}
32