Stock ticker in the status line, /quote pane, price alerts, a market-context band, and a quote tool the model can call.

A Claude Code mod that puts the stock market inside your coding session: a live ticker in the status line, a /quote pane with a sparkline, price alerts, a market-context band above the prompt, and a quote tool so Claude uses current prices instead of guessing from training data.
Built on function hooks: a hooks module (hooks/register.tsx) that exports register. It is not a shell-hook plugin, a skill, or an MCP server.
status line SPY 778.57 ▲0.60% · META 718.67 ▼0.31% · AAPL 336.64 ▼1.11% · AMZN 262.43 ▲3.29% · NFLX 70.30 ▼1.77% · GOOGL 351.66 ▲0.97%
band Market closed · VIX 14.84 ▼3.70% · 10Y 5.24% · last trade 12m ago [ Hide ]
Not investment advice. Prices come from Yahoo Finance's unofficial chart endpoint and may be delayed. See Data source.
| Feature | Where it shows | How you use it |
|---|---|---|
| Ticker | Status line | Starts with the session. Default watchlist: SPY, META, AAPL, AMZN, NFLX, GOOGL. |
| Editable watchlist | Slash command | /watch, /watch add NVDA TSLA, /watch remove NFLX, /watch set QQQ IWM, /watch reset. Saved across sessions, 12 symbols max. |
| Quote pane | Side pane | /quote NVDA: price, day change, intraday sparkline, chart low/high, day range, volume, as-of time. |
| Price alerts | Toast | /alert TSLA > 260, /alert SPY < 550, /alert (list), /alert rm a1, /alert clear. |
| Market band | Row above the prompt | Market open/closed, VIX, 10-year yield, data age, last poll error. /market or the Hide button toggles it. |
quote tool | Claude calls it | Ask "how did the FAANG stocks do this month?" and Claude calls mcp__market-watch__quote with up to 8 symbols and a range (1d 5d 1mo 3mo 6mo ytd 1y). |
An alert fires once when the price crosses its level, then disarms. It re-arms only after the price moves back past the level by 0.5%. That stops a price hovering at 260.01 / 259.99 from toasting every minute. An alert only fires for a symbol on the watchlist; /alert tells you when a symbol is not on it.
^VIX and ^TNX), made one after another.| Minimum | Tested on | |
|---|---|---|
| Claude Code in a terminal | v2.1.287 | 2.1.287 |
| Claude Code in the Desktop app (Code tab) | v2.1.286 | 2.1.293 |
| Network | HTTPS to query1.finance.yahoo.com | |
git | Needed to add the marketplace from GitHub |
Mods are on by default from those versions. No API key, no account, no environment variable.
If you set
CLAUDE_CODE_ENABLE_FUNCTION_HOOKSduring the mods early access, remove it. Claude Code v2.1.287 and later ignores it (mods overview).
Where the mod works: the terminal and the Desktop app's Code tab draw everything. In the VS Code extension's chat panel and in claude -p, the commands and the quote tool work, but nothing is drawn (no status line, pane or band).
claude plugin marketplace add rajib2k5/claude-market-watch
claude plugin install market-watch@claude-market-watch
Start claude (or run /reload-plugins in an open session), then run /watch. You should see:
Watching: SPY, META, AAPL, AMZN, NFLX, GOOGL
The Deployment guide covers team rollout, pinning, updates, releases, rollback and removal.
Reviewers and cautious users: this is the complete surface, as reported by claude plugin validate.
| Hooks | session.start (registers commands, the tool and the poll timer) · command.run for watch, quote, alert, market only · tool.call for mcp__market-watch__quote only · ui.render for its own Pane (market-quote) and the AbovePrompt band |
Calls on $ | http.fetch, clock.now/after/every, store.get/set, state.get/set, command.register, tool.register, ui.open/resolve/status/toast |
| Network | GET https://query1.finance.yahoo.com/v8/finance/chart/<SYMBOL> only. Symbols are validated against ^\^?[A-Z0-9][A-Z0-9.\-=]{0,14}$ and URL-encoded. No credentials are sent. |
| Stored on disk | Your watchlist and alerts, in the plugin's own $.store. Nothing else. |
| Does not touch | Your files ($.fs), shell ($.process), other tools' calls, your prompts, the system prompt, or the transcript. It hooks no other tool and cannot block anything. |
Quotes come from Yahoo Finance's unofficial, undocumented chart endpoint (/v8/finance/chart). It needs no key, but:
/v7/finance/quote) already returns 401, which is why this mod makes one request per symbol.quote tool result includes the source, the fetch time, each symbol's as-of time, and a "market closed" flag, so Claude does not present an old price as live.If the endpoint fails, the band shows the error (for example ⚠ AAPL: timed out after 8s) and the ticker keeps the last good values.
Tested against this repository on Claude Code 2.1.287, in an isolated CLAUDE_CONFIG_DIR, with the output quoted as it appeared: marketplace add, install, plugin list, claude -p "/watch", plugin update, plugin uninstall, marketplace remove, and plugin tag --dry-run.
From Anthropic's docs, not yet run here: project-scope and managed-settings rollout, container seeds, and the /plugin toggles. Links point to the source pages.
From a shell:
claude plugin marketplace add rajib2k5/claude-market-watch
claude plugin install market-watch@claude-market-watch
Expected output:
✔ Successfully added marketplace: claude-market-watch (declared in user settings)
✔ Successfully installed plugin: market-watch@claude-market-watch (scope: user)
Or inside a session:
/plugin install market-watch --marketplace rajib2k5/claude-market-watch
Answer y to add the marketplace, then pick a scope.
The install id is always market-watch@claude-market-watch: the plugin name, @, the marketplace name from .claude-plugin/marketplace.json (not the repository name).
Scopes (--scope, default user):
| Scope | Loads in | Written to |
|---|---|---|
user | Every session on your machine | ~/.claude/settings.json |
project | This repository, for everyone who trusts it | .claude/settings.json (commit it) |
local | This repository, only for you | .claude/settings.local.json |
No GitHub SSH key? claude plugin marketplace add tries SSH first, then HTTPS. To skip the SSH probe, set CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1.
claude plugin list
❯ market-watch@claude-market-watch
Version: 0.1.0
Scope: user
Status: ✔ enabled
In a session:
/plugin shows a dim line such as 1 mod active · market-watch./watch answers Watching: SPY, META, AAPL, AMZN, NFLX, GOOGL.Headless check, with no model call and no cost (the mod answers /watch itself):
claude -p "/watch"
market-watch: Watching: SPY, META, AAPL, AMZN, NFLX, GOOGL
claude plugin details market-watch reports Hooks (0). That count is for settings hooks only; a mod's function hooks are listed by claude plugin validate instead.
One repository. In that repository, run once:
claude plugin marketplace add rajib2k5/claude-market-watch --scope project
claude plugin install market-watch@claude-market-watch --scope project
Commit the .claude/settings.json these write. Each contributor gets the mod after they accept the workspace trust dialog for the folder.
A whole organization (administrators). Put this in managed settings (server-managed settings, MDM, or managed-settings.json):
{
"extraKnownMarketplaces": {
"claude-market-watch": {
"source": { "source": "github", "repo": "rajib2k5/claude-market-watch" },
"autoUpdate": true
}
},
"enabledPlugins": {
"market-watch@claude-market-watch": true
}
}
The marketplace key must equal the marketplace name, claude-market-watch. Managed enabledPlugins force-enables the mod; users cannot disable it at their own scope. See Manage plugins for your organization. Review the mod first: claude plugin validate lists every hook and call (What it hooks).
Containers and CI that cannot clone at run time: build a seed with CLAUDE_CODE_PLUGIN_CACHE_DIR=/opt/claude-seed claude plugin marketplace add … and … plugin install …, then set CLAUDE_CODE_PLUGIN_SEED_DIR=/opt/claude-seed and enabledPlugins at run time. The mod draws nothing under claude -p, but the quote tool works there.
Each release is a git tag named market-watch--v<version>, created by step 6. The first is market-watch--v0.1.0. To hold a machine on one release, add the marketplace at that tag:
claude plugin marketplace add rajib2k5/claude-market-watch#market-watch--v0.1.0
To move to a newer release, remove the marketplace and add it again at the new tag.
Claude Code gives you a new copy only when the version in .claude-plugin/plugin.json changes. Pushing commits without a version bump does nothing for installed users.
claude plugin update market-watch@claude-market-watch
✔ market-watch is already at the latest version (0.1.0).
In a session, /plugin marketplace update claude-market-watch refreshes the catalog. To have updates arrive in the background, open /plugin → Marketplaces → claude-market-watch → Enable auto-update (it is off by default). After an update from the shell, run /reload-plugins in any open session.
claude plugin test .
claude plugin validate .
claude plugin validate .claude-plugin/plugin.json
npx -p typescript@5.6.3 tsc -p .
version in .claude-plugin/plugin.json (semver: patch for fixes, minor for features, major for a breaking change to commands, the tool's input, or stored data). Set it in plugin.json only, never also in marketplace.json.CHANGELOG.md.main.--dry-run: claude plugin tag . --dry-run
✔ Dry run — would create tag market-watch--v0.1.0 at HEAD
Then create and push it:
claude plugin tag . --push
export CLAUDE_CONFIG_DIR="$(mktemp -d)"
claude plugin marketplace add rajib2k5/claude-market-watch
claude plugin install market-watch@claude-market-watch
claude -p "/watch"
unset CLAUDE_CONFIG_DIR
The last command must print market-watch: Watching: ….
Roll forward. Revert the bad commit, bump to a new patch version (for example 0.2.0 → 0.2.1), and release it as in step 6. Users then get the fix through a normal update. Avoid reusing an old version string: Claude Code compares versions to decide whether to update, and a reused string confuses anyone who already pinned it.
A user who needs the old release immediately can pin to its tag, as in step 4.
Stored data (watchlist and alerts) is plain JSON. A release that changes its shape must read the old shape too. loadWatchlist and loadAlerts in hooks/register.tsx already fall back to defaults when the stored value is not what they expect.
| Goal | Do this |
|---|---|
| Hide just the band | /market, or the band's Hide button |
| Stop the mod, keep it installed | /plugin → Installed → disable market-watch |
| Run one session without any mods | claude --safe-mode |
| Uninstall | claude plugin uninstall market-watch@claude-market-watch (add --keep-data to keep the plugin's data directory for a later reinstall) |
| Remove the marketplace too | claude plugin marketplace remove claude-market-watch (this also uninstalls its plugins) |
| Symptom | Cause and fix |
|---|---|
/watch is not a command | The mod did not load. Check claude --version (2.1.287+ in a terminal), run /reload-plugins, and check /plugin for the mods active line or an error. claude --debug logs why a hook was skipped. |
Plugin "market-watch" not found in marketplace | You installed by the wrong id. Use market-watch@claude-market-watch. |
| Marketplace add fails on a git error | No SSH key for GitHub. Set CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 and retry. |
Status line shows SPY … and never fills | The first poll failed. The band shows the error after ⚠. Check that https://query1.finance.yahoo.com is reachable from your machine. |
Band says Market closed, prices do not move | Expected outside US regular hours. The mod then polls every 15 minutes. |
| An alert never fires | The symbol is not on the watchlist (/watch add SYM), or it already fired and is waiting to re-arm (/alert shows fired, waiting to re-arm). |
| A new release does not arrive | The maintainer did not bump version, or auto-update is off. Run claude plugin update market-watch@claude-market-watch. |
tsc reports TS2589 | You are type-checking with the generated claude-code-mcp types. Use this repository's tsconfig.json, which leaves them out. |
hooks/
core.ts pure functions: parse, format, sparkline, market-open check, alert state machine
register.tsx the shell: timer, http.fetch, $.store / $.state, commands, tool, pane, band
core.test.ts unit tests for core.ts
register.test.ts integration tests through the engine: commands and the tool, network mocked
types/index.d.ts state contract: the $.state values the pane and band read
.claude-plugin/
plugin.json manifest
marketplace.json makes this repo installable as a marketplace
core.ts with no $, so it is tested with plain values.$.state for what is drawn, $.store for what persists. Quotes are session state: when the poller writes them, the pane and band redraw. The watchlist and alerts persist across sessions.claude plugin validate .claude-plugin/plugin.json
claude plugin test .
Live development: claude --plugin-dir . loads the folder, and an interactive session reloads it on save. Claude Code writes the API types into .claude-plugin/types/ on load (git-ignored), and tsconfig.json extends them, so this type-checks the mod once it has loaded at least once:
npx -p typescript@5.6.3 tsc -p .
tsconfig.json deliberately leaves out the generated claude-code-mcp types. Those declare every MCP tool connected in your session, and with a few hundred of them, typing the tool.call matcher exceeds TypeScript's instantiation depth (TS2589). The mod reads its tool's input defensively and does not need them.
/quote at any width. The band and status line need no width.MIT © 2026 Rajib Bahar
Not affiliated with or endorsed by Anthropic or Yahoo.
hooks/register.tsx 256 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AlertRule, Quote } from '../types'
5import {
6 CONTEXT_SYMBOLS, DEFAULT_WATCHLIST, MAX_TOOL_SYMBOLS, RANGES, applyWatch, chartUrl, evaluateAlert,
7 formatAlert, formatForModel, formatPct, formatPrice, formatTicker, isMarketOpen, normalizeSymbol,
8 parseAlertArgs, parseChart, parseWatchArgs, sparkline, toolHeader,
9} from './core'
10import type { ParsedChart, Range } from './core'
11
12const quotes = atom({ plugin: 'market-watch', key: 'quotes' } as const, {})
13const focus = atom({ plugin: 'market-watch', key: 'focus' } as const, null)
14const isBandHidden = atom({ plugin: 'market-watch', key: 'isBandHidden' } as const, false)
15const lastError = atom({ plugin: 'market-watch', key: 'lastError' } as const, null)
16
17const PANE = 'market-quote'
18const TOOL = 'mcp__market-watch__quote'
19const STORE_WATCHLIST = 'watchlist'
20const STORE_ALERTS = 'alerts'
21
22const OPEN_POLL_MS = 60_000
23/** While closed, poll rarely: only to learn the next session's window. */
24const CLOSED_POLL_MS = 15 * 60_000
25const FETCH_TIMEOUT_MS = 8_000
26
27type $ = EngineInterface
28
29async function loadWatchlist($: $): Promise<string[]> {
30 const stored = await $.store.get(STORE_WATCHLIST)
31 return Array.isArray(stored) ? stored.filter((s): s is string => typeof s === 'string') : [...DEFAULT_WATCHLIST]
32}
33
34async function loadAlerts($: $): Promise<AlertRule[]> {
35 const stored = await $.store.get(STORE_ALERTS)
36 return Array.isArray(stored) ? (stored as AlertRule[]) : []
37}
38
39/** One chart fetch with a timeout; never rejects, so one bad symbol cannot sink a poll. */
40async function fetchChart($: $, symbol: string, range: Range = '1d'): Promise<ParsedChart> {
41 let timer: { cancel: () => void } | undefined
42 const timeout = new Promise<ParsedChart>(resolve => {
43 timer = $.clock.after(FETCH_TIMEOUT_MS, () =>
44 resolve({ isOk: false, error: `${symbol}: timed out after ${FETCH_TIMEOUT_MS / 1000}s` }))
45 })
46 const request = $.http
47 .fetch(chartUrl(symbol, range), { headers: { 'User-Agent': 'Mozilla/5.0' } })
48 .then(res => parseChart(symbol, res.status, res.text))
49 .catch((err: unknown): ParsedChart => ({ isOk: false, error: `${symbol}: ${String(err)}` }))
50 try {
51 return await Promise.race([request, timeout])
52 } finally {
53 timer?.cancel()
54 }
55}
56
57// Module variables reset on hot reload; that only costs one early poll.
58let lastPollMs = 0
59let isPolling = false
60
61async function poll($: $, isForced = false): Promise<void> {
62 if (isPolling) return
63 const now = await $.clock.now()
64 const held = await read($, quotes)
65 const watchlist = await loadWatchlist($)
66 const symbols = [...watchlist, ...CONTEXT_SYMBOLS]
67 const isMissing = symbols.some(s => held[s] === undefined)
68 if (!isForced && !isMissing && !isMarketOpen(now, held) && now - lastPollMs < CLOSED_POLL_MS) return
69
70 isPolling = true
71 try {
72 lastPollMs = now
73 const fresh: Record<string, Quote> = {}
74 const errors: string[] = []
75 // Sequential on purpose: Yahoo has no batch endpoint for keyless use and
76 // rate-limits bursts; ~8 calls a minute stays well clear of that.
77 for (const symbol of symbols) {
78 const parsed = await fetchChart($, symbol)
79 if (parsed.isOk) fresh[symbol] = parsed.quote
80 else errors.push(parsed.error)
81 }
82 await update($, quotes, prev => ({ ...prev, ...fresh }))
83 await update($, lastError, () => (errors.length > 0 ? errors.join('; ') : null))
84 $.ui.status(formatTicker(watchlist, { ...held, ...fresh }))
85 await checkAlerts($, fresh)
86 } finally {
87 isPolling = false
88 }
89}
90
91async function checkAlerts($: $, fresh: Readonly<Record<string, Quote>>): Promise<void> {
92 const rules = await loadAlerts($)
93 if (rules.length === 0) return
94 let isChanged = false
95 const next = rules.map(rule => {
96 const q = fresh[rule.symbol]
97 if (!q) return rule
98 const { isFired, isArmed } = evaluateAlert(rule, q.price)
99 if (isFired) $.ui.toast(`🔔 ${rule.symbol} ${rule.op} ${rule.level}: now ${formatPrice(q.price)}`, { timeoutMs: 15_000 })
100 if (isArmed !== rule.isArmed) isChanged = true
101 return isArmed === rule.isArmed ? rule : { ...rule, isArmed }
102 })
103 if (isChanged) await $.store.set(STORE_ALERTS, next)
104}
105
106
107export const register: Register = on => {
108 on('session.start', async ($, e, next) => {
109 await $.command.register({ name: 'watch', description: 'Show or edit the market-watch ticker symbols', argumentHint: '[add|remove|set SYM…] | reset' })
110 await $.command.register({ name: 'quote', description: 'Open a quote pane for a symbol', argumentHint: 'SYM' })
111 await $.command.register({ name: 'alert', description: 'Set, list or clear price alerts', argumentHint: 'SYM > 260 | rm <id> | clear' })
112 await $.command.register({ name: 'market', description: 'Show or hide the market-context band above the prompt' })
113 await $.tool.register({
114 name: 'quote',
115 description:
116 'Live-ish stock/ETF/index quotes from Yahoo Finance. Use instead of recalling prices from memory. ' +
117 'Returns price, day change, optional period return over `range`, day range, volume, and an as-of timestamp. ' +
118 `Up to ${MAX_TOOL_SYMBOLS} symbols per call. Index symbols use a caret (^VIX, ^GSPC).`,
119 inputSchema: {
120 type: 'object',
121 properties: {
122 symbols: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: MAX_TOOL_SYMBOLS },
123 range: { type: 'string', enum: [...RANGES], description: 'Period for the return figure; default 1d' },
124 },
125 required: ['symbols'],
126 },
127 isDeferred: false,
128 })
129 $.clock.every(OPEN_POLL_MS, () => void poll($))
130 void poll($)
131 return next(e)
132 })
133
134 on('command.run', { command: 'watch' }, async ($, e) => {
135 const cmd = parseWatchArgs(e.args)
136 const current = await loadWatchlist($)
137 if (cmd.kind === 'usage') return { text: cmd.message }
138 if (cmd.kind === 'show') return { text: `Watching: ${current.join(', ')}` }
139 const updated = applyWatch(current, cmd)
140 await $.store.set(STORE_WATCHLIST, updated)
141 $.ui.status(formatTicker(updated, await read($, quotes)))
142 void poll($, true)
143 const skipped = 'rejected' in cmd && cmd.rejected.length > 0 ? ` (ignored invalid: ${cmd.rejected.join(', ')})` : ''
144 return { text: `Watching: ${updated.join(', ')}${skipped}` }
145 })
146
147 on('command.run', { command: 'quote' }, async ($, e) => {
148 const symbol = normalizeSymbol(e.args)
149 if (symbol === null) return { text: 'Usage: /quote SYM (e.g. /quote NVDA)' }
150 const parsed = await fetchChart($, symbol)
151 if (!parsed.isOk) return { text: `Could not load ${symbol}: ${parsed.error}` }
152 await update($, quotes, prev => ({ ...prev, [symbol]: parsed.quote }))
153 await update($, focus, () => symbol)
154 await $.ui.open({ id: PANE, title: symbol })
155 return { text: `${symbol} ${formatPrice(parsed.quote.price)} ${formatPct(parsed.quote.dayChangePct)}` }
156 })
157
158 on('command.run', { command: 'alert' }, async ($, e) => {
159 const cmd = parseAlertArgs(e.args)
160 const rules = await loadAlerts($)
161 switch (cmd.kind) {
162 case 'usage':
163 return { text: cmd.message }
164 case 'list':
165 return { text: rules.length === 0 ? 'No alerts.' : rules.map(formatAlert).join('\n') }
166 case 'clear':
167 await $.store.set(STORE_ALERTS, [])
168 return { text: `Cleared ${rules.length} alert(s).` }
169 case 'remove': {
170 const kept = rules.filter(r => r.id !== cmd.id)
171 await $.store.set(STORE_ALERTS, kept)
172 return { text: kept.length === rules.length ? `No alert ${cmd.id}.` : `Removed ${cmd.id}.` }
173 }
174 case 'add': {
175 const nextId = 1 + Math.max(0, ...rules.map(r => Number(r.id.slice(1)) || 0))
176 const rule: AlertRule = { id: `a${nextId}`, symbol: cmd.symbol, op: cmd.op, level: cmd.level, isArmed: true }
177 await $.store.set(STORE_ALERTS, [...rules, rule])
178 const watching = (await loadWatchlist($)).includes(cmd.symbol)
179 const hint = watching ? '' : ` Note: ${cmd.symbol} is not on the watchlist, so it is not polled; /watch add ${cmd.symbol}.`
180 return { text: `Added ${formatAlert(rule)}.${hint}` }
181 }
182 }
183 })
184
185 on('command.run', { command: 'market' }, async $ => {
186 const isHidden = !(await read($, isBandHidden))
187 await update($, isBandHidden, () => isHidden)
188 return { text: isHidden ? 'Market band hidden.' : 'Market band shown.' }
189 })
190
191 on('tool.call', { tool: TOOL }, async ($, e) => {
192 const input = e as unknown as { symbols?: unknown; range?: unknown }
193 const range: Range = RANGES.includes(input.range as Range) ? (input.range as Range) : '1d'
194 const raw = Array.isArray(input.symbols) ? input.symbols.map(String) : []
195 const symbols = [...new Set(raw.map(normalizeSymbol).filter((s): s is string => s !== null))].slice(0, MAX_TOOL_SYMBOLS)
196 if (symbols.length === 0) return { deny: 'quote: give 1-8 ticker symbols, e.g. {"symbols": ["AAPL"]}' }
197
198 const fetchedAtMs = await $.clock.now()
199 // Parallel here (unlike the poller): the model is waiting, and 8 is the cap.
200 const parsed = await Promise.all(symbols.map(s => fetchChart($, s, range)))
201 const lines = parsed.map(p => (p.isOk ? formatForModel(p.quote, range, p.rangeBaseClose, fetchedAtMs) : `ERROR ${p.error}`))
202 return { result: [toolHeader(fetchedAtMs), ...lines].join('\n') }
203 })
204
205 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
206 const { Box, Text } = $.ui.resolve(e)
207 const symbol = await read($, focus)
208 const q = symbol ? (await read($, quotes))[symbol] : undefined
209 if (!q) return <Text dimColor>Run /quote SYM to load a symbol.</Text>
210 const width = Math.max(10, (e.props.bodyColumns ?? 40) - 2)
211 const lo = q.closes.length ? Math.min(...q.closes) : null
212 const hi = q.closes.length ? Math.max(...q.closes) : null
213 return (
214 <Box flexDirection="column">
215 <Text bold>{q.symbol} · {q.name}</Text>
216 <Text>
217 {formatPrice(q.price)} {q.currency} {formatPct(q.dayChangePct)}
218 </Text>
219 <Text>{sparkline(q.closes, width)}</Text>
220 {lo !== null && hi !== null && <Text dimColor>chart low {formatPrice(lo)} · high {formatPrice(hi)}</Text>}
221 {q.dayLow !== null && q.dayHigh !== null && (
222 <Text dimColor>day range {formatPrice(q.dayLow)}–{formatPrice(q.dayHigh)}</Text>
223 )}
224 {q.volume !== null && <Text dimColor>volume {q.volume.toLocaleString('en-US')}</Text>}
225 <Text dimColor>as of {new Date(q.asOfMs).toISOString().slice(0, 16).replace('T', ' ')} UTC</Text>
226 </Box>
227 )
228 })
229
230 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
231 if (e.props.hasSurvey || (await read($, isBandHidden))) return next(e)
232 const held = await read($, quotes)
233 const vix = held['^VIX']
234 const tnx = held['^TNX']
235 const error = await read($, lastError)
236 if (!vix && !tnx && !error) return next(e)
237
238 const now = await $.clock.now()
239 const ages = Object.values(held).map(q => q.asOfMs)
240 const newest = ages.length ? Math.max(...ages) : 0
241 const { Box, Button, Text } = $.ui.resolve(e)
242 return (
243 <Box>
244 <Text dimColor>
245 {isMarketOpen(now, held) ? 'Market open' : 'Market closed'}
246 {vix ? ` · VIX ${vix.price.toFixed(2)} ${formatPct(vix.dayChangePct)}` : ''}
247 {tnx ? ` · 10Y ${tnx.price.toFixed(2)}%` : ''}
248 {newest ? ` · last trade ${Math.max(0, Math.round((now - newest) / 60_000))}m ago` : ''}
249 {error ? ` · ⚠ ${error.slice(0, 60)}` : ''}{' '}
250 </Text>
251 <Button key="hide" label="Hide" onPress={() => update($, isBandHidden, () => true)} />
252 </Box>
253 )
254 })
255}
256hooks/core.ts 261 lines1// Pure core of market-watch: no `$`, no I/O, no clock. Everything here takes
2// values and returns values so core.test.ts can pin it without mocks.
3
4import type { AlertRule, Quote } from '../types'
5
6export const DEFAULT_WATCHLIST: readonly string[] = ['SPY', 'META', 'AAPL', 'AMZN', 'NFLX', 'GOOGL']
7/** Shown in the band, not the ticker: volatility and the 10-year yield (in %). */
8export const CONTEXT_SYMBOLS: readonly string[] = ['^VIX', '^TNX']
9export const MAX_WATCHLIST = 12
10export const MAX_TOOL_SYMBOLS = 8
11
12export const SOURCE = 'Yahoo Finance chart API (unofficial, keyless)'
13
14export const RANGES = ['1d', '5d', '1mo', '3mo', '6mo', 'ytd', '1y'] as const
15export type Range = (typeof RANGES)[number]
16const INTERVAL_FOR: Record<Range, string> = {
17 '1d': '5m', '5d': '30m', '1mo': '1d', '3mo': '1d', '6mo': '1d', 'ytd': '1d', '1y': '1d',
18}
19
20// Equities, ETFs, indices (^VIX), share classes (BRK.B, BRK-B), FX/futures (EURUSD=X, ES=F).
21const SYMBOL_PATTERN = /^\^?[A-Z0-9][A-Z0-9.\-=]{0,14}$/
22
23/** Upper-cases and validates one ticker; null when it cannot be a symbol. */
24export function normalizeSymbol(raw: string): string | null {
25 const symbol = raw.trim().toUpperCase()
26 return SYMBOL_PATTERN.test(symbol) ? symbol : null
27}
28
29export function chartUrl(symbol: string, range: Range = '1d'): string {
30 const query = `range=${range}&interval=${INTERVAL_FOR[range]}`
31 return `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?${query}`
32}
33
34export type ParsedChart =
35 | { isOk: true; quote: Quote; rangeBaseClose: number | null }
36 | { isOk: false; error: string }
37
38const num = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
39
40/**
41 * Parses a chart response body. Yahoo answers an unknown symbol with a 404
42 * whose body still holds `chart.error`, so the body is read whatever the status.
43 */
44export function parseChart(symbol: string, status: number, body: string): ParsedChart {
45 let json: any
46 try {
47 json = JSON.parse(body)
48 } catch {
49 return { isOk: false, error: `${symbol}: HTTP ${status}, body is not JSON` }
50 }
51 const apiError = json?.chart?.error
52 if (apiError) return { isOk: false, error: `${symbol}: ${apiError.description ?? apiError.code ?? 'error'}` }
53 const result = json?.chart?.result?.[0]
54 const meta = result?.meta
55 const price = num(meta?.regularMarketPrice)
56 if (!meta || price === null) return { isOk: false, error: `${symbol}: HTTP ${status}, no price in response` }
57
58 const regular = meta.currentTradingPeriod?.regular
59 const startS = num(regular?.start)
60 const endS = num(regular?.end)
61 const rawCloses: unknown[] = result?.indicators?.quote?.[0]?.close ?? []
62
63 // An index (^VIX, ^TNX) is a level, not a price: no currency, no traded volume.
64 const isIndex = meta.instrumentType === 'INDEX'
65 const quote: Quote = {
66 symbol: String(meta.symbol ?? symbol),
67 name: String(meta.shortName ?? meta.longName ?? symbol),
68 currency: isIndex ? '' : String(meta.currency ?? ''),
69 price,
70 dayChangePct: num(meta.regularMarketChangePercent),
71 dayHigh: num(meta.regularMarketDayHigh),
72 dayLow: num(meta.regularMarketDayLow),
73 volume: isIndex ? null : num(meta.regularMarketVolume),
74 asOfMs: (num(meta.regularMarketTime) ?? 0) * 1000,
75 session: startS !== null && endS !== null ? { startMs: startS * 1000, endMs: endS * 1000 } : null,
76 closes: rawCloses.map(num).filter((v): v is number => v !== null),
77 }
78 // For range=1d this is yesterday's close; for longer ranges it is the close
79 // before the range began, which is what a period return is measured from.
80 return { isOk: true, quote, rangeBaseClose: num(meta.chartPreviousClose) }
81}
82
83const BLOCKS = '▁▂▃▄▅▆▇█'
84
85/**
86 * Min-max scaled sparkline, `width` glyphs wide. Min-max makes small moves
87 * look large; that is deliberate for an intraday shape, and the pane prints
88 * the real range beside it so the shape is never read as magnitude.
89 */
90export function sparkline(values: readonly number[], width: number): string {
91 if (values.length === 0 || width < 1) return ''
92 const buckets: number[] = []
93 const per = values.length / Math.min(width, values.length)
94 for (let i = 0; i < Math.min(width, values.length); i++) {
95 const v = values[Math.min(values.length - 1, Math.floor((i + 1) * per) - 1)]
96 if (v !== undefined) buckets.push(v)
97 }
98 const lo = Math.min(...buckets)
99 const hi = Math.max(...buckets)
100 if (hi === lo) return BLOCKS.charAt(3).repeat(buckets.length)
101 return buckets.map(v => BLOCKS.charAt(Math.round(((v - lo) / (hi - lo)) * (BLOCKS.length - 1)))).join('')
102}
103
104export function formatPct(pct: number | null): string {
105 if (pct === null) return '—'
106 const arrow = pct > 0 ? '▲' : pct < 0 ? '▼' : '·'
107 return `${arrow}${Math.abs(pct).toFixed(2)}%`
108}
109
110export function formatPrice(price: number): string {
111 return price >= 1000 ? price.toFixed(0) : price.toFixed(2)
112}
113
114/** The status-line text: `SPY 571.20 ▲0.80% · AAPL …`; missing symbols show `…`. */
115export function formatTicker(list: readonly string[], quotes: Readonly<Record<string, Quote>>): string {
116 return list
117 .map(symbol => {
118 const q = quotes[symbol]
119 return q ? `${symbol} ${formatPrice(q.price)} ${formatPct(q.dayChangePct)}` : `${symbol} …`
120 })
121 .join(' · ')
122}
123
124/** Grace after the close so the closing print is captured before going idle. */
125export const CLOSE_GRACE_MS = 5 * 60_000
126
127/**
128 * True while any held quote's exchange session contains `nowMs`. Uses the
129 * exchange's own window from the response, so DST and holidays need no code
130 * here: on a holiday Yahoo's window is the last session's, which is in the past.
131 */
132export function isMarketOpen(nowMs: number, quotes: Readonly<Record<string, Quote>>): boolean {
133 return Object.values(quotes).some(
134 q => q.session !== null && nowMs >= q.session.startMs && nowMs <= q.session.endMs + CLOSE_GRACE_MS,
135 )
136}
137
138export type WatchCommand =
139 | { kind: 'show' }
140 | { kind: 'reset' }
141 | { kind: 'add' | 'remove' | 'set'; symbols: string[]; rejected: string[] }
142 | { kind: 'usage'; message: string }
143
144const WATCH_USAGE = 'Usage: /watch [add|remove|set] SYM… | /watch reset'
145
146export function parseWatchArgs(args: string): WatchCommand {
147 const [verb = '', ...rest] = args.trim().split(/[\s,]+/).filter(Boolean)
148 const v = verb.toLowerCase()
149 if (v === '') return { kind: 'show' }
150 if (v === 'reset') return { kind: 'reset' }
151 if (v !== 'add' && v !== 'remove' && v !== 'set') return { kind: 'usage', message: WATCH_USAGE }
152 const symbols: string[] = []
153 const rejected: string[] = []
154 for (const raw of rest) {
155 const s = normalizeSymbol(raw)
156 if (s === null) rejected.push(raw)
157 else if (!symbols.includes(s)) symbols.push(s)
158 }
159 if (symbols.length === 0) return { kind: 'usage', message: `${WATCH_USAGE} (no valid symbols given)` }
160 return { kind: v, symbols, rejected }
161}
162
163/** Applies a parsed edit; the list keeps insertion order and is capped. */
164export function applyWatch(current: readonly string[], cmd: WatchCommand): string[] {
165 switch (cmd.kind) {
166 case 'reset': return [...DEFAULT_WATCHLIST]
167 case 'set': return cmd.symbols.slice(0, MAX_WATCHLIST)
168 case 'add': return [...current, ...cmd.symbols.filter(s => !current.includes(s))].slice(0, MAX_WATCHLIST)
169 case 'remove': return current.filter(s => !cmd.symbols.includes(s))
170 default: return [...current]
171 }
172}
173
174export type AlertCommand =
175 | { kind: 'list' }
176 | { kind: 'clear' }
177 | { kind: 'remove'; id: string }
178 | { kind: 'add'; symbol: string; op: '>' | '<'; level: number }
179 | { kind: 'usage'; message: string }
180
181const ALERT_USAGE = 'Usage: /alert SYM > 260 | /alert SYM < 250 | /alert rm <id> | /alert clear | /alert'
182
183export function parseAlertArgs(args: string): AlertCommand {
184 const text = args.trim()
185 if (text === '') return { kind: 'list' }
186 if (/^clear$/i.test(text)) return { kind: 'clear' }
187 const rm = /^(?:rm|remove)\s+(\S+)$/i.exec(text)
188 if (rm?.[1]) return { kind: 'remove', id: rm[1] }
189 const m = /^(\S+)\s*([<>])\s*(\d+(?:\.\d+)?)$/.exec(text)
190 const [, rawSymbol, op, rawLevel] = m ?? []
191 if (!rawSymbol || !op || !rawLevel) return { kind: 'usage', message: ALERT_USAGE }
192 const symbol = normalizeSymbol(rawSymbol)
193 const level = Number(rawLevel)
194 if (symbol === null || !(level > 0)) return { kind: 'usage', message: ALERT_USAGE }
195 return { kind: 'add', symbol, op: op as '>' | '<', level }
196}
197
198/** How far price must retreat past the level, as a fraction, before re-arming. */
199export const REARM_FRACTION = 0.005
200
201/**
202 * Decides whether `rule` fires at `price`, and whether it is armed afterwards.
203 *
204 * Called once per poll per rule. Without hysteresis, a price hovering around
205 * the level fires on every poll; the rule must fire on the crossing, then stay
206 * quiet until the price has moved back past the level by REARM_FRACTION.
207 *
208 * Examples for { op: '>', level: 100 }:
209 * armed, price 101 -> fires, now disarmed
210 * disarmed, price 100.2 -> quiet, stays disarmed (still within the band)
211 * disarmed, price 99.4 -> quiet, re-armed (retreated more than 0.5%)
212 */
213export function evaluateAlert(rule: AlertRule, price: number): { isFired: boolean; isArmed: boolean } {
214 const isPast = rule.op === '>' ? price > rule.level : price < rule.level
215 if (rule.isArmed) return { isFired: isPast, isArmed: !isPast }
216 // Disarmed: re-arm only once price has retreated through the band on the far side.
217 const hasRetreated = rule.op === '>'
218 ? price < rule.level * (1 - REARM_FRACTION)
219 : price > rule.level * (1 + REARM_FRACTION)
220 return { isFired: false, isArmed: hasRetreated }
221}
222
223export function formatAlert(rule: AlertRule): string {
224 return `${rule.id} ${rule.symbol} ${rule.op} ${rule.level}${rule.isArmed ? '' : ' (fired, waiting to re-arm)'}`
225}
226
227const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
228
229/**
230 * The text the model reads from the quote tool. Every line carries the as-of
231 * time and the source, so a stale or after-hours price is never presented as live.
232 */
233export function formatForModel(
234 quote: Quote,
235 range: Range,
236 rangeBaseClose: number | null,
237 fetchedAtMs: number,
238): string {
239 const parts = [
240 `${quote.symbol} (${quote.name})`,
241 `price ${quote.price}${quote.currency ? ` ${quote.currency}` : ''}`,
242 `day ${quote.dayChangePct === null ? 'n/a' : `${quote.dayChangePct.toFixed(2)}%`}`,
243 ]
244 if (range !== '1d') {
245 parts.push(
246 rangeBaseClose === null
247 ? `${range} return n/a`
248 : `${range} return ${((quote.price / rangeBaseClose - 1) * 100).toFixed(2)}% (from ${rangeBaseClose})`,
249 )
250 }
251 if (quote.dayLow !== null && quote.dayHigh !== null) parts.push(`day range ${quote.dayLow}–${quote.dayHigh}`)
252 if (quote.volume !== null) parts.push(`volume ${quote.volume}`)
253 const isClosed = quote.session !== null && fetchedAtMs > quote.session.endMs + CLOSE_GRACE_MS
254 parts.push(`as of ${iso(quote.asOfMs)}${isClosed ? ' (market closed; last regular-session price)' : ''}`)
255 return parts.join(' | ')
256}
257
258export function toolHeader(fetchedAtMs: number): string {
259 return `Source: ${SOURCE}. Fetched ${iso(fetchedAtMs)}. Prices may be delayed; not investment advice.`
260}
261types/index.d.ts 41 lines1/** One symbol's latest quote, as parsed from Yahoo's chart endpoint. */
2export type Quote = {
3 symbol: string
4 name: string
5 currency: string
6 price: number
7 /** Percent change vs the prior regular-session close (2.38 means +2.38%). */
8 dayChangePct: number | null
9 dayHigh: number | null
10 dayLow: number | null
11 volume: number | null
12 /** Epoch ms of the last regular-market trade the price reflects. */
13 asOfMs: number
14 /** The exchange's regular session for the current day, epoch ms. */
15 session: { startMs: number; endMs: number } | null
16 /** Intraday closes, oldest first, nulls dropped. Drives the sparkline. */
17 closes: number[]
18}
19
20/** A price alert. `isArmed` false means it fired and waits to re-arm. */
21export type AlertRule = {
22 id: string
23 symbol: string
24 op: '>' | '<'
25 level: number
26 isArmed: boolean
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 'market-watch': {
32 quotes: Record<string, Quote>
33 /** The symbol the /quote pane shows. */
34 focus: string | null
35 isBandHidden: boolean
36 /** The last poll failure, shown in the band; null after a clean poll. */
37 lastError: string | null
38 }
39 }
40}
41