From 208a7c8d39f660101cc8750b653a9d68b6107bef Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 8 Aug 2026 16:20:10 +0000 Subject: [PATCH] feat(crypto): `/crypto` market data, alongside `/ticker` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit advis0r.com now serves crypto under /crypto/**, so the pit gets the sibling of `ticker` that surface implies: `moshcode crypto` (and `/crypto` in the pit), plus a `crypto@moshcode` plugin for Claude Code. A sibling rather than a mode of `ticker`, because the two answer different questions from different data. A ticker report is a stored snapshot built from transcripts, SEC fundamentals and extracted signals — its risk is a stale price read as a live one. A crypto report is a live venue read with none of those — its risk runs the other way. One code path would mean one set of labels lying about one of them, so the technical score, which counts venue-local liquidity, is never presented as comparable to an equity's. moshcode crypto BTC # price, technicals, score, supply, book moshcode crypto lookup bitcoin # asset name → BTC/USD moshcode crypto spark BTC ETH SOL # recent moves, ranked moshcode crypto bars ETH --timeframe 1Hour moshcode crypto book BTC-USD --depth 5 Notes on two things that would otherwise be quietly wrong: - Prices are formatted at the precision the pair trades at. A fixed two decimals renders half the index — SHIB near $0.000006 — as "$0.00". - Upstream treats `bars`' `limit` as a page size over its own window, not a cap on what returns: `--limit 5` came back with seventeen bars. The renderer honours the flag and says that it trimmed, rather than breaking the promise silently or printing everything. The plugin frontmatter test now iterates the marketplace manifest instead of hard-coding plugins/ticker, so a plugin added later cannot ship unchecked. Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/marketplace.json | 12 + README.md | 45 +- bin/moshcode.mjs | 8 + plugins/crypto/.claude-plugin/plugin.json | 13 + plugins/crypto/README.md | 66 ++ plugins/crypto/commands/bars.md | 35 + plugins/crypto/commands/book.md | 31 + plugins/crypto/commands/coin.md | 31 + plugins/crypto/commands/crypto.md | 50 ++ plugins/crypto/commands/pairs.md | 31 + plugins/crypto/commands/quote.md | 31 + plugins/crypto/commands/spark.md | 36 + src/cli-schema.mjs | 69 ++ src/crypto.mjs | 877 ++++++++++++++++++++++ src/plugins.mjs | 5 + src/tui.mjs | 7 + test/crypto.test.mjs | 288 +++++++ test/plugins.test.mjs | 33 +- 18 files changed, 1654 insertions(+), 14 deletions(-) create mode 100644 plugins/crypto/.claude-plugin/plugin.json create mode 100644 plugins/crypto/README.md create mode 100644 plugins/crypto/commands/bars.md create mode 100644 plugins/crypto/commands/book.md create mode 100644 plugins/crypto/commands/coin.md create mode 100644 plugins/crypto/commands/crypto.md create mode 100644 plugins/crypto/commands/pairs.md create mode 100644 plugins/crypto/commands/quote.md create mode 100644 plugins/crypto/commands/spark.md create mode 100644 src/crypto.mjs create mode 100644 test/crypto.test.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1205359..a42e0a9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -18,6 +18,18 @@ }, "homepage": "https://github.com/moshcoder/moshcode#ticker", "keywords": ["stocks", "equity", "research", "markets", "advis0r"] + }, + { + "name": "crypto", + "description": "Crypto market data slash commands backed by advis0r.com: live prices, technical scores, order books, OHLCV history and sparklines across Alpaca's US crypto venue.", + "source": "./plugins/crypto", + "category": "productivity", + "author": { + "name": "moshcoder", + "url": "https://moshcode.sh" + }, + "homepage": "https://github.com/moshcoder/moshcode#crypto", + "keywords": ["crypto", "bitcoin", "markets", "prices", "advis0r"] } ] } diff --git a/README.md b/README.md index 57ffad9..88394e7 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ or miss one that does. A test fails the build when it drifts. | `moshcode tools` | tools | list workflow tools and installation status | | `moshcode trade` | tools | look up markets and trade through Alpaca | | `moshcode ticker`
`advisor` | tools | equity research from advis0r.com | +| `moshcode crypto`
`coins` | tools | crypto market data from advis0r.com | | `moshcode plugin`
`plugins` | extend | install moshcode's slash commands into Claude Code | | `moshcode commands` | script | list built-in moshscript commands | | `moshcode completion` | extend | print a shell completion script | @@ -238,6 +239,37 @@ delayed and which feed produced it. Scores labelled `offline` come from deterministic rules rather than a model. It is a research aid, not advice, and nothing under `ticker` can place an order. +### Crypto market data (`moshcode crypto`) + +`crypto` is `ticker`'s sibling on the same host: advis0r's read-only crypto +routes over Alpaca's US crypto venue, which trades 24/7 and needs no extra +subscription. + +```sh +moshcode crypto BTC # price, technicals, score, supply, order book +moshcode crypto lookup bitcoin # asset name → BTC/USD +moshcode crypto quote ETH-USD # latest trade + quote, spread in bps +moshcode crypto spark BTC ETH SOL # recent moves across pairs, as sparklines +moshcode crypto bars ETH-USD --timeframe 1Hour # historical OHLCV +moshcode crypto book BTC-USD --depth 5 # top of book, both sides +moshcode crypto assets # every supported pair +moshcode crypto open BTC # the shareable page +``` + +Pairs are accepted as `BTC`, `BTC-USD`, `BTC/USD` or `BTCUSD` — a bare asset +resolves to that asset's USD pair. `--json` gives the raw response, `/crypto …` +is the same facade in the pit, and `MOSHCODE_ADVISOR_URL` points it elsewhere. + +Unlike a `ticker` report, this is a **live venue read**, not a stored snapshot — +there are no transcripts, no filings and no signals behind a crypto pair, and +the failure mode runs the other way: the price is accurate to the second and +stale by the time you act on it. Every response stamps when it was fetched. + +The technical score counts venue-local liquidity, so it is **not comparable** to +an equity's score, and each response ships the `caveats` that say so. Prices are +Alpaca's US venue alone and can differ materially from other exchanges. Research +aid, not advice — and like `ticker`, nothing under `crypto` can place an order. + ### Social posting from the pit The pit can hand a prepared post to Bluesky or Nostr without storing either @@ -340,19 +372,28 @@ inside your engine too: ```sh moshcode plugin list # what the marketplace ships, and who can take it moshcode plugin install # add the marketplace + install `ticker` +moshcode plugin install crypto # add the marketplace + install `crypto` moshcode plugin remove ticker # take it back off ``` `ticker@moshcode` adds `/ticker`, `/signals`, `/research`, `/lookup`, `/reports`, and `/discover` — the same advis0r research surface described above, -driven from inside a coding session. Restart the engine afterwards; a newly -installed plugin is not live in a session that is already running. +driven from inside a coding session. + +`crypto@moshcode` adds `/crypto`, `/quote`, `/book`, `/bars`, `/spark`, +`/pairs`, and `/coin`. It ships separately because it is a different surface, +not a mode of the first: live venue reads instead of stored snapshots, and a +score that must not be ranked against an equity's. + +Restart the engine after installing either; a newly installed plugin is not live +in a session that is already running. The equivalent by hand: ```sh claude plugin marketplace add moshcoder/moshcode claude plugin install ticker@moshcode +claude plugin install crypto@moshcode ``` Claude Code is currently the only engine with a plugin primitive. The others are diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index 09d8565..b06d8bb 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -21,6 +21,7 @@ import { selfUpdateCommand } from "../src/selfupdate.mjs"; import { describeUninstall, uninstallPlan } from "../src/uninstall.mjs"; import { mcpCommand, pluginCommand, skillCommand } from "../src/integrations.mjs"; import { tickerCommand } from "../src/advisor.mjs"; +import { cryptoCommand } from "../src/crypto.mjs"; import { canOpenBrowser, openBrowser } from "../src/open-url.mjs"; import { locate, tilde } from "../src/pwd.mjs"; import { createPrd, listPrds, authoringPrompt } from "../src/prd.mjs"; @@ -353,6 +354,13 @@ async function main() { if (code) process.exitCode = code; return; } + if (cmd === "crypto" || cmd === "coins") { + const code = await cryptoCommand(rest, { + openUrl: (url) => canOpenBrowser() && openBrowser(url), + }); + if (code) process.exitCode = code; + return; + } if (cmd === "plugin" || cmd === "plugins") { const code = await pluginCommand(rest); if (code) process.exitCode = code; diff --git a/plugins/crypto/.claude-plugin/plugin.json b/plugins/crypto/.claude-plugin/plugin.json new file mode 100644 index 0000000..8b31c56 --- /dev/null +++ b/plugins/crypto/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://anthropic.com/claude-code/plugin.schema.json", + "name": "crypto", + "description": "Crypto market data slash commands backed by advis0r.com: live prices, technical scores, order books, OHLCV history and sparklines across Alpaca's US crypto venue.", + "version": "0.1.0", + "author": { + "name": "moshcoder", + "url": "https://moshcode.sh" + }, + "homepage": "https://github.com/moshcoder/moshcode#crypto", + "license": "MIT", + "keywords": ["crypto", "bitcoin", "markets", "prices", "advis0r"] +} diff --git a/plugins/crypto/README.md b/plugins/crypto/README.md new file mode 100644 index 0000000..c1af924 --- /dev/null +++ b/plugins/crypto/README.md @@ -0,0 +1,66 @@ +# crypto — market data in your engine 🤘 + +Slash commands backed by [advis0r.com](https://advis0r.com/api/crypto): live +prices, technical scores, order books, OHLCV history and multi-pair sparklines +across Alpaca's US crypto venue. + +| command | what it does | +| --- | --- | +| `/crypto BTC` | price, technicals, score, supply, order book | +| `/quote ETH-USD` | latest trade and quote, with the spread in bps | +| `/book BTC-USD` | top of the order book, both sides | +| `/bars ETH-USD` | historical OHLCV at any supported timeframe | +| `/spark BTC ETH SOL` | recent moves across pairs, ranked | +| `/pairs` | every supported pair, grouped by quote asset | +| `/coin bitcoin` | asset name → `BTC/USD` | + +Pairs are accepted as `BTC`, `BTC-USD`, `BTC/USD` or `BTCUSD`. A bare asset +resolves to that asset's USD pair. + +## Install + +```bash +moshcode plugin install crypto +``` + +Or straight from Claude Code: + +```bash +claude plugin marketplace add moshcoder/moshcode +claude plugin install crypto@moshcode +``` + +Restart the engine afterwards — a newly installed plugin is not live in a +session that is already running. + +## How it works + +Each command shells out to `moshcode crypto …`, which calls advis0r's public, +read-only API. No key, no login, no write routes. With `moshcode` absent, every +command falls back to `curl` against the same endpoints. + +Point the commands at another instance with `MOSHCODE_ADVISOR_URL`. + +## Why this is separate from `ticker` + +They answer different questions from different data, and share only a hostname. +A `/ticker` report is a **stored snapshot** built from transcripts, SEC +fundamentals and extracted signals — its risk is a stale price read as a live +one. A `/crypto` report is a **live venue read** with no transcripts, no +filings and no signals — its risk is the opposite: a price that is accurate to +the second and stale by the time you act on it. + +The scores are not comparable either. The crypto technical score counts +venue-local liquidity, so ranking a coin against an equity by score is +meaningless. Both surfaces ship their own `caveats`, and both commands are +instructed to print them. + +## What this is not + +A research aid, not advice. Prices are Alpaca's US crypto venue alone and can +differ materially from other exchanges. Crypto trades 24/7 with no circuit +breakers — there is no close, no premarket, and no halt. + +Trading lives behind a different verb: `moshcode trade` wraps Alpaca, previews +orders by default, and requires an explicit `--submit`. Nothing in this plugin +can place an order. diff --git a/plugins/crypto/commands/bars.md b/plugins/crypto/commands/bars.md new file mode 100644 index 0000000..f2f2bdd --- /dev/null +++ b/plugins/crypto/commands/bars.md @@ -0,0 +1,35 @@ +--- +description: Historical crypto OHLCV for one pair, at any supported timeframe. +argument-hint: [timeframe] +allowed-tools: Bash(moshcode crypto:*), Bash(curl -sS https://advis0r.com/api/crypto/:*) +--- + +## Task + +Pull historical bars for `$ARGUMENTS`. + +```bash +moshcode crypto bars $ARGUMENTS --timeframe 1Day --limit 30 --json +``` + +Timeframes: `1Min`, `5Min`, `15Min`, `1Hour`, `1Day`, `1Week`. Add +`--start`/`--end` (ISO dates) to pin a window. + +Fallback: `curl -sS "https://advis0r.com/api/crypto/bars?symbol=&timeframe=1Day&limit=30"` + +## Reading the response + +`bars` is keyed by canonical symbol (`"BTC/USD"`), each value an ascending array +of `{ timestamp, open, high, low, close, volume, vwap }`. + +## Rules + +- **`limit` is upstream's page size, not a cap on what returns.** The response + can hold more bars than you asked for. If you show a subset, say which subset + — "the 5 most recent of 17", never a silent truncation. +- Bars are ascending by time. Confirm the direction before calling a move. +- `volume` is base-asset units on Alpaca's US venue alone, not aggregate market + volume. Do not compare it to a CoinGecko or exchange-aggregate figure. +- Crypto bars are calendar-based and continuous — no gaps, no sessions, no + weekends. A "20-day" window here is not 20 trading sessions. +- End with the response's own `disclaimer`. diff --git a/plugins/crypto/commands/book.md b/plugins/crypto/commands/book.md new file mode 100644 index 0000000..61e1284 --- /dev/null +++ b/plugins/crypto/commands/book.md @@ -0,0 +1,31 @@ +--- +description: Top of the crypto order book, both sides, with the spread. +argument-hint: +allowed-tools: Bash(moshcode crypto:*), Bash(curl -sS https://advis0r.com/api/crypto/:*) +--- + +## Task + +Show the order book for `$ARGUMENTS`. + +```bash +moshcode crypto book $ARGUMENTS --depth 10 --json +``` + +Fallback: `curl -sS "https://advis0r.com/api/crypto/orderbook?symbol=$ARGUMENTS&depth=10"` + +## Reading the response + +`orderbooks[]` each carry a `timestamp`, `bids[]` and `asks[]`, every level a +`{ price, size }`. Bids descend from the best bid; asks ascend from the best ask. + +## Rules + +- Report the spread in basis points, not just in dollars — a $50 spread means + something different on BTC than on ETH. +- **Size is depth on one venue, not the market.** Do not describe the book as + "the market's" depth, and do not extrapolate what a large order would fill at. +- A book is a snapshot of an instant. Timestamp it. +- If one side is much thinner than the other, say so plainly rather than + reading it as a directional signal — it is a liquidity observation. +- End with the response's own `disclaimer`. diff --git a/plugins/crypto/commands/coin.md b/plugins/crypto/commands/coin.md new file mode 100644 index 0000000..7e17f33 --- /dev/null +++ b/plugins/crypto/commands/coin.md @@ -0,0 +1,31 @@ +--- +description: Find a crypto pair by asset name (bitcoin → BTC/USD). +argument-hint: +allowed-tools: Bash(moshcode crypto:*), Bash(curl -sS https://advis0r.com/api/crypto/:*) +--- + +## Task + +Resolve `$ARGUMENTS` to a tradable pair. + +```bash +moshcode crypto lookup $ARGUMENTS --limit 10 --json +``` + +Fallback: `curl -sS "https://advis0r.com/api/crypto/lookup?q=&limit=10"` + +## Reading the response + +`matches` is a list of `{ symbol, slug, base, quote, name }`. + +## Rules + +- One coin usually returns several pairs — `BTC/USD`, `BTC/USDC`, `BTC/USDT`. + Show them all and say which quote asset each settles in; default to the USD + pair unless the user asked otherwise. +- Watch for name collisions: a query can match a different coin whose name + merely contains the words ("bitcoin" also returns Bitcoin Cash). Say which + match is the one they meant. +- No match: say this venue lists no such pair, and do not invent a symbol. The + coin may exist and simply not be listed here — those are different answers. +- Offer `/crypto ` for the match you land on. diff --git a/plugins/crypto/commands/crypto.md b/plugins/crypto/commands/crypto.md new file mode 100644 index 0000000..d8197ea --- /dev/null +++ b/plugins/crypto/commands/crypto.md @@ -0,0 +1,50 @@ +--- +description: Research one crypto pair — price, technicals, score, supply and order book. +argument-hint: +allowed-tools: Bash(moshcode crypto:*), Bash(curl -sS https://advis0r.com/api/crypto/:*) +--- + +## Task + +Pull the full report for `$ARGUMENTS` and summarize it for the user. + +Run: + +```bash +moshcode crypto $ARGUMENTS --json +``` + +If `moshcode` is not installed, fall back to the API directly: + +```bash +curl -sS "https://advis0r.com/api/crypto/report?symbol=$ARGUMENTS" +``` + +Pairs are accepted as `BTC`, `BTC-USD`, `BTC/USD` or `BTCUSD`. A bare asset +resolves to that asset's USD pair. + +## Reading the response + +- `snapshot` is the live read: `latestTrade`, `latestQuote` (bid/ask), + `dailyBar`, `prevDailyBar`, and a `change` against yesterday's close. +- `technical` holds sma / ema / rsi14 / macd / bollinger / atr14 / + `relativeVolume` / `momentum` / `trend` / `volatilityRegime`. +- `technicalScore.score` is 0–100 with a `breakdown`. It is **technical only** — + there is no thesis, no transcript and no filing behind a crypto pair. +- `fundamentals` is CoinGecko supply data: market cap, rank, circulating and max + supply, 24h volume, all-time high. It is absent for many pairs. +- `caveats` are per-response and specific. Read them before quoting the score. + +## Rules + +- **These prices are Alpaca's US crypto venue alone.** Say so. They can differ + materially from Coinbase, Binance, or an aggregate index. +- Crypto trades 24/7 with no circuit breakers and no market close. Never + describe a crypto price as "at the close" or "premarket". +- State `generatedAt` / `fetchedAt`. This is a live read, so it goes stale in + seconds, not days — the opposite failure mode from a stored `/ticker` report. +- The score's liquidity component counts venue-local volume only, so it is + **not comparable** to an equity's score from `/ticker`. Do not rank the two + against each other. +- End with the response's own `disclaimer`. This is research, not advice. +- Link the shareable page: `https://advis0r.com/crypto/`. diff --git a/plugins/crypto/commands/pairs.md b/plugins/crypto/commands/pairs.md new file mode 100644 index 0000000..b9b2d1c --- /dev/null +++ b/plugins/crypto/commands/pairs.md @@ -0,0 +1,31 @@ +--- +description: Every crypto pair advis0r can price, and which are trading. +allowed-tools: Bash(moshcode crypto:*), Bash(curl -sS https://advis0r.com/api/crypto/:*) +--- + +## Task + +List the supported pairs. + +```bash +moshcode crypto assets --json +``` + +Fallback: `curl -sS "https://advis0r.com/api/crypto/assets"` + +## Reading the response + +`assets[]` are `{ symbol, slug, base, quote, name, status }`. `slug` is the +URL-safe spelling (`BTC-USD`) used in paths; `symbol` is canonical (`BTC/USD`). +`status` is `live` or `idle`. + +## Rules + +- Group by `quote` asset. The same coin priced in USD, USDT and BTC are three + different markets with three different liquidity profiles. +- **`idle` means listed but not currently printing trades** — it is not the same + as unsupported. Show idle pairs, marked, rather than filtering them out. +- If the user was looking for a specific coin, use `/coin ` instead of + scanning this list for them. +- This is the coverage of one venue, not of crypto. A coin missing here is + missing *from Alpaca's US venue*. diff --git a/plugins/crypto/commands/quote.md b/plugins/crypto/commands/quote.md new file mode 100644 index 0000000..a6a7d06 --- /dev/null +++ b/plugins/crypto/commands/quote.md @@ -0,0 +1,31 @@ +--- +description: The latest crypto trade and quote, with the bid/ask spread. +argument-hint: +allowed-tools: Bash(moshcode crypto:*), Bash(curl -sS https://advis0r.com/api/crypto/:*) +--- + +## Task + +Get the current quote for `$ARGUMENTS`. + +```bash +moshcode crypto quote $ARGUMENTS --json +``` + +Fallback: `curl -sS "https://advis0r.com/api/crypto/quote?symbol=$ARGUMENTS"` + +## Reading the response + +`quotes[]` each carry `latestTrade` (price, size, timestamp), `latestQuote` +(bidPrice/bidSize/askPrice/askSize), `spread`, `spreadBps`, and `mid`. + +## Rules + +- Give the price at the precision the pair trades at. SHIB near $0.000006 + rounded to two decimals is "$0.00", which is wrong, not concise. +- **`spreadBps` is the cost of crossing.** A wide spread means thin liquidity on + this venue — report it alongside the price rather than burying it. +- `latestTrade.timestamp` and `latestQuote.timestamp` can differ. If the last + trade is old, the mid is a quote, not evidence of a trade at that level. +- Prices are Alpaca's US crypto venue and trade 24/7 — never "at the close". +- End with the response's own `disclaimer`. diff --git a/plugins/crypto/commands/spark.md b/plugins/crypto/commands/spark.md new file mode 100644 index 0000000..0a1f1a4 --- /dev/null +++ b/plugins/crypto/commands/spark.md @@ -0,0 +1,36 @@ +--- +description: Compare recent moves across several crypto pairs at once. +argument-hint: [PAIR…] +allowed-tools: Bash(moshcode crypto:*), Bash(curl -sS https://advis0r.com/api/crypto/:*) +--- + +## Task + +Compare recent price action across `$ARGUMENTS`. + +```bash +moshcode crypto spark $ARGUMENTS --period 24h --json +``` + +`--period` is `24h` or `7d`. Up to 20 pairs per call. With no pairs given, ask +which ones — or offer the majors (BTC, ETH, SOL). + +Fallback: `curl -sS "https://advis0r.com/api/crypto/sparklines?symbols=BTC-USD,ETH-USD&period=24h"` + +## Reading the response + +`series` is keyed by canonical symbol, each with `points[]` (closes, ascending), +`first`, `last`, `changePercent`, and the `start`/`end` of the window. + +## Rules + +- Lead with `changePercent` per pair and rank them — that is the question a + multi-pair call is asking. +- **Each series is scaled to itself.** A dramatic-looking shape on one pair and + a flat one on another can be the same percentage move; compare the numbers, + not the shapes. +- Name the window (`start` → `end`) and the period. "Up 5%" over 24h and over 7d + are different claims. +- Do not extrapolate a trend from 24 points, and do not call a direction + "momentum" without the technicals to back it — `/crypto ` has those. +- End with the response's own `disclaimer`. diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 8537bfb..7af2c26 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -316,6 +316,36 @@ export const CORE_CLI_COMMANDS = [ note: "research aid, not advice — reports are stored snapshots and every one prints when it was generated. Set MOSHCODE_ADVISOR_URL to point at another instance.", }, { name: "advisor", aliasOf: "ticker", description: "alias for ticker" }, + { + name: "crypto", + group: "tools", + description: "crypto market data from advis0r.com", + synopsis: [ + ["moshcode crypto ", "the full report for one pair"], + ["moshcode crypto [args…]", ""], + ], + verbs: "CRYPTO_VERBS", + flags: [ + ["--json", "print the raw API response", ""], + ["--timeframe ", "bars: 1Min | 5Min | 15Min | 1Hour | 1Day | 1Week", "1Day"], + ["--start ", "bars: window start", "the API's own default"], + ["--end ", "bars: window end", "now"], + ["--limit ", "cap results (bars/lookup)", "the API's own default"], + ["--depth ", "book: levels per side", "10"], + ["--period

", "spark: 24h | 7d", "24h"], + ["--horizon ", "technicals: quarters the score looks ahead (1 or 2)", "2"], + ], + examples: [ + ["moshcode crypto BTC", "price, technicals, score, supply, order book"], + ["moshcode crypto lookup bitcoin", "asset name → BTC/USD"], + ["moshcode crypto spark BTC ETH SOL", "24h closes as sparklines"], + ["moshcode crypto bars ETH --timeframe 1Hour", "historical OHLCV"], + ["moshcode crypto book BTC-USD --depth 5", "top of book, both sides"], + ], + seeAlso: ["ticker", "trade", "plugin"], + note: "research aid, not advice — prices are Alpaca's US crypto venue alone and can differ materially from other exchanges. Crypto trades 24/7 with no circuit breakers. Set MOSHCODE_ADVISOR_URL to point at another instance.", + }, + { name: "coins", aliasOf: "crypto", description: "alias for crypto" }, { name: "plugin", group: "extend", @@ -541,6 +571,42 @@ export const TICKER_VERBS = [ { name: "open", description: "open the shareable report page in a browser", synopsis: [["moshcode ticker open ", ""]] }, ]; +/** + * `crypto`'s verbs. + * + * `report` earns its place for the same reason ticker's does — a bare pair is + * the shortcut, so a pair that collides with a verb name needs a spelling that + * cannot be mistaken for one. src/crypto.mjs owns the parser and + * test/crypto.test.mjs fails when the two lists disagree. + */ +export const CRYPTO_VERBS = [ + { name: "report", description: "the full report for one pair", synopsis: [["moshcode crypto report ", "same as `moshcode crypto `"]] }, + { name: "quote", description: "latest trade and quote, with the bid/ask spread", synopsis: [["moshcode crypto quote ", ""]] }, + { + name: "snapshot", description: "trade, quote and daily bars for several pairs", + synopsis: [["moshcode crypto snapshot ", "up to 20 pairs"]], + }, + { + name: "technicals", description: "indicators and the technical score", + synopsis: [["moshcode crypto technicals [--horizon 1|2]", ""]], + }, + { + name: "bars", description: "historical OHLCV", + synopsis: [["moshcode crypto bars [--timeframe tf] [--start iso] [--end iso] [--limit n]", ""]], + }, + { name: "book", description: "top of the order book, both sides", synopsis: [["moshcode crypto book [--depth n]", ""]] }, + { + name: "spark", description: "recent closes, drawn as sparklines", + synopsis: [["moshcode crypto spark [--period 24h|7d]", ""]], + }, + { name: "assets", description: "every supported pair", synopsis: [["moshcode crypto assets", ""]] }, + { + name: "lookup", description: "find a pair by asset name", + synopsis: [["moshcode crypto lookup [--limit n]", "bitcoin → BTC/USD"]], + }, + { name: "open", description: "open the shareable page in a browser", synopsis: [["moshcode crypto open ", ""]] }, +]; + export const PLUGIN_VERBS = [ { name: "install", description: "add the marketplace and install a plugin", @@ -561,6 +627,7 @@ export const VERB_TABLES = { DNS_VERBS, TRADE_VERBS, TICKER_VERBS, + CRYPTO_VERBS, PLUGIN_VERBS, }; @@ -590,6 +657,8 @@ export const PIT_COMMANDS = [ description: "look up markets and preview/place Alpaca orders" }, { name: "ticker", aliases: ["advisor"], args: " [args…]", cli: "ticker", description: "equity research from advis0r.com" }, + { name: "crypto", aliases: ["coins"], args: " [args…]", cli: "crypto", + description: "crypto market data from advis0r.com" }, { name: "plugin", aliases: ["plugins"], args: " [name]", cli: "plugin", description: "install moshcode's slash commands into Claude Code" }, { name: "socials", aliases: ["social"], pitOnly: true, diff --git a/src/crypto.mjs b/src/crypto.mjs new file mode 100644 index 0000000..4649fc0 --- /dev/null +++ b/src/crypto.mjs @@ -0,0 +1,877 @@ +// `moshcode crypto` — crypto market data from advis0r.com, in the pit. +// +// The same split as src/advisor.mjs, for the same reasons: argument translation +// is pure and testable, the network call is injectable, and rendering is a +// function of the decoded JSON. Every route is public and read-only, so there +// is no login verb and no write verb — `moshcode trade` is where orders live. +// +// This is a *sibling* of `ticker`, not a mode of it, because the two answer +// different questions from different data. A ticker report is a stored snapshot +// built from transcripts, SEC fundamentals and extracted signals. A crypto +// report is a live read of Alpaca's US crypto venue: no transcripts, no +// filings, no signals, and a `fetchedAt` measured in seconds rather than days. +// Rendering them through one code path would mean one set of labels lying about +// one of them. +import { advisorBase } from "./advisor.mjs"; +import { acid, ash, amber, bone, danger, dim } from "./ui.mjs"; + +const USAGE = `usage: moshcode crypto [args…] + + the full report for one pair (BTC, BTC-USD, BTC/USD) + report same thing, when a pair looks like a verb + quote latest trade and quote, with the bid/ask spread + snapshot trade, quote and daily bars for up to 20 pairs + technicals SMA/EMA/RSI/MACD/Bollinger/ATR + technical score + bars historical OHLCV + book top of the order book, both sides + spark recent closes, drawn as sparklines + assets every supported pair + lookup find a pair by asset name (bitcoin → BTC/USD) + open open the shareable page in a browser + + --json print the raw API response + --timeframe bars: 1Min | 5Min | 15Min | 1Hour | 1Day | 1Week + --start / --end bars: the window to cover + --limit cap results (bars/lookup) + --depth book: levels per side (default 10) + --period

spark: 24h | 7d + --horizon <1|2> technicals: quarters the score looks ahead + +Research aid, not advice. Crypto trades 24/7 with no circuit breakers, and +these prices are Alpaca's US venue alone — they can differ materially from +other exchanges.`; + +export function cryptoUsage() { + return USAGE; +} + +/** Verb names, in help order. cli-schema's CRYPTO_VERBS must match (drift test). */ +export const CRYPTO_VERB_NAMES = [ + "report", "quote", "snapshot", "technicals", "bars", "book", "spark", "assets", "lookup", "open", +]; + +// The same reasoning as ticker's alias table: `/crypto price BTC` and +// `/crypto candles BTC` should not be errors when the intent is obvious. +// `search` maps to lookup rather than erroring — crypto has no transcript +// index to search, and a directory lookup is what the word means here. +const VERB_ALIASES = { + detail: "report", pair: "report", info: "report", + price: "quote", last: "quote", latest: "quote", + snap: "snapshot", snapshots: "snapshot", + technical: "technicals", ta: "technicals", indicators: "technicals", + ohlc: "bars", ohlcv: "bars", candles: "bars", history: "bars", + orderbook: "book", depth: "book", l2: "book", + sparkline: "spark", sparklines: "spark", chart: "spark", trend: "spark", + pairs: "assets", markets: "assets", symbols: "assets", list: "assets", + find: "lookup", search: "lookup", name: "lookup", coin: "lookup", + browse: "open", www: "open", web: "open", +}; + +/** Resolve a first argument to a canonical verb, or null when it is a pair. */ +export function resolveVerb(word) { + const key = String(word ?? "").toLowerCase(); + if (CRYPTO_VERB_NAMES.includes(key)) return key; + return VERB_ALIASES[key] ?? null; +} + +/** + * A crypto pair in the URL-safe form the API documents for paths, or null. + * + * The API accepts four spellings (BTC/USD, BTC-USD, BTC, BTCUSD); everything + * here is normalized to the dashed one so a request built from `BTC/USD` and + * one built from `btc-usd` are the same request. A bare asset is left bare — + * the API resolves it to that asset's USD pair, and inventing the `-USD` here + * would silently break the day a base has no USD pair. + * + * Deliberately narrow, like ticker's: the whole job of the check is to tell + * `BTC` from `bitcoin` and send the second one to lookup with a useful message + * instead of a 400. Bases run to five characters (SUSHI, MATIC, TRUMP), so six + * leaves room for the concatenated `BTCUSD` spelling without swallowing words. + */ +export function normalizeSymbol(input) { + const raw = String(input ?? "").trim().toUpperCase().replace(/\//g, "-"); + if (/^[A-Z0-9]{2,6}$/.test(raw)) return raw; + return /^[A-Z0-9]{2,6}-[A-Z0-9]{2,5}$/.test(raw) ? raw : null; +} + +function takeFlag(args, name, { boolean = false } = {}) { + const out = { value: null, rest: [], missing: false, present: false }; + for (let i = 0; i < args.length; i++) { + const arg = String(args[i]); + if (arg === name) { + out.present = true; + if (boolean) continue; + const next = args[i + 1]; + if (next == null || String(next).startsWith("-")) out.missing = true; + else { out.value = String(next); i++; } + continue; + } + if (!boolean && arg.startsWith(`${name}=`)) { + out.present = true; + const value = arg.slice(name.length + 1); + if (value === "") out.missing = true; else out.value = value; + continue; + } + out.rest.push(arg); + } + return out; +} + +function positiveInt(value, { max }) { + const n = Number(value); + if (!Number.isInteger(n) || n < 1) return null; + return Math.min(n, max); +} + +const TIMEFRAMES = ["1Min", "5Min", "15Min", "1Hour", "1Day", "1Week"]; +const PERIODS = ["24h", "7d"]; + +/** The documented cap on multi-symbol routes. Exceeding it is a 400, not a truncation. */ +export const MAX_SYMBOLS = 20; + +function canonicalTimeframe(value) { + const key = String(value).toLowerCase(); + return TIMEFRAMES.find((tf) => tf.toLowerCase() === key) ?? null; +} + +/** Normalize a list of pair arguments, reporting the first one that is not a pair. */ +function symbolList(words, verb) { + if (!words.length) return { error: `crypto ${verb} requires at least one pair` }; + const symbols = []; + for (const word of words) { + const symbol = normalizeSymbol(word); + if (!symbol) { + return { error: `${JSON.stringify(String(word))} is not a crypto pair — try: moshcode crypto lookup ${String(word)}` }; + } + symbols.push(symbol); + } + if (symbols.length > MAX_SYMBOLS) { + return { error: `crypto ${verb} accepts at most ${MAX_SYMBOLS} pairs (got ${symbols.length})` }; + } + return { symbols }; +} + +/** + * Translate `crypto` arguments into a request the caller can execute. + * + * Returns one of `{ usage }`, `{ error }`, or + * `{ verb, path, query, json, open? }` — never performs IO, so the whole + * argument surface is testable without a network. + */ +export function cryptoArgs(input = []) { + const args = input.map(String); + const jsonFlag = takeFlag(args, "--json", { boolean: true }); + let rest = jsonFlag.rest; + const json = jsonFlag.present; + + const limitFlag = takeFlag(rest, "--limit"); rest = limitFlag.rest; + const timeframeFlag = takeFlag(rest, "--timeframe"); rest = timeframeFlag.rest; + const startFlag = takeFlag(rest, "--start"); rest = startFlag.rest; + const endFlag = takeFlag(rest, "--end"); rest = endFlag.rest; + const depthFlag = takeFlag(rest, "--depth"); rest = depthFlag.rest; + const periodFlag = takeFlag(rest, "--period"); rest = periodFlag.rest; + const horizonFlag = takeFlag(rest, "--horizon"); rest = horizonFlag.rest; + + if (limitFlag.missing) return { error: "crypto --limit requires a positive number" }; + if (timeframeFlag.missing) return { error: `crypto --timeframe requires one of ${TIMEFRAMES.join(", ")}` }; + if (startFlag.missing) return { error: "crypto --start requires a date or timestamp" }; + if (endFlag.missing) return { error: "crypto --end requires a date or timestamp" }; + if (depthFlag.missing) return { error: "crypto --depth requires a positive number" }; + if (periodFlag.missing) return { error: `crypto --period requires one of ${PERIODS.join(", ")}` }; + if (horizonFlag.missing) return { error: "crypto --horizon requires 1 or 2" }; + + const limit = limitFlag.value == null ? null : positiveInt(limitFlag.value, { max: 1000 }); + if (limitFlag.value != null && limit == null) { + return { error: "crypto --limit requires a positive number" }; + } + const depth = depthFlag.value == null ? null : positiveInt(depthFlag.value, { max: 50 }); + if (depthFlag.value != null && depth == null) { + return { error: "crypto --depth requires a positive number" }; + } + const timeframe = timeframeFlag.value == null ? null : canonicalTimeframe(timeframeFlag.value); + if (timeframeFlag.value != null && timeframe == null) { + return { error: `crypto --timeframe must be one of ${TIMEFRAMES.join(", ")}` }; + } + const period = periodFlag.value == null ? null : String(periodFlag.value).toLowerCase(); + if (period != null && !PERIODS.includes(period)) { + return { error: `crypto --period must be one of ${PERIODS.join(", ")}` }; + } + if (horizonFlag.value != null && !["1", "2"].includes(String(horizonFlag.value))) { + return { error: "crypto --horizon must be 1 or 2" }; + } + + const stray = rest.find((arg) => arg.startsWith("-") && arg !== "-"); + if (stray) return { error: `unknown crypto flag ${JSON.stringify(stray)}` }; + + const [first, ...tail] = rest; + if (!first) return { usage: true }; + + const verb = resolveVerb(first); + const words = verb ? tail : rest; + + // No verb → the first word is the pair. `/crypto BTC` is the headline case + // and must stay the shortest thing anyone types. + const single = { report: "report", quote: "quote", technicals: "technicals", bars: "bars", book: "book", open: "open" }; + const wanted = verb == null ? "report" : verb; + + if (single[wanted]) { + const raw = words[0]; + if (!raw) return { error: `crypto ${wanted} requires a pair` }; + const symbol = normalizeSymbol(raw); + if (!symbol) { + return { error: `${JSON.stringify(String(raw))} is not a crypto pair — try: moshcode crypto lookup ${String(raw)}` }; + } + if (wanted === "open") { + return { verb: "open", symbol, open: `/crypto/${encodeURIComponent(symbol)}`, json }; + } + if (wanted === "report") return { verb: "report", symbol, path: "/api/crypto/report", query: { symbol }, json }; + if (wanted === "quote") return { verb: "quote", symbol, path: "/api/crypto/quote", query: { symbol }, json }; + if (wanted === "technicals") { + return { + verb: "technicals", symbol, path: "/api/crypto/technicals", + query: { symbol, ...(horizonFlag.value ? { horizon: String(horizonFlag.value) } : {}) }, json, + }; + } + if (wanted === "bars") { + return { + verb: "bars", symbol, path: "/api/crypto/bars", + query: { + symbol, + timeframe: timeframe || "1Day", + ...(startFlag.value ? { start: startFlag.value } : {}), + ...(endFlag.value ? { end: endFlag.value } : {}), + ...(limit ? { limit: String(limit) } : {}), + }, + // Upstream treats `limit` as a page size over its own window, not a cap + // on what comes back — `--limit 5` can return seventeen bars. The flag + // is carried through here so the renderer can honour what it promised, + // and say out loud that it trimmed. + limit, + json, + }; + } + return { + verb: "book", symbol, path: "/api/crypto/orderbook", + query: { symbol, ...(depth ? { depth: String(depth) } : {}) }, json, + }; + } + + if (verb === "snapshot" || verb === "spark") { + const list = symbolList(words, verb); + if (list.error) return { error: list.error }; + const symbols = list.symbols; + if (verb === "snapshot") { + return { verb, symbols, path: "/api/crypto/snapshot", query: { symbols: symbols.join(",") }, json }; + } + return { + verb, symbols, path: "/api/crypto/sparklines", + query: { symbols: symbols.join(","), period: period || "24h" }, json, + }; + } + + if (verb === "lookup") { + const q = words.join(" ").trim(); + if (!q) return { error: "crypto lookup requires something to look for" }; + return { verb, path: "/api/crypto/lookup", query: { q, ...(limit ? { limit: String(limit) } : {}) }, json }; + } + + if (verb === "assets") return { verb, path: "/api/crypto/assets", query: {}, json }; + + return { error: `unknown crypto command ${JSON.stringify(String(first))}` }; +} + +/** Build the absolute URL for a translated request. */ +export function cryptoUrl(request, { base = advisorBase() } = {}) { + const url = new URL((request.path || request.open || "/"), `${base}/`); + for (const [k, v] of Object.entries(request.query || {})) { + if (v != null && v !== "") url.searchParams.set(k, String(v)); + } + return url.toString(); +} + +/** + * Execute a translated request. `fetchImpl` is injectable for tests. + * + * Every crypto route is a live venue read, so one timeout fits all of them — + * unlike ticker, which has to budget separately for `discover`'s per-candidate + * analysis. + */ +export async function fetchCrypto(request, { fetchImpl = globalThis.fetch, base = advisorBase(), timeoutMs = 45_000 } = {}) { + const url = cryptoUrl(request, { base }); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetchImpl(url, { + signal: controller.signal, + headers: { accept: "application/json", "user-agent": "moshcode-crypto" }, + }); + const text = await res.text(); + let data; + try { data = JSON.parse(text); } catch { data = null; } + if (data == null) { + return { ok: false, status: res.status, url, error: `advis0r returned ${res.status} and not JSON` }; + } + return { ok: res.ok, status: res.status, url, data }; + } catch (e) { + const reason = e?.name === "AbortError" ? `timed out after ${Math.round(timeoutMs / 1000)}s` : (e?.message || String(e)); + return { ok: false, status: 0, url, error: `advis0r request failed: ${reason}` }; + } finally { + clearTimeout(timer); + } +} + +// ---------------------------------------------------------------- rendering + +/** Quote assets that are dollars, or a claim to be one. */ +const FIAT = new Set(["USD", "USDC", "USDT"]); + +/** + * Format a price at a precision the pair actually trades at. + * + * Crypto spans nine orders of magnitude on one venue — BTC near $65,000 and + * SHIB near $0.000006. A fixed two decimals renders half the index as "$0.00", + * so the decimals follow the magnitude. + */ +export function price(value, quote = "USD", { like } = {}) { + const n = Number(value); + if (value == null || !Number.isFinite(n)) return "—"; + // `like` prices a derived number at the precision of the number it sits next + // to: a $126 move on a $65,000 coin belongs at two decimals, the same as the + // price above it, not at the four its own magnitude would earn. + const reference = Number(like); + const abs = Math.abs(Number.isFinite(reference) ? reference : n); + const digits = abs >= 1000 ? 2 : abs >= 1 ? 4 : abs >= 0.01 ? 5 : abs >= 0.0001 ? 6 : 8; + const text = n.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits }); + return FIAT.has(String(quote).toUpperCase()) ? `$${text}` : `${text} ${String(quote).toUpperCase()}`; +} + +const num = (v, digits = 2) => + v == null || !Number.isFinite(Number(v)) ? null : Number(v).toFixed(digits).replace(/\.00$/, ""); + +/** A signed percentage, because "0.29%" and "-0.29%" must never look alike. */ +function pct(value, digits = 2) { + const n = Number(value); + if (value == null || !Number.isFinite(n)) return "—"; + return `${n >= 0 ? "+" : ""}${n.toFixed(digits)}%`; +} + +function compact(v) { + const n = Number(v); + if (!Number.isFinite(n)) return null; + const units = [[1e12, "T"], [1e9, "B"], [1e6, "M"], [1e3, "K"]]; + for (let i = 0; i < units.length; i++) { + const [size, suffix] = units[i]; + if (Math.abs(n) < size) continue; + // Same carry as advisor's: rounding can push a value up to a full thousand + // of this unit (999,999,999 → "1000M"); carry it to the next unit instead. + const scaled = (n / size).toFixed(2); + if (Math.abs(Number(scaled)) >= 1000 && i > 0) { + const [upSize, upSuffix] = units[i - 1]; + return `${(n / upSize).toFixed(2).replace(/\.?0+$/, "")}${upSuffix}`; + } + return `${scaled.replace(/\.?0+$/, "")}${suffix}`; + } + // Below 1K a raw count is more honest than "0.94K". + return Number(n.toFixed(2)).toLocaleString("en-US"); +} + +/** A timestamp trimmed to the minute — seconds and nanoseconds are noise here. */ +function stamp(v) { + if (!v) return "—"; + const s = String(v); + return s.length >= 16 ? `${s.slice(0, 16).replace("T", " ")}Z` : s; +} + +const day = (v) => (v ? String(v).slice(0, 10) : "—"); + +function clip(text, width) { + const s = String(text ?? "").replace(/\s+/g, " ").trim(); + return s.length <= width ? s : `${s.slice(0, Math.max(1, width - 1))}…`; +} + +function wrapText(text, width) { + const words = String(text).replace(/\s+/g, " ").trim().split(" "); + const lines = []; + let line = ""; + for (const word of words) { + if (line && line.length + word.length + 1 > width) { lines.push(line); line = word; } + else line = line ? `${line} ${word}` : word; + } + if (line) lines.push(line); + return lines; +} + +/** Up is acid, down is danger, flat is ash. */ +function changeTone(value) { + const n = Number(value); + if (!Number.isFinite(n) || n === 0) return ash; + return n > 0 ? acid : danger; +} + +function scoreTone(score) { + const n = Number(score); + if (!Number.isFinite(n)) return ash; + if (n >= 60) return acid; + if (n >= 40) return amber; + return danger; +} + +const SPARK_TICKS = "▁▂▃▄▅▆▇█"; + +/** Render a close series as one line of block characters. */ +export function sparkline(points) { + const values = (Array.isArray(points) ? points : []).map(Number).filter(Number.isFinite); + if (!values.length) return ""; + const min = Math.min(...values); + const max = Math.max(...values); + // A flat series has no range to scale into; drawing it at the floor would + // imply a crash, so it sits mid-band instead. + if (max === min) return SPARK_TICKS[3].repeat(values.length); + return values + .map((v) => SPARK_TICKS[Math.min(SPARK_TICKS.length - 1, Math.floor(((v - min) / (max - min)) * SPARK_TICKS.length))]) + .join(""); +} + +/** + * The API ships a disclaimer with every substantive response. Printing it is + * not decoration — this renders scored market analysis in a terminal next to a + * broker CLI that can place orders, for an asset class with no circuit breakers. + */ +function disclaimerLines(d, width) { + const text = d?.disclaimer; + if (!text) return []; + return wrapText(text, width - 4).map((line) => ` ${dim(line)}`); +} + +/** + * Caveats are per-response and specific — the score's liquidity component is + * venue-local, the 200-day window counts calendar days on a 24/7 market. They + * qualify the numbers directly above them, so they print with them. + */ +function caveatLines(d, width) { + const caveats = Array.isArray(d?.caveats) ? d.caveats : []; + if (!caveats.length) return []; + const lines = ["", ` ${ash("caveats")}`]; + for (const caveat of caveats) { + const wrapped = wrapText(caveat, width - 8); + lines.push(` ${amber("•")} ${dim(wrapped[0] ?? "")}`); + for (const line of wrapped.slice(1)) lines.push(` ${dim(line)}`); + } + return lines; +} + +/** + * A left-hand label in the report's column, padded to one width. + * + * Hand-counted spaces after each label drift the moment a label is renamed — + * `all-time high` is exactly the column width, so it would butt straight up + * against its own value. + */ +const LABEL_WIDTH = 14; +const label = (text) => ash(String(text).padEnd(LABEL_WIDTH)); + +/** `BTC/USD Bitcoin` — the identity line every renderer starts from. */ +function pairHeading(d) { + const symbol = String(d?.symbol ?? ""); + const name = d?.name && d.name !== symbol ? ` ${bone(d.name)}` : ""; + return ` ${acid(symbol)}${name}`; +} + +function quoteAsset(d) { + return String(d?.quote || String(d?.symbol ?? "").split("/")[1] || "USD").toUpperCase(); +} + +/** The price + change line, shared by report and quote. */ +function priceLine(snapshot, quote) { + const last = snapshot?.latestTrade?.price ?? snapshot?.mid ?? snapshot?.dailyBar?.close; + const change = snapshot?.change; + const bits = [bone(price(last, quote))]; + if (change) { + const paint = changeTone(change.percent); + bits.push(paint(`${change.absolute >= 0 ? "+" : ""}${price(change.absolute, quote, { like: last })}`), paint(`(${pct(change.percent)})`)); + } + const feed = [ + snapshot?.delayed === false ? "live" : snapshot?.delayed === true ? "delayed" : null, + snapshot?.feed ? `${snapshot.feed} venue` : null, + "24/7", + ].filter(Boolean).join(" · "); + return ` ${bits.join(" ")} ${ash(feed)}`; +} + +function bookLine(latestQuote, quote, extra = {}) { + if (!latestQuote) return null; + const spreadBps = extra.spreadBps ?? spreadBpsOf(latestQuote); + const parts = [ + `bid ${price(latestQuote.bidPrice, quote)} × ${num(latestQuote.bidSize, 4) ?? "—"}`, + `ask ${price(latestQuote.askPrice, quote)} × ${num(latestQuote.askSize, 4) ?? "—"}`, + spreadBps == null ? null : `spread ${num(spreadBps, 2)}bps`, + ].filter(Boolean); + return ` ${label("book")}${parts.join(ash(" · "))}`; +} + +function spreadBpsOf(latestQuote) { + const bid = Number(latestQuote?.bidPrice); + const ask = Number(latestQuote?.askPrice); + if (!Number.isFinite(bid) || !Number.isFinite(ask) || bid + ask === 0) return null; + return ((ask - bid) / ((ask + bid) / 2)) * 10_000; +} + +function technicalLines(t, quote) { + if (!t) return []; + const lines = []; + const indicators = [ + t.rsi14 == null ? null : `rsi14 ${num(t.rsi14, 1)}`, + t.sma?.[20] == null ? null : `sma20 ${price(t.sma[20], quote)}`, + t.sma?.[50] == null ? null : `sma50 ${price(t.sma[50], quote)}`, + t.sma?.[200] == null ? null : `sma200 ${price(t.sma[200], quote)}`, + t.atr14 == null ? null : `atr ${price(t.atr14, quote)}`, + t.relativeVolume == null ? null : `rvol ${num(t.relativeVolume, 2)}`, + ].filter(Boolean); + if (indicators.length) lines.push(` ${label("technical")}${indicators.join(ash(" · "))}`); + + const regime = [ + t.trend ? `trend ${t.trend}` : null, + t.volatilityRegime ? `volatility ${t.volatilityRegime}` : null, + t.goldenCross ? "golden cross" : null, + t.deathCross ? "death cross" : null, + t.breakout ? "breakout" : null, + t.breakdown ? "breakdown" : null, + ].filter(Boolean); + if (regime.length) lines.push(` ${label("regime")}${regime.map((r) => bone(r)).join(ash(" · "))}`); + + const momentum = [ + t.momentum?.[20] == null ? null : `20d ${pct(t.momentum[20], 1)}`, + t.momentum?.[60] == null ? null : `60d ${pct(t.momentum[60], 1)}`, + t.momentum?.[120] == null ? null : `120d ${pct(t.momentum[120], 1)}`, + t.distanceFrom52WeekHigh == null ? null : `from 52w high ${pct(t.distanceFrom52WeekHigh, 1)}`, + ].filter(Boolean); + if (momentum.length) lines.push(` ${label("momentum")}${momentum.join(ash(" · "))}`); + return lines; +} + +/** CoinGecko supply/market-cap facts. Absent for most pairs, and that is fine. */ +function fundamentalLines(f, quote) { + if (!f || f.source === "unavailable") return []; + const parts = [ + f.marketCap == null ? null : `cap ${compact(f.marketCap)}`, + f.marketCapRank == null ? null : `rank #${f.marketCapRank}`, + f.volume24h == null ? null : `vol24h ${compact(f.volume24h)}`, + f.circulatingSupply == null ? null : `circ ${compact(f.circulatingSupply)}${f.maxSupply ? `/${compact(f.maxSupply)}` : ""}`, + ].filter(Boolean); + const lines = []; + if (parts.length) lines.push(` ${label("market")}${parts.join(ash(" · "))}`); + if (f.ath != null) { + lines.push(` ${label("all-time high")}${bone(price(f.ath, quote))} ${ash(day(f.athDate))} ${changeTone(f.athChangePercent)(pct(f.athChangePercent, 1))}`); + } + if (lines.length && f.source) lines.push(` ${ash(`supply data: ${f.source}`)}`); + return lines; +} + +function renderReport(d, { width }) { + const quote = quoteAsset(d); + const snapshot = d.snapshot || {}; + const lines = ["", pairHeading(d), priceLine(snapshot, quote), ""]; + + const score = d.technicalScore; + if (score?.score != null) { + const paint = scoreTone(score.score); + const bits = [ + `${paint(`technical score ${num(score.score, 1)}`)}${ash("/100")}`, + score.horizonQuarters ? ash(`${score.horizonQuarters}q horizon`) : null, + ].filter(Boolean); + lines.push(` ${bits.join(ash(" "))}`); + } + + lines.push(...technicalLines(d.technical, quote)); + const book = bookLine(snapshot.latestQuote, quote); + if (book) lines.push(book); + + const bar = snapshot.dailyBar; + if (bar) { + const parts = [ + `o ${price(bar.open, quote)}`, `h ${price(bar.high, quote)}`, + `l ${price(bar.low, quote)}`, `c ${price(bar.close, quote)}`, + bar.vwap == null ? null : `vwap ${price(bar.vwap, quote)}`, + bar.volume == null ? null : `vol ${compact(bar.volume)} ${d.base ?? ""}`.trim(), + ].filter(Boolean); + lines.push(` ${label("day")}${parts.join(ash(" · "))}`); + } + + lines.push(...fundamentalLines(d.fundamentals, quote)); + + lines.push("", ` ${label("page")}${acid(`${advisorBase()}/crypto/${d.slug ?? String(d.symbol ?? "").replace("/", "-")}`)}`); + const fetchedAt = d.generatedAt || snapshot.fetchedAt; + if (fetchedAt) lines.push(` ${ash(`fetched ${stamp(fetchedAt)}`)}`); + lines.push(...caveatLines(d, width)); + lines.push("", ...disclaimerLines(d, width)); + return lines.join("\n"); +} + +function renderQuote(d, { width }) { + const quotes = Array.isArray(d.quotes) ? d.quotes : []; + if (!quotes.length) return ` ${ash("no quote came back for that pair")}`; + const lines = [""]; + for (const q of quotes) { + const quote = quoteAsset(q); + lines.push(pairHeading(q)); + const trade = q.latestTrade; + if (trade) { + lines.push(` ${bone(price(trade.price, quote))} ${ash(`last trade ${num(trade.size, 6) ?? "—"} @ ${stamp(trade.timestamp)}`)}`); + } + const book = bookLine(q.latestQuote, quote, { spreadBps: q.spreadBps }); + if (book) lines.push(book); + if (q.mid != null) lines.push(` ${label("mid")}${bone(price(q.mid, quote))}`); + lines.push(""); + } + if (d.fetchedAt) lines.push(` ${ash(`fetched ${stamp(d.fetchedAt)} · ${d.feed ?? "us"} venue · 24/7`)}`, ""); + lines.push(...disclaimerLines(d, width)); + return lines.join("\n"); +} + +function renderSnapshot(d, { width }) { + const snapshots = Array.isArray(d.snapshots) ? d.snapshots : []; + if (!snapshots.length) return ` ${ash("no snapshots came back")}`; + const lines = ["", ` ${ash(`${snapshots.length} ${snapshots.length === 1 ? "pair" : "pairs"}`)}`, ""]; + for (const s of snapshots) { + const quote = quoteAsset(s); + const change = s.change?.percent; + lines.push( + ` ${acid(String(s.symbol).padEnd(11))}` + + `${bone(price(s.latestTrade?.price ?? s.dailyBar?.close, quote).padStart(16))} ` + + `${changeTone(change)(pct(change).padStart(8))} ` + + `${ash(`h ${price(s.dailyBar?.high, quote)} · l ${price(s.dailyBar?.low, quote)}`)} ` + + `${ash(clip(s.name ?? "", 20))}`, + ); + } + const fetchedAt = snapshots.find((s) => s.fetchedAt)?.fetchedAt; + if (fetchedAt) lines.push("", ` ${ash(`fetched ${stamp(fetchedAt)} · 24/7`)}`); + if (Array.isArray(d.rejected) && d.rejected.length) { + lines.push(` ${amber(`not supported: ${d.rejected.join(", ")}`)}`); + } + lines.push("", ...disclaimerLines(d, width)); + return lines.join("\n"); +} + +function renderTechnicals(d, { width }) { + const t = d.indicators; + if (!t) return ` ${ash("no indicators came back for that pair")}`; + const quote = quoteAsset({ symbol: d.symbol }); + const lines = ["", ` ${acid(String(d.symbol ?? ""))} ${ash(`${d.bars ?? "?"} bars · as of ${stamp(t.asOf)}`)}`, ""]; + + const score = d.score; + if (score?.score != null) { + lines.push(` ${scoreTone(score.score)(`technical score ${num(score.score, 1)}`)}${ash("/100")}${score.horizonQuarters ? ash(` ${score.horizonQuarters}q horizon`) : ""}`); + const breakdown = Object.entries(score.breakdown || {}); + if (breakdown.length) { + for (const [key, value] of breakdown) { + lines.push(` ${ash(String(key).padEnd(16))}${bone(String(num(value, 2) ?? "—").padStart(6))}`); + } + } + lines.push(""); + } + + lines.push(...technicalLines(t, quote)); + if (t.lastClose != null) lines.push(` ${label("last close")}${bone(price(t.lastClose, quote))}`); + const macd = t.macd; + if (macd) { + lines.push(` ${label("macd")}${[`macd ${num(macd.macd, 2)}`, `signal ${num(macd.signal, 2)}`, `hist ${num(macd.histogram, 2)}`].join(ash(" · "))}`); + } + const bb = t.bollinger; + if (bb) { + lines.push(` ${label("bollinger")}${[`upper ${price(bb.upper, quote)}`, `mid ${price(bb.middle, quote)}`, `lower ${price(bb.lower, quote)}`].join(ash(" · "))}`); + } + const volume = [ + t.avgDailyVolume == null ? null : `avg daily ${compact(t.avgDailyVolume)}`, + t.avgDollarVolume == null ? null : `avg $ volume ${compact(t.avgDollarVolume)}`, + t.vwap == null ? null : `vwap ${price(t.vwap, quote)}`, + ].filter(Boolean); + if (volume.length) lines.push(` ${label("volume")}${volume.join(ash(" · "))}`); + + lines.push(...caveatLines(d, width)); + lines.push("", ...disclaimerLines(d, width)); + return lines.join("\n"); +} + +function renderBars(d, { width, limit }) { + const groups = Object.entries(d.bars || {}); + if (!groups.length) return ` ${ash("no bars came back for that window")}`; + const lines = []; + for (const [symbol, bars] of groups) { + const quote = quoteAsset({ symbol }); + const all = Array.isArray(bars) ? bars : []; + // The most recent bars are the ones worth keeping when trimming. + const rows = limit && all.length > limit ? all.slice(-limit) : all; + const trimmed = all.length - rows.length; + const heading = `${rows.length} × ${d.timeframe ?? "1Day"}${trimmed > 0 ? ` · newest of ${all.length}` : ""}`; + lines.push("", ` ${acid(symbol)} ${ash(heading)}`, ""); + if (!rows.length) { lines.push(` ${ash("no bars in this window")}`); continue; } + lines.push(` ${ash("when".padEnd(17))}${ash("open".padStart(14))}${ash("high".padStart(14))}${ash("low".padStart(14))}${ash("close".padStart(14))}${ash("volume".padStart(12))}`); + for (const bar of rows) { + // Intraday timeframes need the clock; daily and weekly do not. + const when = /Min|Hour/.test(String(d.timeframe ?? "")) ? stamp(bar.timestamp) : day(bar.timestamp); + const up = Number(bar.close) >= Number(bar.open); + lines.push( + ` ${ash(String(when).padEnd(17))}` + + `${bone(price(bar.open, quote).padStart(14))}` + + `${bone(price(bar.high, quote).padStart(14))}` + + `${bone(price(bar.low, quote).padStart(14))}` + + `${(up ? acid : danger)(price(bar.close, quote).padStart(14))}` + + `${ash(String(compact(bar.volume) ?? "—").padStart(12))}`, + ); + } + const closes = rows.map((b) => Number(b.close)).filter(Number.isFinite); + if (closes.length > 1) { + const move = ((closes[closes.length - 1] - closes[0]) / closes[0]) * 100; + lines.push("", ` ${ash("window")} ${changeTone(move)(pct(move))} ${dim(sparkline(closes))}`); + } + } + lines.push("", ...disclaimerLines(d, width)); + return lines.join("\n"); +} + +function renderBook(d, { width }) { + const books = Array.isArray(d.orderbooks) ? d.orderbooks : []; + if (!books.length) return ` ${ash("no order book came back for that pair")}`; + const lines = []; + for (const book of books) { + const quote = quoteAsset(book); + const bids = Array.isArray(book.bids) ? book.bids : []; + const asks = Array.isArray(book.asks) ? book.asks : []; + lines.push("", pairHeading(book), ` ${ash(stamp(book.timestamp))}`, ""); + lines.push(` ${acid("bid".padEnd(16))}${ash("size".padStart(12))} ${danger("ask".padEnd(16))}${ash("size".padStart(12))}`); + for (let i = 0; i < Math.max(bids.length, asks.length); i++) { + const bid = bids[i]; + const ask = asks[i]; + lines.push( + ` ${acid((bid ? price(bid.price, quote) : "").padEnd(16))}${ash((bid ? String(num(bid.size, 6) ?? "") : "").padStart(12))} ` + + `${danger((ask ? price(ask.price, quote) : "").padEnd(16))}${ash((ask ? String(num(ask.size, 6) ?? "") : "").padStart(12))}`, + ); + } + const spreadBps = spreadBpsOf({ bidPrice: bids[0]?.price, askPrice: asks[0]?.price }); + if (spreadBps != null) { + lines.push("", ` ${ash("spread")} ${bone(`${num(spreadBps, 2)}bps`)} ${ash(`${price((asks[0].price + bids[0].price) / 2, quote)} mid`)}`); + } + } + lines.push("", ...disclaimerLines(d, width)); + return lines.join("\n"); +} + +function renderSpark(d, { width }) { + const series = Object.entries(d.series || {}); + if (!series.length) return ` ${ash("no series came back")}`; + const lines = ["", ` ${ash(`last ${d.period ?? "24h"}`)}`, ""]; + for (const [symbol, s] of series) { + const quote = quoteAsset({ symbol }); + const paint = changeTone(s.changePercent); + lines.push( + ` ${acid(String(symbol).padEnd(11))}${paint(sparkline(s.points))} ` + + `${bone(price(s.last, quote).padStart(14))} ${paint(pct(s.changePercent).padStart(8))}`, + ); + } + const first = series[0]?.[1]; + if (first?.start) lines.push("", ` ${ash(`${stamp(first.start)} → ${stamp(first.end)}`)}`); + lines.push("", ...disclaimerLines(d, width)); + return lines.join("\n"); +} + +function renderAssets(d) { + const assets = Array.isArray(d.assets) ? d.assets : []; + if (!assets.length) return ` ${ash("no pairs are listed")}`; + const byQuote = new Map(); + for (const asset of assets) { + const key = String(asset.quote ?? "?").toUpperCase(); + if (!byQuote.has(key)) byQuote.set(key, []); + byQuote.get(key).push(asset); + } + const lines = ["", ` ${ash(`${d.count ?? assets.length} pairs${d.liveness ? ` · liveness ${d.liveness}` : ""}`)}`]; + for (const [quote, rows] of [...byQuote].sort((a, b) => b[1].length - a[1].length)) { + lines.push("", ` ${bone(`quoted in ${quote}`)} ${ash(`(${rows.length})`)}`); + // `idle` is the API's own word for a listed pair with no recent prints; + // it stays visible rather than being filtered out, because "missing" and + // "listed but not trading" are different answers to "can I trade this". + const cells = rows.map((r) => { + const paint = r.status === "live" ? acid : ash; + return `${paint(String(r.slug ?? r.symbol).padEnd(11))}${ash(clip(r.name, 14).padEnd(15))}`; + }); + for (let i = 0; i < cells.length; i += 3) lines.push(` ${cells.slice(i, i + 3).join(" ")}`); + } + lines.push("", ` ${ash("then:")} ${bone(`moshcode crypto ${assets[0].slug ?? assets[0].symbol}`)}`); + return lines.join("\n"); +} + +function renderLookup(d) { + const matches = Array.isArray(d.matches) ? d.matches : []; + if (!matches.length) return ` ${ash(`no crypto pair matches ${JSON.stringify(String(d.query ?? ""))}`)}`; + const lines = ["", ` ${ash("matches for")} ${bone(String(d.query ?? ""))}`, ""]; + for (const m of matches) { + lines.push(` ${acid(String(m.slug ?? m.symbol).padEnd(12))}${bone(clip(m.name, 28).padEnd(30))}${ash(`${m.base ?? ""}/${m.quote ?? ""}`)}`); + } + lines.push("", ` ${ash("then:")} ${bone(`moshcode crypto ${matches[0].slug ?? matches[0].symbol}`)}`); + return lines.join("\n"); +} + +/** Render a decoded API response for one verb. */ +export function renderCrypto(verb, data, { columns, limit } = {}) { + const width = Math.max(48, Math.min(Number(columns) || 88, 100)); + switch (verb) { + case "report": return renderReport(data, { width }); + case "quote": return renderQuote(data, { width }); + case "snapshot": return renderSnapshot(data, { width }); + case "technicals": return renderTechnicals(data, { width }); + case "bars": return renderBars(data, { width, limit }); + case "book": return renderBook(data, { width }); + case "spark": return renderSpark(data, { width }); + case "assets": return renderAssets(data); + case "lookup": return renderLookup(data); + default: return JSON.stringify(data, null, 2); + } +} + +/** + * Run a `crypto` invocation end to end. Returns a process exit code. + * + * `deps` exists so tests drive the whole command — parse, fetch, render — with + * no network and no stdout. + */ +export async function cryptoCommand(argv = [], deps = {}) { + const { + out = (s) => console.log(s), + fail = (s) => console.error(s), + fetchImpl, + base = advisorBase(), + openUrl, + columns = process.stdout.columns, + } = deps; + + const request = cryptoArgs(argv); + if (request.usage) { out(cryptoUsage()); return 0; } + if (request.error) { fail(danger(`✗ ${request.error}`)); return 1; } + + if (request.verb === "open") { + const url = cryptoUrl(request, { base }); + if (request.json) { out(JSON.stringify({ url }, null, 2)); return 0; } + const opened = openUrl ? openUrl(url) : false; + out(opened ? `${acid("✓ ")}opened ${bone(url)}` : `${ash("· ")}open this in a browser:\n ${acid(url)}`); + return 0; + } + + const res = await fetchCrypto(request, { fetchImpl, base }); + if (res.error) { fail(danger(`✗ ${res.error}`)); return 1; } + + // The API's own error bodies are more useful than any message invented here: + // an unsupported pair comes back naming the lookup that would have resolved it. + if (!res.ok) { + const message = res.data?.error || `advis0r returned ${res.status}`; + if (request.json) { out(JSON.stringify(res.data, null, 2)); return 1; } + fail(danger(`✗ ${message}`)); + if (res.data?.lookup) { + const q = String(res.data.lookup).split("q=")[1]; + if (q) fail(` ${ash("try:")} ${bone(`moshcode crypto lookup ${decodeURIComponent(q)}`)}`); + } + return 1; + } + + if (request.json) { out(JSON.stringify(res.data, null, 2)); return 0; } + out(renderCrypto(request.verb, res.data, { columns, limit: request.limit })); + return 0; +} diff --git a/src/plugins.mjs b/src/plugins.mjs index 696ad90..1b2ab3d 100644 --- a/src/plugins.mjs +++ b/src/plugins.mjs @@ -33,6 +33,11 @@ export const PLUGINS = [ description: "equity research slash commands backed by advis0r.com", commands: ["/ticker", "/signals", "/research", "/lookup", "/reports", "/discover"], }, + { + name: "crypto", + description: "crypto market data slash commands backed by advis0r.com", + commands: ["/crypto", "/quote", "/book", "/bars", "/spark", "/pairs", "/coin"], + }, ]; export const DEFAULT_PLUGIN = PLUGINS[0].name; diff --git a/src/tui.mjs b/src/tui.mjs index 26b8dad..a95d37a 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -21,6 +21,7 @@ import { runScript } from "./runtime.mjs"; import { moshVocabulary } from "./commands.mjs"; import { mcpCommand, pluginCommand, skillCommand } from "./integrations.mjs"; import { tickerCommand } from "./advisor.mjs"; +import { cryptoCommand } from "./crypto.mjs"; import { canOpenBrowser, openBrowser } from "./open-url.mjs"; import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion } from "./ui.mjs"; import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs"; @@ -678,6 +679,12 @@ export async function tui() { await tickerCommand(rest, { openUrl: (url) => canOpenBrowser() && openBrowser(url) }); continue; } + // `/crypto` renders in the pit for the same reason `/ticker` does: there is + // no crypto binary to hand the terminal to, only a public read-only API. + if (cmd === "crypto" || cmd === "coins") { + await cryptoCommand(rest, { openUrl: (url) => canOpenBrowser() && openBrowser(url) }); + continue; + } if (cmd === "plugin" || cmd === "plugins") { await pluginCommand(rest); continue; diff --git a/test/crypto.test.mjs b/test/crypto.test.mjs new file mode 100644 index 0000000..a1d4753 --- /dev/null +++ b/test/crypto.test.mjs @@ -0,0 +1,288 @@ +// `moshcode crypto` — argument translation, request building, and the things +// this command must never get wrong: rendering a sub-cent coin as "$0.00", +// promising a cap it does not apply, and dropping the API's disclaimer. +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CRYPTO_VERB_NAMES, MAX_SYMBOLS, cryptoArgs, cryptoCommand, cryptoUrl, cryptoUsage, + fetchCrypto, normalizeSymbol, price, renderCrypto, resolveVerb, sparkline, +} from "../src/crypto.mjs"; +import { CRYPTO_VERBS } from "../src/cli-schema.mjs"; + +// --- the bare-pair shortcut -------------------------------------------------- + +test("a bare pair is the report, in every spelling the API accepts", () => { + const expected = { + verb: "report", symbol: "BTC-USD", path: "/api/crypto/report", + query: { symbol: "BTC-USD" }, json: false, + }; + assert.deepEqual(cryptoArgs(["btc-usd"]), expected); + assert.deepEqual(cryptoArgs(["BTC/USD"]), expected); + assert.deepEqual(cryptoArgs(["report", "btc/usd"]), expected); +}); + +test("a bare asset stays bare — the API resolves it, this does not guess", () => { + // Appending -USD here would break the day a base has no USD pair. + assert.deepEqual(cryptoArgs(["btc"]), { + verb: "report", symbol: "BTC", path: "/api/crypto/report", + query: { symbol: "BTC" }, json: false, + }); +}); + +test("no arguments prints usage rather than guessing a pair", () => { + assert.deepEqual(cryptoArgs([]), { usage: true }); + assert.match(cryptoUsage(), /usage: moshcode crypto/); +}); + +test("an asset name is refused with the lookup that resolves it", () => { + const result = cryptoArgs(["bitcoin"]); + assert.match(result.error, /is not a crypto pair/); + assert.match(result.error, /moshcode crypto lookup bitcoin/); +}); + +// --- verbs ------------------------------------------------------------------- + +test("every verb the schema documents is one the parser resolves", () => { + // The schema drives help and completion; the parser drives behaviour. A verb + // in one and not the other is a command that completes and then fails. + assert.deepEqual(CRYPTO_VERBS.map(({ name }) => name).sort(), [...CRYPTO_VERB_NAMES].sort()); + for (const { name } of CRYPTO_VERBS) assert.equal(resolveVerb(name), name, `${name} does not resolve`); +}); + +test("aliases resolve, and anything else is treated as a pair", () => { + assert.equal(resolveVerb("candles"), "bars"); + assert.equal(resolveVerb("orderbook"), "book"); + assert.equal(resolveVerb("price"), "quote"); + assert.equal(resolveVerb("search"), "lookup"); + assert.equal(resolveVerb("BTC"), null); +}); + +test("multi-pair verbs take a list, and stop at the documented cap", () => { + assert.deepEqual(cryptoArgs(["snapshot", "btc", "eth-usd"]), { + verb: "snapshot", symbols: ["BTC", "ETH-USD"], path: "/api/crypto/snapshot", + query: { symbols: "BTC,ETH-USD" }, json: false, + }); + assert.deepEqual(cryptoArgs(["spark", "btc", "--period", "7d"]), { + verb: "spark", symbols: ["BTC"], path: "/api/crypto/sparklines", + query: { symbols: "BTC", period: "7d" }, json: false, + }); + const tooMany = cryptoArgs(["snapshot", ...Array.from({ length: MAX_SYMBOLS + 1 }, (_, i) => `AA${i}`)]); + assert.match(tooMany.error, new RegExp(`at most ${MAX_SYMBOLS} pairs`)); +}); + +test("a bad pair anywhere in a list is named, not silently dropped", () => { + const result = cryptoArgs(["snapshot", "btc", "dogecoin-to-the-moon"]); + assert.match(result.error, /"dogecoin-to-the-moon" is not a crypto pair/); +}); + +test("bars defaults to daily and carries its window", () => { + assert.deepEqual(cryptoArgs(["bars", "eth", "--timeframe", "1hour", "--start", "2026-01-01"]), { + verb: "bars", symbol: "ETH", path: "/api/crypto/bars", + query: { symbol: "ETH", timeframe: "1Hour", start: "2026-01-01" }, limit: null, json: false, + }); + assert.equal(cryptoArgs(["bars", "eth"]).query.timeframe, "1Day"); +}); + +test("lookup takes words, not pairs", () => { + assert.deepEqual(cryptoArgs(["lookup", "basic", "attention", "--limit", "3"]), { + verb: "lookup", path: "/api/crypto/lookup", query: { q: "basic attention", limit: "3" }, json: false, + }); +}); + +// --- flags ------------------------------------------------------------------- + +test("flag values are validated before a request is ever built", () => { + assert.match(cryptoArgs(["bars", "btc", "--timeframe", "1Fortnight"]).error, /--timeframe must be one of/); + assert.match(cryptoArgs(["spark", "btc", "--period", "1y"]).error, /--period must be one of/); + assert.match(cryptoArgs(["technicals", "btc", "--horizon", "9"]).error, /--horizon must be 1 or 2/); + assert.match(cryptoArgs(["book", "btc", "--depth", "0"]).error, /--depth requires a positive number/); + assert.match(cryptoArgs(["bars", "btc", "--limit"]).error, /--limit requires a positive number/); + assert.match(cryptoArgs(["btc", "--nope"]).error, /unknown crypto flag/); +}); + +test("--json is a flag, not a pair", () => { + assert.equal(cryptoArgs(["btc", "--json"]).json, true); + assert.equal(cryptoArgs(["--json", "btc"]).symbol, "BTC"); +}); + +// --- symbols ----------------------------------------------------------------- + +test("normalizeSymbol keeps pairs and refuses prose", () => { + assert.equal(normalizeSymbol("btc/usd"), "BTC-USD"); + assert.equal(normalizeSymbol(" eth-usdt "), "ETH-USDT"); + assert.equal(normalizeSymbol("BTCUSD"), "BTCUSD"); + assert.equal(normalizeSymbol("bitcoin"), null); + assert.equal(normalizeSymbol(""), null); + assert.equal(normalizeSymbol("a"), null); +}); + +// --- URLs -------------------------------------------------------------------- + +test("query values are encoded, and open builds the shareable page", () => { + const url = cryptoUrl(cryptoArgs(["lookup", "basic attention"]), { base: "https://example.test" }); + assert.equal(url, "https://example.test/api/crypto/lookup?q=basic+attention"); + assert.deepEqual(cryptoArgs(["open", "btc/usd"]), { + verb: "open", symbol: "BTC-USD", open: "/crypto/BTC-USD", json: false, + }); + assert.equal( + cryptoUrl(cryptoArgs(["open", "btc/usd"]), { base: "https://example.test" }), + "https://example.test/crypto/BTC-USD", + ); +}); + +// --- rendering --------------------------------------------------------------- + +test("prices are formatted at the precision the pair trades at", () => { + // A fixed two decimals renders half the index as "$0.00". + assert.match(price(65082.1), /^\$65,082\.10$/); + assert.match(price(91.458), /^\$91\.4580$/); + assert.match(price(0.06897), /^\$0\.06897$/); + assert.match(price(0.00000469), /^\$0\.00000469$/); + assert.equal(price(null), "—"); +}); + +test("a BTC-quoted pair is not priced in dollars", () => { + assert.equal(price(0.0295, "BTC"), "0.02950 BTC"); + assert.equal(price(0.0295, "USDC"), "$0.02950", "a dollar stablecoin still reads as dollars"); +}); + +test("a derived number is priced like the number beside it", () => { + // $126.10 next to $65,021.84, not $126.0980. + assert.equal(price(126.098, "USD", { like: 65021.84 }), "$126.10"); +}); + +test("a flat series does not draw as a crash", () => { + assert.equal(sparkline([5, 5, 5]), "▄▄▄"); + assert.equal(sparkline([]), ""); + assert.equal(sparkline([1, 2, 3]).length, 3); +}); + +test("the report renders the live stamp, the score and the disclaimer", () => { + const out = renderCrypto("report", { + symbol: "BTC/USD", slug: "BTC-USD", name: "Bitcoin", base: "BTC", quote: "USD", + snapshot: { + latestTrade: { price: 65082.1 }, + latestQuote: { bidPrice: 65017.605, bidSize: 0.7766, askPrice: 65064.367, askSize: 0.78609 }, + dailyBar: { open: 64891.7, high: 65156.1, low: 64758.2, close: 65084.2, volume: 0.28 }, + delayed: false, feed: "us", change: { absolute: 186.363, percent: 0.2872 }, + }, + technical: { rsi14: 55.268, sma: { 20: 64409.08 }, trend: "neutral", deathCross: true }, + technicalScore: { score: 34.33, horizonQuarters: 2 }, + fundamentals: { marketCap: 1305297819040, marketCapRank: 1, source: "coingecko" }, + caveats: ["Volume reflects Alpaca's US crypto venue alone."], + generatedAt: "2026-08-08T16:05:27.385Z", + disclaimer: "Research aid, not advice.", + }, { columns: 88 }); + + assert.match(out, /BTC\/USD/); + assert.match(out, /\$65,082\.10/); + assert.match(out, /\+0\.29%/); + assert.match(out, /technical score 34\.3/); + assert.match(out, /death cross/); + assert.match(out, /cap 1\.31T/); + assert.match(out, /fetched 2026-08-08 16:05Z/, "a live read must say when it was read"); + assert.match(out, /Volume reflects Alpaca/, "the response's caveats qualify the numbers above them"); + assert.match(out, /Research aid, not advice\./); +}); + +test("--limit on bars is honoured here, because upstream does not honour it", () => { + // The API treats limit as a page size over its own window, so a renderer that + // just printed everything would silently break the flag's promise. + const bars = Array.from({ length: 17 }, (_, i) => ({ + timestamp: `2026-08-08T${String(i).padStart(2, "0")}:00:00Z`, + open: 1900 + i, high: 1910 + i, low: 1890 + i, close: 1905 + i, volume: 1, + })); + const out = renderCrypto("bars", { timeframe: "1Hour", bars: { "ETH/USD": bars } }, { columns: 88, limit: 5 }); + assert.match(out, /5 × 1Hour · newest of 17/, "trimming must be stated, not silent"); + assert.match(out, /2026-08-08 16:00Z/, "the newest bar is kept"); + assert.doesNotMatch(out, /2026-08-08 05:00Z/, "older bars are the ones dropped"); +}); + +test("an empty payload reads as empty rather than throwing", () => { + assert.match(renderCrypto("assets", {}), /no pairs are listed/); + assert.match(renderCrypto("lookup", { query: "zzz", matches: [] }), /no crypto pair matches/); + assert.match(renderCrypto("quote", { quotes: [] }), /no quote came back/); + assert.match(renderCrypto("bars", { bars: {} }), /no bars came back/); + assert.match(renderCrypto("book", { orderbooks: [] }), /no order book came back/); + assert.match(renderCrypto("spark", { series: {} }), /no series came back/); +}); + +// --- the command, end to end ------------------------------------------------- + +const jsonResponse = (data, { ok = true, status = 200 } = {}) => async () => ({ + ok, status, text: async () => JSON.stringify(data), +}); + +test("--json prints the API response untouched", async () => { + const lines = []; + const code = await cryptoCommand(["assets", "--json"], { + out: (s) => lines.push(s), + fail: (s) => lines.push(s), + fetchImpl: jsonResponse({ count: 1, assets: [{ symbol: "BTC/USD", slug: "BTC-USD", quote: "USD" }] }), + base: "https://example.test", + }); + assert.equal(code, 0); + assert.deepEqual(JSON.parse(lines.join("\n")).assets[0].slug, "BTC-USD"); +}); + +test("an unsupported pair surfaces the API's own lookup suggestion", async () => { + const errors = []; + const code = await cryptoCommand(["ZZZZ"], { + out: () => {}, + fail: (s) => errors.push(s), + fetchImpl: jsonResponse( + { error: '"ZZZZ" is not a supported crypto pair', lookup: "/crypto/lookup?q=ZZZZ" }, + { ok: false, status: 400 }, + ), + base: "https://example.test", + }); + assert.equal(code, 1); + assert.match(errors.join("\n"), /is not a supported crypto pair/); + assert.match(errors.join("\n"), /moshcode crypto lookup ZZZZ/); +}); + +test("a bad argument fails before any request is made", async () => { + let called = false; + const code = await cryptoCommand(["bars", "btc", "--timeframe", "1Century"], { + out: () => {}, + fail: () => {}, + fetchImpl: async () => { called = true; throw new Error("should not fetch"); }, + }); + assert.equal(code, 1); + assert.equal(called, false); +}); + +test("open does not hit the network, and falls back to printing the URL", async () => { + const lines = []; + const code = await cryptoCommand(["open", "btc"], { + out: (s) => lines.push(s), + fail: (s) => lines.push(s), + fetchImpl: async () => { throw new Error("should not fetch"); }, + base: "https://example.test", + openUrl: () => false, + }); + assert.equal(code, 0); + assert.match(lines.join("\n"), /https:\/\/example\.test\/crypto\/BTC/); +}); + +test("a non-JSON body is reported as one, not parsed into nothing", async () => { + const res = await fetchCrypto(cryptoArgs(["assets"]), { + fetchImpl: async () => ({ ok: false, status: 502, text: async () => "bad gateway" }), + base: "https://example.test", + }); + assert.equal(res.ok, false); + assert.match(res.error, /returned 502 and not JSON/); +}); + +test("a wedged connection times out instead of hanging forever", async () => { + const res = await fetchCrypto(cryptoArgs(["assets"]), { + fetchImpl: (url, { signal }) => new Promise((_, reject) => { + signal.addEventListener("abort", () => reject(Object.assign(new Error("aborted"), { name: "AbortError" }))); + }), + base: "https://example.test", + timeoutMs: 10, + }); + assert.equal(res.ok, false); + assert.match(res.error, /timed out after/); +}); diff --git a/test/plugins.test.mjs b/test/plugins.test.mjs index d6d12fa..ebc7aed 100644 --- a/test/plugins.test.mjs +++ b/test/plugins.test.mjs @@ -121,20 +121,29 @@ test("every plugin the marketplace lists exists, with a manifest and its command test("every shipped command declares a description and parseable frontmatter", () => { // Unparseable frontmatter loads the command with empty metadata — no - // description, no allowed-tools — and nothing at runtime says so. - const dir = new URL("../plugins/ticker/commands/", import.meta.url); - for (const file of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) { - const text = fs.readFileSync(new URL(file, dir), "utf8"); - const match = text.match(/^---\n([\s\S]*?)\n---\n/); - assert.ok(match, `${file} has no frontmatter block`); - assert.match(match[1], /^description: \S/m, `${file} has no description`); - // A value opening with `[` is a YAML flow sequence, and `[--limit n]` in one - // is a parse error that silently drops every field in the block. - for (const line of match[1].split("\n")) { - const value = line.match(/^[a-z-]+: (.*)$/)?.[1]; - if (value?.startsWith("[")) assert.fail(`${file}: unquoted "[" in frontmatter — ${line}`); + // description, no allowed-tools — and nothing at runtime says so. Driven from + // the manifest rather than one hard-coded directory, so a plugin added later + // cannot ship unchecked. + const manifest = JSON.parse(fs.readFileSync(new URL("../.claude-plugin/marketplace.json", import.meta.url), "utf8")); + let checked = 0; + for (const entry of manifest.plugins) { + const dir = new URL(`../${String(entry.source).replace(/^\.\//, "")}/commands/`, import.meta.url); + for (const file of fs.readdirSync(dir).filter((f) => f.endsWith(".md"))) { + const id = `${entry.name}/${file}`; + const text = fs.readFileSync(new URL(file, dir), "utf8"); + const match = text.match(/^---\n([\s\S]*?)\n---\n/); + assert.ok(match, `${id} has no frontmatter block`); + assert.match(match[1], /^description: \S/m, `${id} has no description`); + // A value opening with `[` is a YAML flow sequence, and `[--limit n]` in one + // is a parse error that silently drops every field in the block. + for (const line of match[1].split("\n")) { + const value = line.match(/^[a-z-]+: (.*)$/)?.[1]; + if (value?.startsWith("[")) assert.fail(`${id}: unquoted "[" in frontmatter — ${line}`); + } + checked++; } } + assert.ok(checked >= 6, "found no commands to check"); }); test("pluginId is the form the engine disambiguates with", () => {