SLOPSHOPPER

market-watch

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

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-10rajib2k5/claude-market-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · market-watch
│ ┃ market-quote ✕ › fix the failing auth test and add an audit log call │ ┃ Run /quote SYM to load a symbol. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /watch │ ⎿ market-watch: Watching: SPY, META, AAPL, AMZN, NFLX, GOOGL │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ market-watch: SPY … · META … · AAPL … · AMZN … · NFLX … · GOOGL …

Draws

Pane · market-quote
Run /quote SYM to load a symbol.
README

market-watch

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.

Features

FeatureWhere it showsHow you use it
TickerStatus lineStarts with the session. Default watchlist: SPY, META, AAPL, AMZN, NFLX, GOOGL.
Editable watchlistSlash command/watch, /watch add NVDA TSLA, /watch remove NFLX, /watch set QQQ IWM, /watch reset. Saved across sessions, 12 symbols max.
Quote paneSide pane/quote NVDA: price, day change, intraday sparkline, chart low/high, day range, volume, as-of time.
Price alertsToast/alert TSLA > 260, /alert SPY < 550, /alert (list), /alert rm a1, /alert clear.
Market bandRow above the promptMarket open/closed, VIX, 10-year yield, data age, last poll error. /market or the Hide button toggles it.
quote toolClaude calls itAsk "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).

Alert behaviour

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.

Polling

  • One request per symbol (the watchlist plus ^VIX and ^TNX), made one after another.
  • Every 60 s while the exchange's regular session is open, plus 5 minutes after the close to catch the closing print.
  • Every 15 min while the market is closed, only to learn the next session's window.
  • The open/closed decision uses the session window Yahoo returns with each quote, so there is no timezone, DST or holiday calendar code to get wrong.

Requirements

MinimumTested on
Claude Code in a terminalv2.1.2872.1.287
Claude Code in the Desktop app (Code tab)v2.1.2862.1.293
NetworkHTTPS to query1.finance.yahoo.com
gitNeeded 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_HOOKS during 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).

Quick start

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.

What it hooks, and what it can reach

Reviewers and cautious users: this is the complete surface, as reported by claude plugin validate.

Hookssession.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
NetworkGET https://query1.finance.yahoo.com/v8/finance/chart/&lt;SYMBOL&gt; only. Symbols are validated against ^\^?[A-Z0-9][A-Z0-9.\-=]{0,14}$ and URL-encoded. No credentials are sent.
Stored on diskYour watchlist and alerts, in the plugin's own $.store. Nothing else.
Does not touchYour files ($.fs), shell ($.process), other tools' calls, your prompts, the system prompt, or the transcript. It hooks no other tool and cannot block anything.

Data source

Quotes come from Yahoo Finance's unofficial, undocumented chart endpoint (/v8/finance/chart). It needs no key, but:

  • It can change or start refusing requests without notice. The batch quote endpoint (/v7/finance/quote) already returns 401, which is why this mod makes one request per symbol.
  • Prices may be delayed, and Yahoo's terms govern use of the data. This mod is for personal, informational use.
  • Every 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.

Deployment guide

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.

1. Install for yourself

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):

ScopeLoads inWritten to
userEvery session on your machine~/.claude/settings.json
projectThis repository, for everyone who trusts it.claude/settings.json (commit it)
localThis 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.

2. Check that it is running

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.
  • The status line fills with prices within a few seconds of the session starting.

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.

3. Roll out to a team

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.

4. Pin a version

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.

5. Get updates

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.

6. Release a new version (maintainer)

  1. Gate. All of these must pass:
   claude plugin test .
   claude plugin validate .
   claude plugin validate .claude-plugin/plugin.json
   npx -p typescript@5.6.3 tsc -p .
  1. Bump 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.
  2. Record the change in CHANGELOG.md.
  3. Commit and push to main.
  4. Tag the release. Check first with --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
  1. Smoke test the published copy in a throwaway config, so your own settings are untouched:
   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: ….

7. Roll back a bad release

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.

8. Turn it off or remove it

GoalDo 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 modsclaude --safe-mode
Uninstallclaude plugin uninstall market-watch@claude-market-watch (add --keep-data to keep the plugin's data directory for a later reinstall)
Remove the marketplace tooclaude plugin marketplace remove claude-market-watch (this also uninstalls its plugins)

9. Troubleshooting

SymptomCause and fix
/watch is not a commandThe 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 marketplaceYou installed by the wrong id. Use market-watch@claude-market-watch.
Marketplace add fails on a git errorNo SSH key for GitHub. Set CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 and retry.
Status line shows SPY … and never fillsThe 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 moveExpected outside US regular hours. The mod then polls every 15 minutes.
An alert never firesThe 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 arriveThe maintainer did not bump version, or auto-update is off. Run claude plugin update market-watch@claude-market-watch.
tsc reports TS2589You are type-checking with the generated claude-code-mcp types. Use this repository's tsconfig.json, which leaves them out.

How it works

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
  • Pure core, imperative shell. Everything that decides anything lives in 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.
  • Every fetch has an 8 s timeout and never throws, so one bad symbol cannot stop a poll. Polls never overlap.

Development

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.

Limitations

  • Yahoo's endpoint is unofficial; see Data source.
  • Up to 12 watchlist symbols, polled one by one, which keeps you clear of Yahoo's rate limit. More symbols would make each poll slower.
  • Alerts are checked only on a poll, so with a 60 s poll a brief spike between polls can be missed.
  • The pane opens from /quote at any width. The band and status line need no width.

Changelog and product notes

  • CHANGELOG.md: what changed in each release.
  • PRFAQ.md: why this exists, alternatives considered, risks, and what is next.

License

MIT © 2026 Rajib Bahar

Not affiliated with or endorsed by Anthropic or Yahoo.

Source 3 files
hooks/register.tsx 256 lines
1import { 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}
256
hooks/core.ts 261 lines
1// 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}
261
types/index.d.ts 41 lines
1/** 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