moshcode 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +12 -0
- package/README.md +56 -15
- package/bin/moshcode.mjs +11 -3
- package/package.json +1 -1
- package/plugins/crypto/.claude-plugin/plugin.json +13 -0
- package/plugins/crypto/README.md +66 -0
- package/plugins/crypto/commands/bars.md +35 -0
- package/plugins/crypto/commands/book.md +31 -0
- package/plugins/crypto/commands/coin.md +31 -0
- package/plugins/crypto/commands/crypto.md +50 -0
- package/plugins/crypto/commands/pairs.md +31 -0
- package/plugins/crypto/commands/quote.md +31 -0
- package/plugins/crypto/commands/spark.md +36 -0
- package/plugins/ticker/README.md +8 -2
- package/plugins/ticker/commands/discover.md +2 -2
- package/plugins/ticker/commands/lookup.md +3 -3
- package/plugins/ticker/commands/reports.md +3 -3
- package/plugins/ticker/commands/research.md +2 -2
- package/plugins/ticker/commands/signals.md +2 -2
- package/plugins/ticker/commands/{ticker.md → stocks.md} +2 -2
- package/src/advisor.mjs +45 -35
- package/src/cli-schema.mjs +93 -24
- package/src/crypto.mjs +877 -0
- package/src/help.mjs +14 -0
- package/src/plugins.mjs +6 -1
- package/src/tui.mjs +18 -6
package/src/crypto.mjs
ADDED
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
// `moshcode crypto` — crypto market data from advis0r.com, in the pit.
|
|
2
|
+
//
|
|
3
|
+
// The same split as src/advisor.mjs, for the same reasons: argument translation
|
|
4
|
+
// is pure and testable, the network call is injectable, and rendering is a
|
|
5
|
+
// function of the decoded JSON. Every route is public and read-only, so there
|
|
6
|
+
// is no login verb and no write verb — `moshcode trade` is where orders live.
|
|
7
|
+
//
|
|
8
|
+
// This is a *sibling* of `stocks`, not a mode of it, because the two answer
|
|
9
|
+
// different questions from different data. A stocks report is a stored snapshot
|
|
10
|
+
// built from transcripts, SEC fundamentals and extracted signals. A crypto
|
|
11
|
+
// report is a live read of Alpaca's US crypto venue: no transcripts, no
|
|
12
|
+
// filings, no signals, and a `fetchedAt` measured in seconds rather than days.
|
|
13
|
+
// Rendering them through one code path would mean one set of labels lying about
|
|
14
|
+
// one of them.
|
|
15
|
+
import { advisorBase } from "./advisor.mjs";
|
|
16
|
+
import { acid, ash, amber, bone, danger, dim } from "./ui.mjs";
|
|
17
|
+
|
|
18
|
+
const USAGE = `usage: moshcode crypto <pair|verb> [args…]
|
|
19
|
+
|
|
20
|
+
<pair> the full report for one pair (BTC, BTC-USD, BTC/USD)
|
|
21
|
+
report <pair> same thing, when a pair looks like a verb
|
|
22
|
+
quote <pair> latest trade and quote, with the bid/ask spread
|
|
23
|
+
snapshot <pair…> trade, quote and daily bars for up to 20 pairs
|
|
24
|
+
technicals <pair> SMA/EMA/RSI/MACD/Bollinger/ATR + technical score
|
|
25
|
+
bars <pair> historical OHLCV
|
|
26
|
+
book <pair> top of the order book, both sides
|
|
27
|
+
spark <pair…> recent closes, drawn as sparklines
|
|
28
|
+
assets every supported pair
|
|
29
|
+
lookup <name…> find a pair by asset name (bitcoin → BTC/USD)
|
|
30
|
+
open <pair> open the shareable page in a browser
|
|
31
|
+
|
|
32
|
+
--json print the raw API response
|
|
33
|
+
--timeframe <tf> bars: 1Min | 5Min | 15Min | 1Hour | 1Day | 1Week
|
|
34
|
+
--start <iso> / --end <iso> bars: the window to cover
|
|
35
|
+
--limit <n> cap results (bars/lookup)
|
|
36
|
+
--depth <n> book: levels per side (default 10)
|
|
37
|
+
--period <p> spark: 24h | 7d
|
|
38
|
+
--horizon <1|2> technicals: quarters the score looks ahead
|
|
39
|
+
|
|
40
|
+
Research aid, not advice. Crypto trades 24/7 with no circuit breakers, and
|
|
41
|
+
these prices are Alpaca's US venue alone — they can differ materially from
|
|
42
|
+
other exchanges.`;
|
|
43
|
+
|
|
44
|
+
export function cryptoUsage() {
|
|
45
|
+
return USAGE;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Verb names, in help order. cli-schema's CRYPTO_VERBS must match (drift test). */
|
|
49
|
+
export const CRYPTO_VERB_NAMES = [
|
|
50
|
+
"report", "quote", "snapshot", "technicals", "bars", "book", "spark", "assets", "lookup", "open",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
// The same reasoning as stocks's alias table: `/crypto price BTC` and
|
|
54
|
+
// `/crypto candles BTC` should not be errors when the intent is obvious.
|
|
55
|
+
// `search` maps to lookup rather than erroring — crypto has no transcript
|
|
56
|
+
// index to search, and a directory lookup is what the word means here.
|
|
57
|
+
const VERB_ALIASES = {
|
|
58
|
+
detail: "report", pair: "report", info: "report",
|
|
59
|
+
price: "quote", last: "quote", latest: "quote",
|
|
60
|
+
snap: "snapshot", snapshots: "snapshot",
|
|
61
|
+
technical: "technicals", ta: "technicals", indicators: "technicals",
|
|
62
|
+
ohlc: "bars", ohlcv: "bars", candles: "bars", history: "bars",
|
|
63
|
+
orderbook: "book", depth: "book", l2: "book",
|
|
64
|
+
sparkline: "spark", sparklines: "spark", chart: "spark", trend: "spark",
|
|
65
|
+
pairs: "assets", markets: "assets", symbols: "assets", list: "assets",
|
|
66
|
+
find: "lookup", search: "lookup", name: "lookup", coin: "lookup",
|
|
67
|
+
browse: "open", www: "open", web: "open",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Resolve a first argument to a canonical verb, or null when it is a pair. */
|
|
71
|
+
export function resolveVerb(word) {
|
|
72
|
+
const key = String(word ?? "").toLowerCase();
|
|
73
|
+
if (CRYPTO_VERB_NAMES.includes(key)) return key;
|
|
74
|
+
return VERB_ALIASES[key] ?? null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A crypto pair in the URL-safe form the API documents for paths, or null.
|
|
79
|
+
*
|
|
80
|
+
* The API accepts four spellings (BTC/USD, BTC-USD, BTC, BTCUSD); everything
|
|
81
|
+
* here is normalized to the dashed one so a request built from `BTC/USD` and
|
|
82
|
+
* one built from `btc-usd` are the same request. A bare asset is left bare —
|
|
83
|
+
* the API resolves it to that asset's USD pair, and inventing the `-USD` here
|
|
84
|
+
* would silently break the day a base has no USD pair.
|
|
85
|
+
*
|
|
86
|
+
* Deliberately narrow, like stocks's: the whole job of the check is to tell
|
|
87
|
+
* `BTC` from `bitcoin` and send the second one to lookup with a useful message
|
|
88
|
+
* instead of a 400. Bases run to five characters (SUSHI, MATIC, TRUMP), so six
|
|
89
|
+
* leaves room for the concatenated `BTCUSD` spelling without swallowing words.
|
|
90
|
+
*/
|
|
91
|
+
export function normalizeSymbol(input) {
|
|
92
|
+
const raw = String(input ?? "").trim().toUpperCase().replace(/\//g, "-");
|
|
93
|
+
if (/^[A-Z0-9]{2,6}$/.test(raw)) return raw;
|
|
94
|
+
return /^[A-Z0-9]{2,6}-[A-Z0-9]{2,5}$/.test(raw) ? raw : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function takeFlag(args, name, { boolean = false } = {}) {
|
|
98
|
+
const out = { value: null, rest: [], missing: false, present: false };
|
|
99
|
+
for (let i = 0; i < args.length; i++) {
|
|
100
|
+
const arg = String(args[i]);
|
|
101
|
+
if (arg === name) {
|
|
102
|
+
out.present = true;
|
|
103
|
+
if (boolean) continue;
|
|
104
|
+
const next = args[i + 1];
|
|
105
|
+
if (next == null || String(next).startsWith("-")) out.missing = true;
|
|
106
|
+
else { out.value = String(next); i++; }
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!boolean && arg.startsWith(`${name}=`)) {
|
|
110
|
+
out.present = true;
|
|
111
|
+
const value = arg.slice(name.length + 1);
|
|
112
|
+
if (value === "") out.missing = true; else out.value = value;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
out.rest.push(arg);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function positiveInt(value, { max }) {
|
|
121
|
+
const n = Number(value);
|
|
122
|
+
if (!Number.isInteger(n) || n < 1) return null;
|
|
123
|
+
return Math.min(n, max);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const TIMEFRAMES = ["1Min", "5Min", "15Min", "1Hour", "1Day", "1Week"];
|
|
127
|
+
const PERIODS = ["24h", "7d"];
|
|
128
|
+
|
|
129
|
+
/** The documented cap on multi-symbol routes. Exceeding it is a 400, not a truncation. */
|
|
130
|
+
export const MAX_SYMBOLS = 20;
|
|
131
|
+
|
|
132
|
+
function canonicalTimeframe(value) {
|
|
133
|
+
const key = String(value).toLowerCase();
|
|
134
|
+
return TIMEFRAMES.find((tf) => tf.toLowerCase() === key) ?? null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Normalize a list of pair arguments, reporting the first one that is not a pair. */
|
|
138
|
+
function symbolList(words, verb) {
|
|
139
|
+
if (!words.length) return { error: `crypto ${verb} requires at least one pair` };
|
|
140
|
+
const symbols = [];
|
|
141
|
+
for (const word of words) {
|
|
142
|
+
const symbol = normalizeSymbol(word);
|
|
143
|
+
if (!symbol) {
|
|
144
|
+
return { error: `${JSON.stringify(String(word))} is not a crypto pair — try: moshcode crypto lookup ${String(word)}` };
|
|
145
|
+
}
|
|
146
|
+
symbols.push(symbol);
|
|
147
|
+
}
|
|
148
|
+
if (symbols.length > MAX_SYMBOLS) {
|
|
149
|
+
return { error: `crypto ${verb} accepts at most ${MAX_SYMBOLS} pairs (got ${symbols.length})` };
|
|
150
|
+
}
|
|
151
|
+
return { symbols };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Translate `crypto` arguments into a request the caller can execute.
|
|
156
|
+
*
|
|
157
|
+
* Returns one of `{ usage }`, `{ error }`, or
|
|
158
|
+
* `{ verb, path, query, json, open? }` — never performs IO, so the whole
|
|
159
|
+
* argument surface is testable without a network.
|
|
160
|
+
*/
|
|
161
|
+
export function cryptoArgs(input = []) {
|
|
162
|
+
const args = input.map(String);
|
|
163
|
+
const jsonFlag = takeFlag(args, "--json", { boolean: true });
|
|
164
|
+
let rest = jsonFlag.rest;
|
|
165
|
+
const json = jsonFlag.present;
|
|
166
|
+
|
|
167
|
+
const limitFlag = takeFlag(rest, "--limit"); rest = limitFlag.rest;
|
|
168
|
+
const timeframeFlag = takeFlag(rest, "--timeframe"); rest = timeframeFlag.rest;
|
|
169
|
+
const startFlag = takeFlag(rest, "--start"); rest = startFlag.rest;
|
|
170
|
+
const endFlag = takeFlag(rest, "--end"); rest = endFlag.rest;
|
|
171
|
+
const depthFlag = takeFlag(rest, "--depth"); rest = depthFlag.rest;
|
|
172
|
+
const periodFlag = takeFlag(rest, "--period"); rest = periodFlag.rest;
|
|
173
|
+
const horizonFlag = takeFlag(rest, "--horizon"); rest = horizonFlag.rest;
|
|
174
|
+
|
|
175
|
+
if (limitFlag.missing) return { error: "crypto --limit requires a positive number" };
|
|
176
|
+
if (timeframeFlag.missing) return { error: `crypto --timeframe requires one of ${TIMEFRAMES.join(", ")}` };
|
|
177
|
+
if (startFlag.missing) return { error: "crypto --start requires a date or timestamp" };
|
|
178
|
+
if (endFlag.missing) return { error: "crypto --end requires a date or timestamp" };
|
|
179
|
+
if (depthFlag.missing) return { error: "crypto --depth requires a positive number" };
|
|
180
|
+
if (periodFlag.missing) return { error: `crypto --period requires one of ${PERIODS.join(", ")}` };
|
|
181
|
+
if (horizonFlag.missing) return { error: "crypto --horizon requires 1 or 2" };
|
|
182
|
+
|
|
183
|
+
const limit = limitFlag.value == null ? null : positiveInt(limitFlag.value, { max: 1000 });
|
|
184
|
+
if (limitFlag.value != null && limit == null) {
|
|
185
|
+
return { error: "crypto --limit requires a positive number" };
|
|
186
|
+
}
|
|
187
|
+
const depth = depthFlag.value == null ? null : positiveInt(depthFlag.value, { max: 50 });
|
|
188
|
+
if (depthFlag.value != null && depth == null) {
|
|
189
|
+
return { error: "crypto --depth requires a positive number" };
|
|
190
|
+
}
|
|
191
|
+
const timeframe = timeframeFlag.value == null ? null : canonicalTimeframe(timeframeFlag.value);
|
|
192
|
+
if (timeframeFlag.value != null && timeframe == null) {
|
|
193
|
+
return { error: `crypto --timeframe must be one of ${TIMEFRAMES.join(", ")}` };
|
|
194
|
+
}
|
|
195
|
+
const period = periodFlag.value == null ? null : String(periodFlag.value).toLowerCase();
|
|
196
|
+
if (period != null && !PERIODS.includes(period)) {
|
|
197
|
+
return { error: `crypto --period must be one of ${PERIODS.join(", ")}` };
|
|
198
|
+
}
|
|
199
|
+
if (horizonFlag.value != null && !["1", "2"].includes(String(horizonFlag.value))) {
|
|
200
|
+
return { error: "crypto --horizon must be 1 or 2" };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const stray = rest.find((arg) => arg.startsWith("-") && arg !== "-");
|
|
204
|
+
if (stray) return { error: `unknown crypto flag ${JSON.stringify(stray)}` };
|
|
205
|
+
|
|
206
|
+
const [first, ...tail] = rest;
|
|
207
|
+
if (!first) return { usage: true };
|
|
208
|
+
|
|
209
|
+
const verb = resolveVerb(first);
|
|
210
|
+
const words = verb ? tail : rest;
|
|
211
|
+
|
|
212
|
+
// No verb → the first word is the pair. `/crypto BTC` is the headline case
|
|
213
|
+
// and must stay the shortest thing anyone types.
|
|
214
|
+
const single = { report: "report", quote: "quote", technicals: "technicals", bars: "bars", book: "book", open: "open" };
|
|
215
|
+
const wanted = verb == null ? "report" : verb;
|
|
216
|
+
|
|
217
|
+
if (single[wanted]) {
|
|
218
|
+
const raw = words[0];
|
|
219
|
+
if (!raw) return { error: `crypto ${wanted} requires a pair` };
|
|
220
|
+
const symbol = normalizeSymbol(raw);
|
|
221
|
+
if (!symbol) {
|
|
222
|
+
return { error: `${JSON.stringify(String(raw))} is not a crypto pair — try: moshcode crypto lookup ${String(raw)}` };
|
|
223
|
+
}
|
|
224
|
+
if (wanted === "open") {
|
|
225
|
+
return { verb: "open", symbol, open: `/crypto/${encodeURIComponent(symbol)}`, json };
|
|
226
|
+
}
|
|
227
|
+
if (wanted === "report") return { verb: "report", symbol, path: "/api/crypto/report", query: { symbol }, json };
|
|
228
|
+
if (wanted === "quote") return { verb: "quote", symbol, path: "/api/crypto/quote", query: { symbol }, json };
|
|
229
|
+
if (wanted === "technicals") {
|
|
230
|
+
return {
|
|
231
|
+
verb: "technicals", symbol, path: "/api/crypto/technicals",
|
|
232
|
+
query: { symbol, ...(horizonFlag.value ? { horizon: String(horizonFlag.value) } : {}) }, json,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (wanted === "bars") {
|
|
236
|
+
return {
|
|
237
|
+
verb: "bars", symbol, path: "/api/crypto/bars",
|
|
238
|
+
query: {
|
|
239
|
+
symbol,
|
|
240
|
+
timeframe: timeframe || "1Day",
|
|
241
|
+
...(startFlag.value ? { start: startFlag.value } : {}),
|
|
242
|
+
...(endFlag.value ? { end: endFlag.value } : {}),
|
|
243
|
+
...(limit ? { limit: String(limit) } : {}),
|
|
244
|
+
},
|
|
245
|
+
// Upstream treats `limit` as a page size over its own window, not a cap
|
|
246
|
+
// on what comes back — `--limit 5` can return seventeen bars. The flag
|
|
247
|
+
// is carried through here so the renderer can honour what it promised,
|
|
248
|
+
// and say out loud that it trimmed.
|
|
249
|
+
limit,
|
|
250
|
+
json,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
verb: "book", symbol, path: "/api/crypto/orderbook",
|
|
255
|
+
query: { symbol, ...(depth ? { depth: String(depth) } : {}) }, json,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (verb === "snapshot" || verb === "spark") {
|
|
260
|
+
const list = symbolList(words, verb);
|
|
261
|
+
if (list.error) return { error: list.error };
|
|
262
|
+
const symbols = list.symbols;
|
|
263
|
+
if (verb === "snapshot") {
|
|
264
|
+
return { verb, symbols, path: "/api/crypto/snapshot", query: { symbols: symbols.join(",") }, json };
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
verb, symbols, path: "/api/crypto/sparklines",
|
|
268
|
+
query: { symbols: symbols.join(","), period: period || "24h" }, json,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (verb === "lookup") {
|
|
273
|
+
const q = words.join(" ").trim();
|
|
274
|
+
if (!q) return { error: "crypto lookup requires something to look for" };
|
|
275
|
+
return { verb, path: "/api/crypto/lookup", query: { q, ...(limit ? { limit: String(limit) } : {}) }, json };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (verb === "assets") return { verb, path: "/api/crypto/assets", query: {}, json };
|
|
279
|
+
|
|
280
|
+
return { error: `unknown crypto command ${JSON.stringify(String(first))}` };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Build the absolute URL for a translated request. */
|
|
284
|
+
export function cryptoUrl(request, { base = advisorBase() } = {}) {
|
|
285
|
+
const url = new URL((request.path || request.open || "/"), `${base}/`);
|
|
286
|
+
for (const [k, v] of Object.entries(request.query || {})) {
|
|
287
|
+
if (v != null && v !== "") url.searchParams.set(k, String(v));
|
|
288
|
+
}
|
|
289
|
+
return url.toString();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Execute a translated request. `fetchImpl` is injectable for tests.
|
|
294
|
+
*
|
|
295
|
+
* Every crypto route is a live venue read, so one timeout fits all of them —
|
|
296
|
+
* unlike stocks, which has to budget separately for `discover`'s per-candidate
|
|
297
|
+
* analysis.
|
|
298
|
+
*/
|
|
299
|
+
export async function fetchCrypto(request, { fetchImpl = globalThis.fetch, base = advisorBase(), timeoutMs = 45_000 } = {}) {
|
|
300
|
+
const url = cryptoUrl(request, { base });
|
|
301
|
+
const controller = new AbortController();
|
|
302
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
303
|
+
try {
|
|
304
|
+
const res = await fetchImpl(url, {
|
|
305
|
+
signal: controller.signal,
|
|
306
|
+
headers: { accept: "application/json", "user-agent": "moshcode-crypto" },
|
|
307
|
+
});
|
|
308
|
+
const text = await res.text();
|
|
309
|
+
let data;
|
|
310
|
+
try { data = JSON.parse(text); } catch { data = null; }
|
|
311
|
+
if (data == null) {
|
|
312
|
+
return { ok: false, status: res.status, url, error: `advis0r returned ${res.status} and not JSON` };
|
|
313
|
+
}
|
|
314
|
+
return { ok: res.ok, status: res.status, url, data };
|
|
315
|
+
} catch (e) {
|
|
316
|
+
const reason = e?.name === "AbortError" ? `timed out after ${Math.round(timeoutMs / 1000)}s` : (e?.message || String(e));
|
|
317
|
+
return { ok: false, status: 0, url, error: `advis0r request failed: ${reason}` };
|
|
318
|
+
} finally {
|
|
319
|
+
clearTimeout(timer);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---------------------------------------------------------------- rendering
|
|
324
|
+
|
|
325
|
+
/** Quote assets that are dollars, or a claim to be one. */
|
|
326
|
+
const FIAT = new Set(["USD", "USDC", "USDT"]);
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Format a price at a precision the pair actually trades at.
|
|
330
|
+
*
|
|
331
|
+
* Crypto spans nine orders of magnitude on one venue — BTC near $65,000 and
|
|
332
|
+
* SHIB near $0.000006. A fixed two decimals renders half the index as "$0.00",
|
|
333
|
+
* so the decimals follow the magnitude.
|
|
334
|
+
*/
|
|
335
|
+
export function price(value, quote = "USD", { like } = {}) {
|
|
336
|
+
const n = Number(value);
|
|
337
|
+
if (value == null || !Number.isFinite(n)) return "—";
|
|
338
|
+
// `like` prices a derived number at the precision of the number it sits next
|
|
339
|
+
// to: a $126 move on a $65,000 coin belongs at two decimals, the same as the
|
|
340
|
+
// price above it, not at the four its own magnitude would earn.
|
|
341
|
+
const reference = Number(like);
|
|
342
|
+
const abs = Math.abs(Number.isFinite(reference) ? reference : n);
|
|
343
|
+
const digits = abs >= 1000 ? 2 : abs >= 1 ? 4 : abs >= 0.01 ? 5 : abs >= 0.0001 ? 6 : 8;
|
|
344
|
+
const text = n.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
|
345
|
+
return FIAT.has(String(quote).toUpperCase()) ? `$${text}` : `${text} ${String(quote).toUpperCase()}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const num = (v, digits = 2) =>
|
|
349
|
+
v == null || !Number.isFinite(Number(v)) ? null : Number(v).toFixed(digits).replace(/\.00$/, "");
|
|
350
|
+
|
|
351
|
+
/** A signed percentage, because "0.29%" and "-0.29%" must never look alike. */
|
|
352
|
+
function pct(value, digits = 2) {
|
|
353
|
+
const n = Number(value);
|
|
354
|
+
if (value == null || !Number.isFinite(n)) return "—";
|
|
355
|
+
return `${n >= 0 ? "+" : ""}${n.toFixed(digits)}%`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function compact(v) {
|
|
359
|
+
const n = Number(v);
|
|
360
|
+
if (!Number.isFinite(n)) return null;
|
|
361
|
+
const units = [[1e12, "T"], [1e9, "B"], [1e6, "M"], [1e3, "K"]];
|
|
362
|
+
for (let i = 0; i < units.length; i++) {
|
|
363
|
+
const [size, suffix] = units[i];
|
|
364
|
+
if (Math.abs(n) < size) continue;
|
|
365
|
+
// Same carry as advisor's: rounding can push a value up to a full thousand
|
|
366
|
+
// of this unit (999,999,999 → "1000M"); carry it to the next unit instead.
|
|
367
|
+
const scaled = (n / size).toFixed(2);
|
|
368
|
+
if (Math.abs(Number(scaled)) >= 1000 && i > 0) {
|
|
369
|
+
const [upSize, upSuffix] = units[i - 1];
|
|
370
|
+
return `${(n / upSize).toFixed(2).replace(/\.?0+$/, "")}${upSuffix}`;
|
|
371
|
+
}
|
|
372
|
+
return `${scaled.replace(/\.?0+$/, "")}${suffix}`;
|
|
373
|
+
}
|
|
374
|
+
// Below 1K a raw count is more honest than "0.94K".
|
|
375
|
+
return Number(n.toFixed(2)).toLocaleString("en-US");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** A timestamp trimmed to the minute — seconds and nanoseconds are noise here. */
|
|
379
|
+
function stamp(v) {
|
|
380
|
+
if (!v) return "—";
|
|
381
|
+
const s = String(v);
|
|
382
|
+
return s.length >= 16 ? `${s.slice(0, 16).replace("T", " ")}Z` : s;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const day = (v) => (v ? String(v).slice(0, 10) : "—");
|
|
386
|
+
|
|
387
|
+
function clip(text, width) {
|
|
388
|
+
const s = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
389
|
+
return s.length <= width ? s : `${s.slice(0, Math.max(1, width - 1))}…`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function wrapText(text, width) {
|
|
393
|
+
const words = String(text).replace(/\s+/g, " ").trim().split(" ");
|
|
394
|
+
const lines = [];
|
|
395
|
+
let line = "";
|
|
396
|
+
for (const word of words) {
|
|
397
|
+
if (line && line.length + word.length + 1 > width) { lines.push(line); line = word; }
|
|
398
|
+
else line = line ? `${line} ${word}` : word;
|
|
399
|
+
}
|
|
400
|
+
if (line) lines.push(line);
|
|
401
|
+
return lines;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Up is acid, down is danger, flat is ash. */
|
|
405
|
+
function changeTone(value) {
|
|
406
|
+
const n = Number(value);
|
|
407
|
+
if (!Number.isFinite(n) || n === 0) return ash;
|
|
408
|
+
return n > 0 ? acid : danger;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function scoreTone(score) {
|
|
412
|
+
const n = Number(score);
|
|
413
|
+
if (!Number.isFinite(n)) return ash;
|
|
414
|
+
if (n >= 60) return acid;
|
|
415
|
+
if (n >= 40) return amber;
|
|
416
|
+
return danger;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const SPARK_TICKS = "▁▂▃▄▅▆▇█";
|
|
420
|
+
|
|
421
|
+
/** Render a close series as one line of block characters. */
|
|
422
|
+
export function sparkline(points) {
|
|
423
|
+
const values = (Array.isArray(points) ? points : []).map(Number).filter(Number.isFinite);
|
|
424
|
+
if (!values.length) return "";
|
|
425
|
+
const min = Math.min(...values);
|
|
426
|
+
const max = Math.max(...values);
|
|
427
|
+
// A flat series has no range to scale into; drawing it at the floor would
|
|
428
|
+
// imply a crash, so it sits mid-band instead.
|
|
429
|
+
if (max === min) return SPARK_TICKS[3].repeat(values.length);
|
|
430
|
+
return values
|
|
431
|
+
.map((v) => SPARK_TICKS[Math.min(SPARK_TICKS.length - 1, Math.floor(((v - min) / (max - min)) * SPARK_TICKS.length))])
|
|
432
|
+
.join("");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The API ships a disclaimer with every substantive response. Printing it is
|
|
437
|
+
* not decoration — this renders scored market analysis in a terminal next to a
|
|
438
|
+
* broker CLI that can place orders, for an asset class with no circuit breakers.
|
|
439
|
+
*/
|
|
440
|
+
function disclaimerLines(d, width) {
|
|
441
|
+
const text = d?.disclaimer;
|
|
442
|
+
if (!text) return [];
|
|
443
|
+
return wrapText(text, width - 4).map((line) => ` ${dim(line)}`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Caveats are per-response and specific — the score's liquidity component is
|
|
448
|
+
* venue-local, the 200-day window counts calendar days on a 24/7 market. They
|
|
449
|
+
* qualify the numbers directly above them, so they print with them.
|
|
450
|
+
*/
|
|
451
|
+
function caveatLines(d, width) {
|
|
452
|
+
const caveats = Array.isArray(d?.caveats) ? d.caveats : [];
|
|
453
|
+
if (!caveats.length) return [];
|
|
454
|
+
const lines = ["", ` ${ash("caveats")}`];
|
|
455
|
+
for (const caveat of caveats) {
|
|
456
|
+
const wrapped = wrapText(caveat, width - 8);
|
|
457
|
+
lines.push(` ${amber("•")} ${dim(wrapped[0] ?? "")}`);
|
|
458
|
+
for (const line of wrapped.slice(1)) lines.push(` ${dim(line)}`);
|
|
459
|
+
}
|
|
460
|
+
return lines;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* A left-hand label in the report's column, padded to one width.
|
|
465
|
+
*
|
|
466
|
+
* Hand-counted spaces after each label drift the moment a label is renamed —
|
|
467
|
+
* `all-time high` is exactly the column width, so it would butt straight up
|
|
468
|
+
* against its own value.
|
|
469
|
+
*/
|
|
470
|
+
const LABEL_WIDTH = 14;
|
|
471
|
+
const label = (text) => ash(String(text).padEnd(LABEL_WIDTH));
|
|
472
|
+
|
|
473
|
+
/** `BTC/USD Bitcoin` — the identity line every renderer starts from. */
|
|
474
|
+
function pairHeading(d) {
|
|
475
|
+
const symbol = String(d?.symbol ?? "");
|
|
476
|
+
const name = d?.name && d.name !== symbol ? ` ${bone(d.name)}` : "";
|
|
477
|
+
return ` ${acid(symbol)}${name}`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function quoteAsset(d) {
|
|
481
|
+
return String(d?.quote || String(d?.symbol ?? "").split("/")[1] || "USD").toUpperCase();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** The price + change line, shared by report and quote. */
|
|
485
|
+
function priceLine(snapshot, quote) {
|
|
486
|
+
const last = snapshot?.latestTrade?.price ?? snapshot?.mid ?? snapshot?.dailyBar?.close;
|
|
487
|
+
const change = snapshot?.change;
|
|
488
|
+
const bits = [bone(price(last, quote))];
|
|
489
|
+
if (change) {
|
|
490
|
+
const paint = changeTone(change.percent);
|
|
491
|
+
bits.push(paint(`${change.absolute >= 0 ? "+" : ""}${price(change.absolute, quote, { like: last })}`), paint(`(${pct(change.percent)})`));
|
|
492
|
+
}
|
|
493
|
+
const feed = [
|
|
494
|
+
snapshot?.delayed === false ? "live" : snapshot?.delayed === true ? "delayed" : null,
|
|
495
|
+
snapshot?.feed ? `${snapshot.feed} venue` : null,
|
|
496
|
+
"24/7",
|
|
497
|
+
].filter(Boolean).join(" · ");
|
|
498
|
+
return ` ${bits.join(" ")} ${ash(feed)}`;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function bookLine(latestQuote, quote, extra = {}) {
|
|
502
|
+
if (!latestQuote) return null;
|
|
503
|
+
const spreadBps = extra.spreadBps ?? spreadBpsOf(latestQuote);
|
|
504
|
+
const parts = [
|
|
505
|
+
`bid ${price(latestQuote.bidPrice, quote)} × ${num(latestQuote.bidSize, 4) ?? "—"}`,
|
|
506
|
+
`ask ${price(latestQuote.askPrice, quote)} × ${num(latestQuote.askSize, 4) ?? "—"}`,
|
|
507
|
+
spreadBps == null ? null : `spread ${num(spreadBps, 2)}bps`,
|
|
508
|
+
].filter(Boolean);
|
|
509
|
+
return ` ${label("book")}${parts.join(ash(" · "))}`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function spreadBpsOf(latestQuote) {
|
|
513
|
+
const bid = Number(latestQuote?.bidPrice);
|
|
514
|
+
const ask = Number(latestQuote?.askPrice);
|
|
515
|
+
if (!Number.isFinite(bid) || !Number.isFinite(ask) || bid + ask === 0) return null;
|
|
516
|
+
return ((ask - bid) / ((ask + bid) / 2)) * 10_000;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function technicalLines(t, quote) {
|
|
520
|
+
if (!t) return [];
|
|
521
|
+
const lines = [];
|
|
522
|
+
const indicators = [
|
|
523
|
+
t.rsi14 == null ? null : `rsi14 ${num(t.rsi14, 1)}`,
|
|
524
|
+
t.sma?.[20] == null ? null : `sma20 ${price(t.sma[20], quote)}`,
|
|
525
|
+
t.sma?.[50] == null ? null : `sma50 ${price(t.sma[50], quote)}`,
|
|
526
|
+
t.sma?.[200] == null ? null : `sma200 ${price(t.sma[200], quote)}`,
|
|
527
|
+
t.atr14 == null ? null : `atr ${price(t.atr14, quote)}`,
|
|
528
|
+
t.relativeVolume == null ? null : `rvol ${num(t.relativeVolume, 2)}`,
|
|
529
|
+
].filter(Boolean);
|
|
530
|
+
if (indicators.length) lines.push(` ${label("technical")}${indicators.join(ash(" · "))}`);
|
|
531
|
+
|
|
532
|
+
const regime = [
|
|
533
|
+
t.trend ? `trend ${t.trend}` : null,
|
|
534
|
+
t.volatilityRegime ? `volatility ${t.volatilityRegime}` : null,
|
|
535
|
+
t.goldenCross ? "golden cross" : null,
|
|
536
|
+
t.deathCross ? "death cross" : null,
|
|
537
|
+
t.breakout ? "breakout" : null,
|
|
538
|
+
t.breakdown ? "breakdown" : null,
|
|
539
|
+
].filter(Boolean);
|
|
540
|
+
if (regime.length) lines.push(` ${label("regime")}${regime.map((r) => bone(r)).join(ash(" · "))}`);
|
|
541
|
+
|
|
542
|
+
const momentum = [
|
|
543
|
+
t.momentum?.[20] == null ? null : `20d ${pct(t.momentum[20], 1)}`,
|
|
544
|
+
t.momentum?.[60] == null ? null : `60d ${pct(t.momentum[60], 1)}`,
|
|
545
|
+
t.momentum?.[120] == null ? null : `120d ${pct(t.momentum[120], 1)}`,
|
|
546
|
+
t.distanceFrom52WeekHigh == null ? null : `from 52w high ${pct(t.distanceFrom52WeekHigh, 1)}`,
|
|
547
|
+
].filter(Boolean);
|
|
548
|
+
if (momentum.length) lines.push(` ${label("momentum")}${momentum.join(ash(" · "))}`);
|
|
549
|
+
return lines;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** CoinGecko supply/market-cap facts. Absent for most pairs, and that is fine. */
|
|
553
|
+
function fundamentalLines(f, quote) {
|
|
554
|
+
if (!f || f.source === "unavailable") return [];
|
|
555
|
+
const parts = [
|
|
556
|
+
f.marketCap == null ? null : `cap ${compact(f.marketCap)}`,
|
|
557
|
+
f.marketCapRank == null ? null : `rank #${f.marketCapRank}`,
|
|
558
|
+
f.volume24h == null ? null : `vol24h ${compact(f.volume24h)}`,
|
|
559
|
+
f.circulatingSupply == null ? null : `circ ${compact(f.circulatingSupply)}${f.maxSupply ? `/${compact(f.maxSupply)}` : ""}`,
|
|
560
|
+
].filter(Boolean);
|
|
561
|
+
const lines = [];
|
|
562
|
+
if (parts.length) lines.push(` ${label("market")}${parts.join(ash(" · "))}`);
|
|
563
|
+
if (f.ath != null) {
|
|
564
|
+
lines.push(` ${label("all-time high")}${bone(price(f.ath, quote))} ${ash(day(f.athDate))} ${changeTone(f.athChangePercent)(pct(f.athChangePercent, 1))}`);
|
|
565
|
+
}
|
|
566
|
+
if (lines.length && f.source) lines.push(` ${ash(`supply data: ${f.source}`)}`);
|
|
567
|
+
return lines;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function renderReport(d, { width }) {
|
|
571
|
+
const quote = quoteAsset(d);
|
|
572
|
+
const snapshot = d.snapshot || {};
|
|
573
|
+
const lines = ["", pairHeading(d), priceLine(snapshot, quote), ""];
|
|
574
|
+
|
|
575
|
+
const score = d.technicalScore;
|
|
576
|
+
if (score?.score != null) {
|
|
577
|
+
const paint = scoreTone(score.score);
|
|
578
|
+
const bits = [
|
|
579
|
+
`${paint(`technical score ${num(score.score, 1)}`)}${ash("/100")}`,
|
|
580
|
+
score.horizonQuarters ? ash(`${score.horizonQuarters}q horizon`) : null,
|
|
581
|
+
].filter(Boolean);
|
|
582
|
+
lines.push(` ${bits.join(ash(" "))}`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
lines.push(...technicalLines(d.technical, quote));
|
|
586
|
+
const book = bookLine(snapshot.latestQuote, quote);
|
|
587
|
+
if (book) lines.push(book);
|
|
588
|
+
|
|
589
|
+
const bar = snapshot.dailyBar;
|
|
590
|
+
if (bar) {
|
|
591
|
+
const parts = [
|
|
592
|
+
`o ${price(bar.open, quote)}`, `h ${price(bar.high, quote)}`,
|
|
593
|
+
`l ${price(bar.low, quote)}`, `c ${price(bar.close, quote)}`,
|
|
594
|
+
bar.vwap == null ? null : `vwap ${price(bar.vwap, quote)}`,
|
|
595
|
+
bar.volume == null ? null : `vol ${compact(bar.volume)} ${d.base ?? ""}`.trim(),
|
|
596
|
+
].filter(Boolean);
|
|
597
|
+
lines.push(` ${label("day")}${parts.join(ash(" · "))}`);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
lines.push(...fundamentalLines(d.fundamentals, quote));
|
|
601
|
+
|
|
602
|
+
lines.push("", ` ${label("page")}${acid(`${advisorBase()}/crypto/${d.slug ?? String(d.symbol ?? "").replace("/", "-")}`)}`);
|
|
603
|
+
const fetchedAt = d.generatedAt || snapshot.fetchedAt;
|
|
604
|
+
if (fetchedAt) lines.push(` ${ash(`fetched ${stamp(fetchedAt)}`)}`);
|
|
605
|
+
lines.push(...caveatLines(d, width));
|
|
606
|
+
lines.push("", ...disclaimerLines(d, width));
|
|
607
|
+
return lines.join("\n");
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function renderQuote(d, { width }) {
|
|
611
|
+
const quotes = Array.isArray(d.quotes) ? d.quotes : [];
|
|
612
|
+
if (!quotes.length) return ` ${ash("no quote came back for that pair")}`;
|
|
613
|
+
const lines = [""];
|
|
614
|
+
for (const q of quotes) {
|
|
615
|
+
const quote = quoteAsset(q);
|
|
616
|
+
lines.push(pairHeading(q));
|
|
617
|
+
const trade = q.latestTrade;
|
|
618
|
+
if (trade) {
|
|
619
|
+
lines.push(` ${bone(price(trade.price, quote))} ${ash(`last trade ${num(trade.size, 6) ?? "—"} @ ${stamp(trade.timestamp)}`)}`);
|
|
620
|
+
}
|
|
621
|
+
const book = bookLine(q.latestQuote, quote, { spreadBps: q.spreadBps });
|
|
622
|
+
if (book) lines.push(book);
|
|
623
|
+
if (q.mid != null) lines.push(` ${label("mid")}${bone(price(q.mid, quote))}`);
|
|
624
|
+
lines.push("");
|
|
625
|
+
}
|
|
626
|
+
if (d.fetchedAt) lines.push(` ${ash(`fetched ${stamp(d.fetchedAt)} · ${d.feed ?? "us"} venue · 24/7`)}`, "");
|
|
627
|
+
lines.push(...disclaimerLines(d, width));
|
|
628
|
+
return lines.join("\n");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function renderSnapshot(d, { width }) {
|
|
632
|
+
const snapshots = Array.isArray(d.snapshots) ? d.snapshots : [];
|
|
633
|
+
if (!snapshots.length) return ` ${ash("no snapshots came back")}`;
|
|
634
|
+
const lines = ["", ` ${ash(`${snapshots.length} ${snapshots.length === 1 ? "pair" : "pairs"}`)}`, ""];
|
|
635
|
+
for (const s of snapshots) {
|
|
636
|
+
const quote = quoteAsset(s);
|
|
637
|
+
const change = s.change?.percent;
|
|
638
|
+
lines.push(
|
|
639
|
+
` ${acid(String(s.symbol).padEnd(11))}` +
|
|
640
|
+
`${bone(price(s.latestTrade?.price ?? s.dailyBar?.close, quote).padStart(16))} ` +
|
|
641
|
+
`${changeTone(change)(pct(change).padStart(8))} ` +
|
|
642
|
+
`${ash(`h ${price(s.dailyBar?.high, quote)} · l ${price(s.dailyBar?.low, quote)}`)} ` +
|
|
643
|
+
`${ash(clip(s.name ?? "", 20))}`,
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
const fetchedAt = snapshots.find((s) => s.fetchedAt)?.fetchedAt;
|
|
647
|
+
if (fetchedAt) lines.push("", ` ${ash(`fetched ${stamp(fetchedAt)} · 24/7`)}`);
|
|
648
|
+
if (Array.isArray(d.rejected) && d.rejected.length) {
|
|
649
|
+
lines.push(` ${amber(`not supported: ${d.rejected.join(", ")}`)}`);
|
|
650
|
+
}
|
|
651
|
+
lines.push("", ...disclaimerLines(d, width));
|
|
652
|
+
return lines.join("\n");
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function renderTechnicals(d, { width }) {
|
|
656
|
+
const t = d.indicators;
|
|
657
|
+
if (!t) return ` ${ash("no indicators came back for that pair")}`;
|
|
658
|
+
const quote = quoteAsset({ symbol: d.symbol });
|
|
659
|
+
const lines = ["", ` ${acid(String(d.symbol ?? ""))} ${ash(`${d.bars ?? "?"} bars · as of ${stamp(t.asOf)}`)}`, ""];
|
|
660
|
+
|
|
661
|
+
const score = d.score;
|
|
662
|
+
if (score?.score != null) {
|
|
663
|
+
lines.push(` ${scoreTone(score.score)(`technical score ${num(score.score, 1)}`)}${ash("/100")}${score.horizonQuarters ? ash(` ${score.horizonQuarters}q horizon`) : ""}`);
|
|
664
|
+
const breakdown = Object.entries(score.breakdown || {});
|
|
665
|
+
if (breakdown.length) {
|
|
666
|
+
for (const [key, value] of breakdown) {
|
|
667
|
+
lines.push(` ${ash(String(key).padEnd(16))}${bone(String(num(value, 2) ?? "—").padStart(6))}`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
lines.push("");
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
lines.push(...technicalLines(t, quote));
|
|
674
|
+
if (t.lastClose != null) lines.push(` ${label("last close")}${bone(price(t.lastClose, quote))}`);
|
|
675
|
+
const macd = t.macd;
|
|
676
|
+
if (macd) {
|
|
677
|
+
lines.push(` ${label("macd")}${[`macd ${num(macd.macd, 2)}`, `signal ${num(macd.signal, 2)}`, `hist ${num(macd.histogram, 2)}`].join(ash(" · "))}`);
|
|
678
|
+
}
|
|
679
|
+
const bb = t.bollinger;
|
|
680
|
+
if (bb) {
|
|
681
|
+
lines.push(` ${label("bollinger")}${[`upper ${price(bb.upper, quote)}`, `mid ${price(bb.middle, quote)}`, `lower ${price(bb.lower, quote)}`].join(ash(" · "))}`);
|
|
682
|
+
}
|
|
683
|
+
const volume = [
|
|
684
|
+
t.avgDailyVolume == null ? null : `avg daily ${compact(t.avgDailyVolume)}`,
|
|
685
|
+
t.avgDollarVolume == null ? null : `avg $ volume ${compact(t.avgDollarVolume)}`,
|
|
686
|
+
t.vwap == null ? null : `vwap ${price(t.vwap, quote)}`,
|
|
687
|
+
].filter(Boolean);
|
|
688
|
+
if (volume.length) lines.push(` ${label("volume")}${volume.join(ash(" · "))}`);
|
|
689
|
+
|
|
690
|
+
lines.push(...caveatLines(d, width));
|
|
691
|
+
lines.push("", ...disclaimerLines(d, width));
|
|
692
|
+
return lines.join("\n");
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function renderBars(d, { width, limit }) {
|
|
696
|
+
const groups = Object.entries(d.bars || {});
|
|
697
|
+
if (!groups.length) return ` ${ash("no bars came back for that window")}`;
|
|
698
|
+
const lines = [];
|
|
699
|
+
for (const [symbol, bars] of groups) {
|
|
700
|
+
const quote = quoteAsset({ symbol });
|
|
701
|
+
const all = Array.isArray(bars) ? bars : [];
|
|
702
|
+
// The most recent bars are the ones worth keeping when trimming.
|
|
703
|
+
const rows = limit && all.length > limit ? all.slice(-limit) : all;
|
|
704
|
+
const trimmed = all.length - rows.length;
|
|
705
|
+
const heading = `${rows.length} × ${d.timeframe ?? "1Day"}${trimmed > 0 ? ` · newest of ${all.length}` : ""}`;
|
|
706
|
+
lines.push("", ` ${acid(symbol)} ${ash(heading)}`, "");
|
|
707
|
+
if (!rows.length) { lines.push(` ${ash("no bars in this window")}`); continue; }
|
|
708
|
+
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))}`);
|
|
709
|
+
for (const bar of rows) {
|
|
710
|
+
// Intraday timeframes need the clock; daily and weekly do not.
|
|
711
|
+
const when = /Min|Hour/.test(String(d.timeframe ?? "")) ? stamp(bar.timestamp) : day(bar.timestamp);
|
|
712
|
+
const up = Number(bar.close) >= Number(bar.open);
|
|
713
|
+
lines.push(
|
|
714
|
+
` ${ash(String(when).padEnd(17))}` +
|
|
715
|
+
`${bone(price(bar.open, quote).padStart(14))}` +
|
|
716
|
+
`${bone(price(bar.high, quote).padStart(14))}` +
|
|
717
|
+
`${bone(price(bar.low, quote).padStart(14))}` +
|
|
718
|
+
`${(up ? acid : danger)(price(bar.close, quote).padStart(14))}` +
|
|
719
|
+
`${ash(String(compact(bar.volume) ?? "—").padStart(12))}`,
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
const closes = rows.map((b) => Number(b.close)).filter(Number.isFinite);
|
|
723
|
+
if (closes.length > 1) {
|
|
724
|
+
const move = ((closes[closes.length - 1] - closes[0]) / closes[0]) * 100;
|
|
725
|
+
lines.push("", ` ${ash("window")} ${changeTone(move)(pct(move))} ${dim(sparkline(closes))}`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
lines.push("", ...disclaimerLines(d, width));
|
|
729
|
+
return lines.join("\n");
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function renderBook(d, { width }) {
|
|
733
|
+
const books = Array.isArray(d.orderbooks) ? d.orderbooks : [];
|
|
734
|
+
if (!books.length) return ` ${ash("no order book came back for that pair")}`;
|
|
735
|
+
const lines = [];
|
|
736
|
+
for (const book of books) {
|
|
737
|
+
const quote = quoteAsset(book);
|
|
738
|
+
const bids = Array.isArray(book.bids) ? book.bids : [];
|
|
739
|
+
const asks = Array.isArray(book.asks) ? book.asks : [];
|
|
740
|
+
lines.push("", pairHeading(book), ` ${ash(stamp(book.timestamp))}`, "");
|
|
741
|
+
lines.push(` ${acid("bid".padEnd(16))}${ash("size".padStart(12))} ${danger("ask".padEnd(16))}${ash("size".padStart(12))}`);
|
|
742
|
+
for (let i = 0; i < Math.max(bids.length, asks.length); i++) {
|
|
743
|
+
const bid = bids[i];
|
|
744
|
+
const ask = asks[i];
|
|
745
|
+
lines.push(
|
|
746
|
+
` ${acid((bid ? price(bid.price, quote) : "").padEnd(16))}${ash((bid ? String(num(bid.size, 6) ?? "") : "").padStart(12))} ` +
|
|
747
|
+
`${danger((ask ? price(ask.price, quote) : "").padEnd(16))}${ash((ask ? String(num(ask.size, 6) ?? "") : "").padStart(12))}`,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
const spreadBps = spreadBpsOf({ bidPrice: bids[0]?.price, askPrice: asks[0]?.price });
|
|
751
|
+
if (spreadBps != null) {
|
|
752
|
+
lines.push("", ` ${ash("spread")} ${bone(`${num(spreadBps, 2)}bps`)} ${ash(`${price((asks[0].price + bids[0].price) / 2, quote)} mid`)}`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
lines.push("", ...disclaimerLines(d, width));
|
|
756
|
+
return lines.join("\n");
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function renderSpark(d, { width }) {
|
|
760
|
+
const series = Object.entries(d.series || {});
|
|
761
|
+
if (!series.length) return ` ${ash("no series came back")}`;
|
|
762
|
+
const lines = ["", ` ${ash(`last ${d.period ?? "24h"}`)}`, ""];
|
|
763
|
+
for (const [symbol, s] of series) {
|
|
764
|
+
const quote = quoteAsset({ symbol });
|
|
765
|
+
const paint = changeTone(s.changePercent);
|
|
766
|
+
lines.push(
|
|
767
|
+
` ${acid(String(symbol).padEnd(11))}${paint(sparkline(s.points))} ` +
|
|
768
|
+
`${bone(price(s.last, quote).padStart(14))} ${paint(pct(s.changePercent).padStart(8))}`,
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
const first = series[0]?.[1];
|
|
772
|
+
if (first?.start) lines.push("", ` ${ash(`${stamp(first.start)} → ${stamp(first.end)}`)}`);
|
|
773
|
+
lines.push("", ...disclaimerLines(d, width));
|
|
774
|
+
return lines.join("\n");
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function renderAssets(d) {
|
|
778
|
+
const assets = Array.isArray(d.assets) ? d.assets : [];
|
|
779
|
+
if (!assets.length) return ` ${ash("no pairs are listed")}`;
|
|
780
|
+
const byQuote = new Map();
|
|
781
|
+
for (const asset of assets) {
|
|
782
|
+
const key = String(asset.quote ?? "?").toUpperCase();
|
|
783
|
+
if (!byQuote.has(key)) byQuote.set(key, []);
|
|
784
|
+
byQuote.get(key).push(asset);
|
|
785
|
+
}
|
|
786
|
+
const lines = ["", ` ${ash(`${d.count ?? assets.length} pairs${d.liveness ? ` · liveness ${d.liveness}` : ""}`)}`];
|
|
787
|
+
for (const [quote, rows] of [...byQuote].sort((a, b) => b[1].length - a[1].length)) {
|
|
788
|
+
lines.push("", ` ${bone(`quoted in ${quote}`)} ${ash(`(${rows.length})`)}`);
|
|
789
|
+
// `idle` is the API's own word for a listed pair with no recent prints;
|
|
790
|
+
// it stays visible rather than being filtered out, because "missing" and
|
|
791
|
+
// "listed but not trading" are different answers to "can I trade this".
|
|
792
|
+
const cells = rows.map((r) => {
|
|
793
|
+
const paint = r.status === "live" ? acid : ash;
|
|
794
|
+
return `${paint(String(r.slug ?? r.symbol).padEnd(11))}${ash(clip(r.name, 14).padEnd(15))}`;
|
|
795
|
+
});
|
|
796
|
+
for (let i = 0; i < cells.length; i += 3) lines.push(` ${cells.slice(i, i + 3).join(" ")}`);
|
|
797
|
+
}
|
|
798
|
+
lines.push("", ` ${ash("then:")} ${bone(`moshcode crypto ${assets[0].slug ?? assets[0].symbol}`)}`);
|
|
799
|
+
return lines.join("\n");
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function renderLookup(d) {
|
|
803
|
+
const matches = Array.isArray(d.matches) ? d.matches : [];
|
|
804
|
+
if (!matches.length) return ` ${ash(`no crypto pair matches ${JSON.stringify(String(d.query ?? ""))}`)}`;
|
|
805
|
+
const lines = ["", ` ${ash("matches for")} ${bone(String(d.query ?? ""))}`, ""];
|
|
806
|
+
for (const m of matches) {
|
|
807
|
+
lines.push(` ${acid(String(m.slug ?? m.symbol).padEnd(12))}${bone(clip(m.name, 28).padEnd(30))}${ash(`${m.base ?? ""}/${m.quote ?? ""}`)}`);
|
|
808
|
+
}
|
|
809
|
+
lines.push("", ` ${ash("then:")} ${bone(`moshcode crypto ${matches[0].slug ?? matches[0].symbol}`)}`);
|
|
810
|
+
return lines.join("\n");
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** Render a decoded API response for one verb. */
|
|
814
|
+
export function renderCrypto(verb, data, { columns, limit } = {}) {
|
|
815
|
+
const width = Math.max(48, Math.min(Number(columns) || 88, 100));
|
|
816
|
+
switch (verb) {
|
|
817
|
+
case "report": return renderReport(data, { width });
|
|
818
|
+
case "quote": return renderQuote(data, { width });
|
|
819
|
+
case "snapshot": return renderSnapshot(data, { width });
|
|
820
|
+
case "technicals": return renderTechnicals(data, { width });
|
|
821
|
+
case "bars": return renderBars(data, { width, limit });
|
|
822
|
+
case "book": return renderBook(data, { width });
|
|
823
|
+
case "spark": return renderSpark(data, { width });
|
|
824
|
+
case "assets": return renderAssets(data);
|
|
825
|
+
case "lookup": return renderLookup(data);
|
|
826
|
+
default: return JSON.stringify(data, null, 2);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Run a `crypto` invocation end to end. Returns a process exit code.
|
|
832
|
+
*
|
|
833
|
+
* `deps` exists so tests drive the whole command — parse, fetch, render — with
|
|
834
|
+
* no network and no stdout.
|
|
835
|
+
*/
|
|
836
|
+
export async function cryptoCommand(argv = [], deps = {}) {
|
|
837
|
+
const {
|
|
838
|
+
out = (s) => console.log(s),
|
|
839
|
+
fail = (s) => console.error(s),
|
|
840
|
+
fetchImpl,
|
|
841
|
+
base = advisorBase(),
|
|
842
|
+
openUrl,
|
|
843
|
+
columns = process.stdout.columns,
|
|
844
|
+
} = deps;
|
|
845
|
+
|
|
846
|
+
const request = cryptoArgs(argv);
|
|
847
|
+
if (request.usage) { out(cryptoUsage()); return 0; }
|
|
848
|
+
if (request.error) { fail(danger(`✗ ${request.error}`)); return 1; }
|
|
849
|
+
|
|
850
|
+
if (request.verb === "open") {
|
|
851
|
+
const url = cryptoUrl(request, { base });
|
|
852
|
+
if (request.json) { out(JSON.stringify({ url }, null, 2)); return 0; }
|
|
853
|
+
const opened = openUrl ? openUrl(url) : false;
|
|
854
|
+
out(opened ? `${acid("✓ ")}opened ${bone(url)}` : `${ash("· ")}open this in a browser:\n ${acid(url)}`);
|
|
855
|
+
return 0;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const res = await fetchCrypto(request, { fetchImpl, base });
|
|
859
|
+
if (res.error) { fail(danger(`✗ ${res.error}`)); return 1; }
|
|
860
|
+
|
|
861
|
+
// The API's own error bodies are more useful than any message invented here:
|
|
862
|
+
// an unsupported pair comes back naming the lookup that would have resolved it.
|
|
863
|
+
if (!res.ok) {
|
|
864
|
+
const message = res.data?.error || `advis0r returned ${res.status}`;
|
|
865
|
+
if (request.json) { out(JSON.stringify(res.data, null, 2)); return 1; }
|
|
866
|
+
fail(danger(`✗ ${message}`));
|
|
867
|
+
if (res.data?.lookup) {
|
|
868
|
+
const q = String(res.data.lookup).split("q=")[1];
|
|
869
|
+
if (q) fail(` ${ash("try:")} ${bone(`moshcode crypto lookup ${decodeURIComponent(q)}`)}`);
|
|
870
|
+
}
|
|
871
|
+
return 1;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (request.json) { out(JSON.stringify(res.data, null, 2)); return 0; }
|
|
875
|
+
out(renderCrypto(request.verb, res.data, { columns, limit: request.limit }));
|
|
876
|
+
return 0;
|
|
877
|
+
}
|