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", () => {