moshcode 0.25.0 → 0.26.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 +23 -0
- package/README.md +75 -2
- package/bin/moshcode.mjs +15 -1
- package/package.json +3 -1
- package/plugins/ticker/.claude-plugin/plugin.json +13 -0
- package/plugins/ticker/README.md +49 -0
- package/plugins/ticker/commands/discover.md +37 -0
- package/plugins/ticker/commands/lookup.md +27 -0
- package/plugins/ticker/commands/reports.md +30 -0
- package/plugins/ticker/commands/research.md +29 -0
- package/plugins/ticker/commands/signals.md +30 -0
- package/plugins/ticker/commands/ticker.md +42 -0
- package/prd/0008-ticker-research-and-plugin-marketplace.md +130 -0
- package/prd/README.md +1 -0
- package/src/advisor.mjs +588 -0
- package/src/cli-schema.mjs +97 -0
- package/src/engines.mjs +13 -10
- package/src/integrations.mjs +76 -2
- package/src/plugins.mjs +121 -0
- package/src/socials.mjs +72 -0
- package/src/tui.mjs +38 -1
package/src/advisor.mjs
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
// `moshcode ticker` — equity research from advis0r.com, in the pit.
|
|
2
|
+
//
|
|
3
|
+
// Same split as src/trade.mjs: argument translation is pure and testable, the
|
|
4
|
+
// network call is injectable, and rendering is a function of the decoded JSON.
|
|
5
|
+
// Nothing here holds credentials — every route this touches is public and
|
|
6
|
+
// read-only, which is why there is no login verb and no write verb.
|
|
7
|
+
//
|
|
8
|
+
// The API is documented at https://advis0r.com/api and returns *stored*
|
|
9
|
+
// snapshots: a report carries `reportGeneratedAt`, and every renderer prints it.
|
|
10
|
+
// A stale price is fine; a stale price dressed up as a live one is not.
|
|
11
|
+
import { acid, ash, amber, bone, danger, dim } from "./ui.mjs";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_ADVISOR_URL = "https://advis0r.com";
|
|
14
|
+
|
|
15
|
+
/** The advis0r base URL, overridable for a local instance or a test server. */
|
|
16
|
+
export function advisorBase(env = process.env) {
|
|
17
|
+
const raw = String(env.MOSHCODE_ADVISOR_URL || DEFAULT_ADVISOR_URL).trim();
|
|
18
|
+
return (raw || DEFAULT_ADVISOR_URL).replace(/\/+$/, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const USAGE = `usage: moshcode ticker <symbol|verb> [args…]
|
|
22
|
+
|
|
23
|
+
<symbol> the stored research report for one ticker
|
|
24
|
+
report <symbol> same thing, when a symbol looks like a verb
|
|
25
|
+
signals <symbol> every extracted signal for a ticker
|
|
26
|
+
search <words…> full-text search across indexed transcripts
|
|
27
|
+
lookup <company…> find a ticker by company name (rivian → RIVN)
|
|
28
|
+
reports every stored report, best score first
|
|
29
|
+
discover [topic…] a ranked watchlist for a topic
|
|
30
|
+
tickers every ticker present in the index
|
|
31
|
+
stats index coverage counts
|
|
32
|
+
open <symbol> open the shareable report page in a browser
|
|
33
|
+
|
|
34
|
+
--json print the raw API response
|
|
35
|
+
--limit <n> cap results (search/lookup/reports/discover)
|
|
36
|
+
--sort <recent|score|ticker> order reports (default: score)
|
|
37
|
+
--horizon <1|2> discover: quarters to look ahead (default: 2)
|
|
38
|
+
--provider <name> discover: analysis provider (default: offline)
|
|
39
|
+
|
|
40
|
+
Research aid, not advice. Every route is public, read-only, and served from
|
|
41
|
+
stored snapshots — see the generated-at stamp printed with each report.`;
|
|
42
|
+
|
|
43
|
+
export function tickerUsage() {
|
|
44
|
+
return USAGE;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Verb names, in help order. cli-schema's TICKER_VERBS must match (drift test). */
|
|
48
|
+
export const TICKER_VERB_NAMES = [
|
|
49
|
+
"report", "signals", "search", "lookup", "reports", "discover", "tickers", "stats", "open",
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
// Aliases exist because muscle memory differs: `/ticker news AAPL` and
|
|
53
|
+
// `/ticker quotes AAPL` should not be errors when the intent is obvious.
|
|
54
|
+
const VERB_ALIASES = {
|
|
55
|
+
signal: "signals", news: "signals",
|
|
56
|
+
find: "search", grep: "search", q: "search",
|
|
57
|
+
symbol: "lookup", company: "lookup", name: "lookup",
|
|
58
|
+
index: "reports", list: "reports",
|
|
59
|
+
watchlist: "discover", rank: "discover",
|
|
60
|
+
symbols: "tickers",
|
|
61
|
+
coverage: "stats", status: "stats",
|
|
62
|
+
browse: "open", www: "open", web: "open",
|
|
63
|
+
detail: "report", quote: "report", snapshot: "report",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Resolve a first argument to a canonical verb, or null when it is a symbol. */
|
|
67
|
+
export function resolveVerb(word) {
|
|
68
|
+
const key = String(word ?? "").toLowerCase();
|
|
69
|
+
if (TICKER_VERB_NAMES.includes(key)) return key;
|
|
70
|
+
return VERB_ALIASES[key] ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A ticker symbol as the API will accept it, or null.
|
|
75
|
+
*
|
|
76
|
+
* Deliberately narrow — 1-6 letters with an optional class suffix (BRK.B) —
|
|
77
|
+
* because the whole point of the check is to tell "AAPL" from "rivian", and
|
|
78
|
+
* send the second one to /api/lookup with a useful message instead of a 400.
|
|
79
|
+
*/
|
|
80
|
+
export function normalizeSymbol(input) {
|
|
81
|
+
const raw = String(input ?? "").trim().toUpperCase();
|
|
82
|
+
return /^[A-Z]{1,6}(?:[.-][A-Z]{1,2})?$/.test(raw) ? raw : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function takeFlag(args, name, { boolean = false } = {}) {
|
|
86
|
+
const out = { value: null, rest: [], missing: false, present: false };
|
|
87
|
+
for (let i = 0; i < args.length; i++) {
|
|
88
|
+
const arg = String(args[i]);
|
|
89
|
+
if (arg === name) {
|
|
90
|
+
out.present = true;
|
|
91
|
+
if (boolean) continue;
|
|
92
|
+
const next = args[i + 1];
|
|
93
|
+
if (next == null || String(next).startsWith("-")) out.missing = true;
|
|
94
|
+
else { out.value = String(next); i++; }
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!boolean && arg.startsWith(`${name}=`)) {
|
|
98
|
+
out.present = true;
|
|
99
|
+
const value = arg.slice(name.length + 1);
|
|
100
|
+
if (value === "") out.missing = true; else out.value = value;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
out.rest.push(arg);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function positiveInt(value, { max }) {
|
|
109
|
+
const n = Number(value);
|
|
110
|
+
if (!Number.isInteger(n) || n < 1) return null;
|
|
111
|
+
return Math.min(n, max);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const SORTS = ["recent", "score", "ticker"];
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Translate `ticker` arguments into a request the caller can execute.
|
|
118
|
+
*
|
|
119
|
+
* Returns one of `{ usage }`, `{ error }`, or
|
|
120
|
+
* `{ verb, path, query, json, open? }` — never performs IO, so the whole
|
|
121
|
+
* argument surface is testable without a network.
|
|
122
|
+
*/
|
|
123
|
+
export function tickerArgs(input = []) {
|
|
124
|
+
const args = input.map(String);
|
|
125
|
+
const jsonFlag = takeFlag(args, "--json", { boolean: true });
|
|
126
|
+
let rest = jsonFlag.rest;
|
|
127
|
+
const json = jsonFlag.present;
|
|
128
|
+
|
|
129
|
+
const limitFlag = takeFlag(rest, "--limit"); rest = limitFlag.rest;
|
|
130
|
+
const sortFlag = takeFlag(rest, "--sort"); rest = sortFlag.rest;
|
|
131
|
+
const horizonFlag = takeFlag(rest, "--horizon"); rest = horizonFlag.rest;
|
|
132
|
+
const providerFlag = takeFlag(rest, "--provider"); rest = providerFlag.rest;
|
|
133
|
+
|
|
134
|
+
if (limitFlag.missing) return { error: "ticker --limit requires a positive number" };
|
|
135
|
+
if (sortFlag.missing) return { error: `ticker --sort requires one of ${SORTS.join(", ")}` };
|
|
136
|
+
if (horizonFlag.missing) return { error: "ticker --horizon requires 1 or 2" };
|
|
137
|
+
if (providerFlag.missing) return { error: "ticker --provider requires a name" };
|
|
138
|
+
|
|
139
|
+
// A limit above the server's own cap is silently clamped there; clamping here
|
|
140
|
+
// too keeps `--limit 9999` from reading like a promise the API never made.
|
|
141
|
+
const limit = limitFlag.value == null ? null : positiveInt(limitFlag.value, { max: 50 });
|
|
142
|
+
if (limitFlag.value != null && limit == null) {
|
|
143
|
+
return { error: "ticker --limit requires a positive number" };
|
|
144
|
+
}
|
|
145
|
+
if (sortFlag.value != null && !SORTS.includes(sortFlag.value.toLowerCase())) {
|
|
146
|
+
return { error: `ticker --sort must be one of ${SORTS.join(", ")}` };
|
|
147
|
+
}
|
|
148
|
+
if (horizonFlag.value != null && !["1", "2"].includes(String(horizonFlag.value))) {
|
|
149
|
+
return { error: "ticker --horizon must be 1 or 2" };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const stray = rest.find((arg) => arg.startsWith("-") && arg !== "-");
|
|
153
|
+
if (stray) return { error: `unknown ticker flag ${JSON.stringify(stray)}` };
|
|
154
|
+
|
|
155
|
+
const [first, ...tail] = rest;
|
|
156
|
+
if (!first) return { usage: true };
|
|
157
|
+
|
|
158
|
+
const verb = resolveVerb(first);
|
|
159
|
+
const words = verb ? tail : rest;
|
|
160
|
+
|
|
161
|
+
// No verb → the first word is the ticker. `/ticker AAPL` is the headline
|
|
162
|
+
// case and must stay the shortest thing anyone types.
|
|
163
|
+
const wantsReport = verb == null || verb === "report" || verb === "open";
|
|
164
|
+
if (wantsReport) {
|
|
165
|
+
const raw = words[0];
|
|
166
|
+
if (!raw) return { error: `ticker ${verb === "open" ? "open" : "report"} requires a ticker symbol` };
|
|
167
|
+
const symbol = normalizeSymbol(raw);
|
|
168
|
+
if (!symbol) {
|
|
169
|
+
return {
|
|
170
|
+
error: `${JSON.stringify(String(raw))} is not a ticker symbol — try: moshcode ticker lookup ${String(raw)}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (verb === "open") return { verb: "open", symbol, open: `/ticker/${encodeURIComponent(symbol)}`, json };
|
|
174
|
+
return { verb: "report", symbol, path: "/api/ticker", query: { symbol }, json };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (verb === "signals") {
|
|
178
|
+
const symbol = normalizeSymbol(words[0]);
|
|
179
|
+
if (!words[0]) return { error: "ticker signals requires a ticker symbol" };
|
|
180
|
+
if (!symbol) {
|
|
181
|
+
return { error: `${JSON.stringify(String(words[0]))} is not a ticker symbol — try: moshcode ticker lookup ${words[0]}` };
|
|
182
|
+
}
|
|
183
|
+
return { verb, symbol, path: "/api/signals", query: { ticker: symbol }, json };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (verb === "search" || verb === "lookup") {
|
|
187
|
+
const q = words.join(" ").trim();
|
|
188
|
+
if (!q) return { error: `ticker ${verb} requires something to look for` };
|
|
189
|
+
const path = verb === "search" ? "/api/search" : "/api/lookup";
|
|
190
|
+
return { verb, path, query: { q, ...(limit ? { limit: String(limit) } : {}) }, json };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (verb === "reports") {
|
|
194
|
+
return {
|
|
195
|
+
verb,
|
|
196
|
+
path: "/api/reports",
|
|
197
|
+
query: {
|
|
198
|
+
sort: (sortFlag.value || "score").toLowerCase(),
|
|
199
|
+
...(limit ? { limit: String(limit) } : {}),
|
|
200
|
+
},
|
|
201
|
+
json,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (verb === "discover") {
|
|
206
|
+
const topic = words.join(" ").trim();
|
|
207
|
+
return {
|
|
208
|
+
verb,
|
|
209
|
+
path: "/api/discover",
|
|
210
|
+
query: {
|
|
211
|
+
...(topic ? { topic } : {}),
|
|
212
|
+
provider: providerFlag.value || "offline",
|
|
213
|
+
horizon: String(horizonFlag.value || 2),
|
|
214
|
+
...(limit ? { limit: String(limit) } : {}),
|
|
215
|
+
},
|
|
216
|
+
json,
|
|
217
|
+
slow: true,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (verb === "tickers") return { verb, path: "/api/tickers", query: {}, json };
|
|
222
|
+
if (verb === "stats") return { verb, path: "/api/stats", query: {}, json };
|
|
223
|
+
|
|
224
|
+
return { error: `unknown ticker command ${JSON.stringify(String(first))}` };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Build the absolute URL for a translated request. */
|
|
228
|
+
export function advisorUrl(request, { base = advisorBase() } = {}) {
|
|
229
|
+
const url = new URL((request.path || request.open || "/"), `${base}/`);
|
|
230
|
+
for (const [k, v] of Object.entries(request.query || {})) {
|
|
231
|
+
if (v != null && v !== "") url.searchParams.set(k, String(v));
|
|
232
|
+
}
|
|
233
|
+
return url.toString();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Execute a translated request. `fetchImpl` is injectable for tests.
|
|
238
|
+
*
|
|
239
|
+
* `/api/discover` ranks candidates by running an analysis per ticker, so it can
|
|
240
|
+
* legitimately take a minute; every other route is a row read. One timeout for
|
|
241
|
+
* both would either abort discover or hang forever on a wedged connection.
|
|
242
|
+
*/
|
|
243
|
+
export async function fetchAdvisor(request, { fetchImpl = globalThis.fetch, base = advisorBase(), timeoutMs } = {}) {
|
|
244
|
+
const url = advisorUrl(request, { base });
|
|
245
|
+
const ms = timeoutMs ?? (request.slow ? 180_000 : 45_000);
|
|
246
|
+
const controller = new AbortController();
|
|
247
|
+
const timer = setTimeout(() => controller.abort(), ms);
|
|
248
|
+
try {
|
|
249
|
+
const res = await fetchImpl(url, {
|
|
250
|
+
signal: controller.signal,
|
|
251
|
+
headers: { accept: "application/json", "user-agent": "moshcode-ticker" },
|
|
252
|
+
});
|
|
253
|
+
const text = await res.text();
|
|
254
|
+
let data;
|
|
255
|
+
try { data = JSON.parse(text); } catch { data = null; }
|
|
256
|
+
if (data == null) {
|
|
257
|
+
return { ok: false, status: res.status, url, error: `advis0r returned ${res.status} and not JSON` };
|
|
258
|
+
}
|
|
259
|
+
return { ok: res.ok, status: res.status, url, data };
|
|
260
|
+
} catch (e) {
|
|
261
|
+
const reason = e?.name === "AbortError" ? `timed out after ${Math.round(ms / 1000)}s` : (e?.message || String(e));
|
|
262
|
+
return { ok: false, status: 0, url, error: `advis0r request failed: ${reason}` };
|
|
263
|
+
} finally {
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------- rendering
|
|
269
|
+
|
|
270
|
+
const num = (v, digits = 2) =>
|
|
271
|
+
v == null || !Number.isFinite(Number(v)) ? null : Number(v).toFixed(digits).replace(/\.00$/, "");
|
|
272
|
+
|
|
273
|
+
const money = (v) =>
|
|
274
|
+
num(v) == null
|
|
275
|
+
? "—"
|
|
276
|
+
: `$${Number(v).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
277
|
+
|
|
278
|
+
function compact(v) {
|
|
279
|
+
const n = Number(v);
|
|
280
|
+
if (!Number.isFinite(n)) return null;
|
|
281
|
+
const units = [[1e12, "T"], [1e9, "B"], [1e6, "M"], [1e3, "K"]];
|
|
282
|
+
for (const [size, suffix] of units) {
|
|
283
|
+
if (Math.abs(n) >= size) return `${(n / size).toFixed(2).replace(/\.?0+$/, "")}${suffix}`;
|
|
284
|
+
}
|
|
285
|
+
return String(n);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const day = (v) => (v ? String(v).slice(0, 10) : "—");
|
|
289
|
+
|
|
290
|
+
function clip(text, width) {
|
|
291
|
+
const s = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
292
|
+
return s.length <= width ? s : `${s.slice(0, Math.max(1, width - 1))}…`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Direction → color, so a wall of signals is skimmable. */
|
|
296
|
+
function tone(direction) {
|
|
297
|
+
if (direction === "positive") return acid;
|
|
298
|
+
if (direction === "negative") return danger;
|
|
299
|
+
return ash;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function scoreTone(score) {
|
|
303
|
+
const n = Number(score);
|
|
304
|
+
if (!Number.isFinite(n)) return ash;
|
|
305
|
+
if (n >= 60) return acid;
|
|
306
|
+
if (n >= 40) return amber;
|
|
307
|
+
return danger;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function reportHeader(d) {
|
|
311
|
+
const lines = [];
|
|
312
|
+
const name = d.companyName && d.companyName !== d.ticker ? ` ${bone(d.companyName)}` : "";
|
|
313
|
+
lines.push(` ${acid(d.ticker)}${name}${d.exchange ? ash(` ${d.exchange}`) : ""}`);
|
|
314
|
+
const asOf = day(d.priceTimestamp);
|
|
315
|
+
const feed = [d.delayed === false ? "live" : "delayed", d.marketSource].filter(Boolean).join(" · ");
|
|
316
|
+
lines.push(` ${bone(money(d.lastPrice))} ${ash(`${feed} · ${asOf}`)}`);
|
|
317
|
+
return lines;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function renderReport(d, { width }) {
|
|
321
|
+
const lines = ["", ...reportHeader(d), ""];
|
|
322
|
+
|
|
323
|
+
if (d.overallScore != null) {
|
|
324
|
+
const paint = scoreTone(d.overallScore);
|
|
325
|
+
const bits = [
|
|
326
|
+
`${paint(`score ${num(d.overallScore, 1)}`)}${ash("/100")}`,
|
|
327
|
+
d.confidence == null ? null : ash(`confidence ${num(d.confidence, 1)}%`),
|
|
328
|
+
d.classification ? bone(d.classification) : null,
|
|
329
|
+
].filter(Boolean);
|
|
330
|
+
lines.push(` ${bits.join(ash(" "))}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const t = d.technical;
|
|
334
|
+
if (t) {
|
|
335
|
+
const parts = [
|
|
336
|
+
t.rsi14 == null ? null : `rsi14 ${num(t.rsi14, 1)}`,
|
|
337
|
+
t.sma?.[50] == null ? null : `sma50 ${num(t.sma[50])}`,
|
|
338
|
+
t.sma?.[200] == null ? null : `sma200 ${num(t.sma[200])}`,
|
|
339
|
+
t.atr14 == null ? null : `atr ${num(t.atr14)}`,
|
|
340
|
+
t.relativeVolume == null ? null : `rvol ${num(t.relativeVolume)}`,
|
|
341
|
+
].filter(Boolean);
|
|
342
|
+
if (parts.length) lines.push(` ${ash("technical")} ${parts.join(ash(" · "))}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const f = d.facts;
|
|
346
|
+
if (f && f.source !== "unavailable") {
|
|
347
|
+
const parts = [
|
|
348
|
+
f.marketCap == null ? null : `cap ${compact(f.marketCap)}`,
|
|
349
|
+
f.revenue == null ? null : `rev ${compact(f.revenue)}`,
|
|
350
|
+
f.revenueGrowth == null ? null : `growth ${num(f.revenueGrowth, 1)}%`,
|
|
351
|
+
f.freeCashFlow == null ? null : `fcf ${compact(f.freeCashFlow)}`,
|
|
352
|
+
f.totalDebt == null ? null : `debt ${compact(f.totalDebt)}`,
|
|
353
|
+
].filter(Boolean);
|
|
354
|
+
if (parts.length) lines.push(` ${ash("fundamentals")} ${parts.join(ash(" · "))}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// The hosted-model take when one has been paid for, the deterministic one
|
|
358
|
+
// otherwise — labelled either way, because "an LLM said so" and "a rule fired"
|
|
359
|
+
// deserve different amounts of trust.
|
|
360
|
+
const ai = d.aiAnalysis;
|
|
361
|
+
const thesis = ai?.analysis?.thesis || d.analysis?.thesis;
|
|
362
|
+
if (thesis) {
|
|
363
|
+
const label = ai ? `${ai.provider}${ai.model ? `/${ai.model}` : ""}` : "offline";
|
|
364
|
+
lines.push("", ` ${ash(`thesis (${label})`)}`);
|
|
365
|
+
for (const line of wrapText(thesis, width - 4)) lines.push(` ${bone(line)}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const signals = Array.isArray(d.signals) ? d.signals : [];
|
|
369
|
+
if (signals.length) {
|
|
370
|
+
const pos = signals.filter((s) => s.direction === "positive").length;
|
|
371
|
+
const neg = signals.filter((s) => s.direction === "negative").length;
|
|
372
|
+
lines.push("", ` ${ash("signals")} ${acid(`${pos} positive`)} ${ash("·")} ${danger(`${neg} negative`)} ${ash(`· ${signals.length} total`)}`);
|
|
373
|
+
for (const s of signals.slice(0, 5)) {
|
|
374
|
+
const paint = tone(s.direction);
|
|
375
|
+
lines.push(` ${paint("•")} ${ash(day(s.event_date))} ${bone(String(s.signal_type ?? "signal"))} ${ash(clip(s.quote, Math.max(20, width - 40)))}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const sources = Array.isArray(d.sources) ? d.sources : [];
|
|
380
|
+
if (sources.length) {
|
|
381
|
+
lines.push("", ` ${ash("sources")} ${bone(String(sources.length))}`);
|
|
382
|
+
for (const s of sources.slice(0, 4)) {
|
|
383
|
+
lines.push(` ${ash(day(s.publishedAt))} ${bone(clip(s.title, Math.max(20, width - 24)))}`);
|
|
384
|
+
lines.push(` ${dim(clip(s.url, width - 8))}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
lines.push("", ` ${ash("report")} ${acid(`${advisorBase()}/ticker/${d.ticker}`)}`);
|
|
389
|
+
if (d.reportGeneratedAt) {
|
|
390
|
+
lines.push(` ${ash(`snapshot generated ${d.reportGeneratedAt}${d.cached ? " (cached)" : ""}`)}`);
|
|
391
|
+
}
|
|
392
|
+
if (d.marketError) lines.push(` ${amber(`market data unavailable: ${clip(d.marketError, width - 30)}`)}`);
|
|
393
|
+
if (d.factsError) lines.push(` ${amber(`fundamentals unavailable: ${clip(d.factsError, width - 30)}`)}`);
|
|
394
|
+
lines.push("", ...disclaimerLines(d, width));
|
|
395
|
+
return lines.join("\n");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function wrapText(text, width) {
|
|
399
|
+
const words = String(text).replace(/\s+/g, " ").trim().split(" ");
|
|
400
|
+
const lines = [];
|
|
401
|
+
let line = "";
|
|
402
|
+
for (const word of words) {
|
|
403
|
+
if (line && line.length + word.length + 1 > width) { lines.push(line); line = word; }
|
|
404
|
+
else line = line ? `${line} ${word}` : word;
|
|
405
|
+
}
|
|
406
|
+
if (line) lines.push(line);
|
|
407
|
+
return lines;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The API ships a disclaimer with every substantive response. Printing it is
|
|
412
|
+
* not decoration — this surface renders scored equity research in a terminal
|
|
413
|
+
* next to a broker CLI that can place orders.
|
|
414
|
+
*/
|
|
415
|
+
function disclaimerLines(d, width) {
|
|
416
|
+
const text = d?.disclaimer;
|
|
417
|
+
if (!text) return [];
|
|
418
|
+
return wrapText(text, width - 4).map((line) => ` ${dim(line)}`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function renderSignals(d, { width }) {
|
|
422
|
+
const signals = Array.isArray(d.signals) ? d.signals : [];
|
|
423
|
+
if (!signals.length) return ` ${ash(`no signals indexed for ${d.ticker}`)}`;
|
|
424
|
+
const lines = ["", ` ${acid(d.ticker)} ${ash(`${signals.length} signals`)}`, ""];
|
|
425
|
+
for (const s of signals.slice(0, 40)) {
|
|
426
|
+
const paint = tone(s.direction);
|
|
427
|
+
const strength = s.strength == null ? "" : ash(` ${num(s.strength)}`);
|
|
428
|
+
lines.push(` ${paint("•")} ${ash(day(s.event_date))} ${bone(String(s.signal_type ?? "signal"))}${strength}`);
|
|
429
|
+
if (s.speaker) lines.push(` ${ash(`${s.speaker}${s.speaker_title ? `, ${s.speaker_title}` : ""}`)}`);
|
|
430
|
+
if (s.quote) for (const line of wrapText(s.quote, width - 6)) lines.push(` ${dim(line)}`);
|
|
431
|
+
if (s.source_url) lines.push(` ${dim(clip(s.source_url, width - 6))}`);
|
|
432
|
+
lines.push("");
|
|
433
|
+
}
|
|
434
|
+
if (signals.length > 40) lines.push(` ${ash(`… ${signals.length - 40} more`)}`, "");
|
|
435
|
+
lines.push(...disclaimerLines(d, width));
|
|
436
|
+
return lines.join("\n");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function renderSearch(d, { width }) {
|
|
440
|
+
const results = Array.isArray(d.results) ? d.results : [];
|
|
441
|
+
if (!results.length) return ` ${ash(`nothing indexed matches ${JSON.stringify(String(d.query ?? ""))}`)}`;
|
|
442
|
+
const lines = ["", ` ${ash(`${results.length} hits for`)} ${bone(String(d.query ?? ""))}`, ""];
|
|
443
|
+
for (const r of results) {
|
|
444
|
+
const head = [r.ticker ? acid(String(r.ticker)) : null, r.speaker ? bone(String(r.speaker)) : null, ash(day(r.event_date))]
|
|
445
|
+
.filter(Boolean).join(ash(" · "));
|
|
446
|
+
lines.push(` ${head}`);
|
|
447
|
+
for (const line of wrapText(r.text, width - 6)) lines.push(` ${dim(line)}`);
|
|
448
|
+
lines.push("");
|
|
449
|
+
}
|
|
450
|
+
return lines.join("\n");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function renderLookup(d) {
|
|
454
|
+
const matches = Array.isArray(d.matches) ? d.matches : [];
|
|
455
|
+
if (!matches.length) return ` ${ash(`no ticker matches ${JSON.stringify(String(d.query ?? ""))}`)}`;
|
|
456
|
+
const lines = ["", ` ${ash("matches for")} ${bone(String(d.query ?? ""))}`, ""];
|
|
457
|
+
for (const m of matches) {
|
|
458
|
+
const report = m.hasReport ? acid(" ✓ report") : ash(" · no report yet");
|
|
459
|
+
lines.push(` ${acid(String(m.symbol).padEnd(8))}${bone(clip(m.name, 44).padEnd(46))}${ash(String(m.exchange ?? ""))}${report}`);
|
|
460
|
+
}
|
|
461
|
+
lines.push("", ` ${ash("then:")} ${bone(`moshcode ticker ${matches[0].symbol}`)}`);
|
|
462
|
+
return lines.join("\n");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function renderReports(d) {
|
|
466
|
+
const reports = Array.isArray(d.reports) ? d.reports : [];
|
|
467
|
+
if (!reports.length) return ` ${ash("no stored reports yet")}`;
|
|
468
|
+
const lines = ["", ` ${ash(`${d.total ?? reports.length} stored reports`)}`, ""];
|
|
469
|
+
for (const r of reports) {
|
|
470
|
+
const paint = scoreTone(r.overallScore);
|
|
471
|
+
lines.push(
|
|
472
|
+
` ${acid(String(r.ticker).padEnd(7))}${paint(String(num(r.overallScore, 1) ?? "—").padStart(5))}` +
|
|
473
|
+
`${ash("/100")} ${ash(clip(r.classification ?? "", 21).padEnd(22))}` +
|
|
474
|
+
`${bone(money(r.lastPrice).padStart(11))} ${ash(clip(r.companyName, 28).padEnd(29))}${ash(day(r.generatedAt))}`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
lines.push("", ` ${ash("detail:")} ${bone(`moshcode ticker ${reports[0].ticker}`)}`);
|
|
478
|
+
return lines.join("\n");
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function renderDiscover(d, { width }) {
|
|
482
|
+
const ranked = Array.isArray(d.candidates) ? d.candidates : Array.isArray(d.ranked) ? d.ranked : [];
|
|
483
|
+
if (!ranked.length) return ` ${ash("nothing ranked for that topic")}`;
|
|
484
|
+
const provenance = [d.topic, d.provider, d.horizonQuarters ? `${d.horizonQuarters}q horizon` : null]
|
|
485
|
+
.filter(Boolean).join(" · ");
|
|
486
|
+
const lines = ["", ` ${ash(`ranked watchlist${provenance ? ` · ${provenance}` : ""}`)}`, ""];
|
|
487
|
+
for (const c of ranked) {
|
|
488
|
+
const score = c.overallScore ?? c.score;
|
|
489
|
+
lines.push(
|
|
490
|
+
` ${ash(String(c.rank ?? "").padStart(2))} ${acid(String(c.ticker).padEnd(7))}` +
|
|
491
|
+
`${scoreTone(score)(String(num(score, 1) ?? "—").padStart(5))}${ash("/100")} ` +
|
|
492
|
+
`${bone(money(c.lastPrice).padStart(10))} ${ash(clip(c.classification ?? "", 22).padEnd(23))}` +
|
|
493
|
+
`${bone(clip(c.companyName ?? "", 28))}`,
|
|
494
|
+
);
|
|
495
|
+
if (c.thesis) for (const line of wrapText(c.thesis, width - 8)) lines.push(` ${dim(line)}`);
|
|
496
|
+
if (c.mainRisk) lines.push(` ${amber("risk")} ${dim(clip(c.mainRisk, width - 12))}`);
|
|
497
|
+
lines.push("");
|
|
498
|
+
}
|
|
499
|
+
lines.push("", ...disclaimerLines(d, width));
|
|
500
|
+
return lines.join("\n");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function renderTickers(d) {
|
|
504
|
+
const rows = Array.isArray(d.tickers) ? d.tickers : [];
|
|
505
|
+
if (!rows.length) return ` ${ash("the index is empty")}`;
|
|
506
|
+
const lines = ["", ` ${ash(`${rows.length} tickers in the index`)}`, ""];
|
|
507
|
+
const cells = rows.map((r) => `${acid(String(r.ticker).padEnd(7))}${ash(String(r.n ?? "").padStart(5))}`);
|
|
508
|
+
for (let i = 0; i < cells.length; i += 4) lines.push(` ${cells.slice(i, i + 4).join(" ")}`);
|
|
509
|
+
return lines.join("\n");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function renderStats(d) {
|
|
513
|
+
const rows = [
|
|
514
|
+
["documents", d.documents], ["news documents", d.news_documents],
|
|
515
|
+
["transcripts", d.transcripts], ["signals (usable)", d.signals_usable],
|
|
516
|
+
["signals (boilerplate)", d.signals_boilerplate], ["analyses", d.analyses],
|
|
517
|
+
["market bars", d.market_bars],
|
|
518
|
+
].filter(([, v]) => v != null);
|
|
519
|
+
const lines = ["", ` ${ash("advis0r index coverage")}`, ""];
|
|
520
|
+
for (const [label, value] of rows) {
|
|
521
|
+
lines.push(` ${ash(String(label).padEnd(24))}${bone(Number(value).toLocaleString("en-US"))}`);
|
|
522
|
+
}
|
|
523
|
+
return lines.join("\n");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Render a decoded API response for one verb. */
|
|
527
|
+
export function renderAdvisor(verb, data, { columns } = {}) {
|
|
528
|
+
const width = Math.max(48, Math.min(Number(columns) || 88, 100));
|
|
529
|
+
switch (verb) {
|
|
530
|
+
case "report": return renderReport(data, { width });
|
|
531
|
+
case "signals": return renderSignals(data, { width });
|
|
532
|
+
case "search": return renderSearch(data, { width });
|
|
533
|
+
case "lookup": return renderLookup(data, { width });
|
|
534
|
+
case "reports": return renderReports(data, { width });
|
|
535
|
+
case "discover": return renderDiscover(data, { width });
|
|
536
|
+
case "tickers": return renderTickers(data);
|
|
537
|
+
case "stats": return renderStats(data);
|
|
538
|
+
default: return JSON.stringify(data, null, 2);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Run a `ticker` invocation end to end. Returns a process exit code.
|
|
544
|
+
*
|
|
545
|
+
* `deps` exists so tests drive the whole command — parse, fetch, render — with
|
|
546
|
+
* no network and no stdout.
|
|
547
|
+
*/
|
|
548
|
+
export async function tickerCommand(argv = [], deps = {}) {
|
|
549
|
+
const {
|
|
550
|
+
out = (s) => console.log(s),
|
|
551
|
+
fail = (s) => console.error(s),
|
|
552
|
+
fetchImpl,
|
|
553
|
+
base = advisorBase(),
|
|
554
|
+
openUrl,
|
|
555
|
+
columns = process.stdout.columns,
|
|
556
|
+
} = deps;
|
|
557
|
+
|
|
558
|
+
const request = tickerArgs(argv);
|
|
559
|
+
if (request.usage) { out(tickerUsage()); return 0; }
|
|
560
|
+
if (request.error) { fail(danger(`✗ ${request.error}`)); return 1; }
|
|
561
|
+
|
|
562
|
+
if (request.verb === "open") {
|
|
563
|
+
const url = advisorUrl(request, { base });
|
|
564
|
+
if (request.json) { out(JSON.stringify({ url }, null, 2)); return 0; }
|
|
565
|
+
const opened = openUrl ? openUrl(url) : false;
|
|
566
|
+
out(opened ? `${acid("✓ ")}opened ${bone(url)}` : `${ash("· ")}open this in a browser:\n ${acid(url)}`);
|
|
567
|
+
return 0;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const res = await fetchAdvisor(request, { fetchImpl, base });
|
|
571
|
+
if (res.error) { fail(danger(`✗ ${res.error}`)); return 1; }
|
|
572
|
+
|
|
573
|
+
// The API's own error bodies are more useful than any message invented here:
|
|
574
|
+
// a bad symbol comes back with a didYouMean and a lookup URL.
|
|
575
|
+
if (!res.ok) {
|
|
576
|
+
const message = res.data?.error || `advis0r returned ${res.status}`;
|
|
577
|
+
if (request.json) { out(JSON.stringify(res.data, null, 2)); return 1; }
|
|
578
|
+
fail(danger(`✗ ${message}`));
|
|
579
|
+
if (res.data?.didYouMean?.symbol) {
|
|
580
|
+
fail(` ${ash("try:")} ${bone(`moshcode ticker ${res.data.didYouMean.symbol}`)}`);
|
|
581
|
+
}
|
|
582
|
+
return 1;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (request.json) { out(JSON.stringify(res.data, null, 2)); return 0; }
|
|
586
|
+
out(renderAdvisor(request.verb, res.data, { columns }));
|
|
587
|
+
return 0;
|
|
588
|
+
}
|