sentisense 0.41.0 → 0.43.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/README.md +94 -0
- package/dist/cli.cjs +3904 -0
- package/dist/index.cjs +31 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +159 -4
- package/dist/index.d.ts +159 -4
- package/dist/index.mjs +31 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -1
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,3904 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// src/errors.ts
|
|
5
|
+
var SentiSenseError = class extends Error {
|
|
6
|
+
constructor(message, status, code) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "SentiSenseError";
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
var AuthenticationError = class extends SentiSenseError {
|
|
14
|
+
constructor(message, status, code) {
|
|
15
|
+
super(message, status, code);
|
|
16
|
+
this.name = "AuthenticationError";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
var NotFoundError = class extends SentiSenseError {
|
|
20
|
+
constructor(message, code) {
|
|
21
|
+
super(message, 404, code);
|
|
22
|
+
this.name = "NotFoundError";
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var DeepHistoryUnavailableError = class extends SentiSenseError {
|
|
26
|
+
constructor(message, retryAfter) {
|
|
27
|
+
super(message, 202);
|
|
28
|
+
this.name = "DeepHistoryUnavailableError";
|
|
29
|
+
this.retryAfter = retryAfter;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
var RateLimitError = class extends SentiSenseError {
|
|
33
|
+
constructor(message, code, retryAfter) {
|
|
34
|
+
super(message, 429, code);
|
|
35
|
+
this.name = "RateLimitError";
|
|
36
|
+
this.retryAfter = retryAfter;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
var APIError = class extends SentiSenseError {
|
|
40
|
+
constructor(message, status, code) {
|
|
41
|
+
super(message, status, code);
|
|
42
|
+
this.name = "APIError";
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// src/cli/errors.ts
|
|
47
|
+
var EXIT = {
|
|
48
|
+
OK: 0,
|
|
49
|
+
ERROR: 1,
|
|
50
|
+
USAGE: 2,
|
|
51
|
+
AUTH: 3,
|
|
52
|
+
NOT_FOUND: 4,
|
|
53
|
+
RATE_LIMIT: 5,
|
|
54
|
+
NETWORK: 6
|
|
55
|
+
};
|
|
56
|
+
var EXIT_TABLE = [
|
|
57
|
+
[EXIT.OK, "success"],
|
|
58
|
+
[EXIT.ERROR, "API error or unexpected failure"],
|
|
59
|
+
[EXIT.USAGE, "bad usage: unknown command, flag, or missing argument"],
|
|
60
|
+
[EXIT.AUTH, "missing or rejected API key"],
|
|
61
|
+
[EXIT.NOT_FOUND, "no data for that symbol or identifier"],
|
|
62
|
+
[EXIT.RATE_LIMIT, "rate limited"],
|
|
63
|
+
[EXIT.NETWORK, "network failure or timeout"]
|
|
64
|
+
];
|
|
65
|
+
var KEY_URL = "https://app.sentisense.ai/get-api-key";
|
|
66
|
+
var CliUsageError = class extends Error {
|
|
67
|
+
constructor(message, hint) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = "CliUsageError";
|
|
70
|
+
this.hint = hint;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
var UnknownTickerError = class extends Error {
|
|
74
|
+
constructor(ticker) {
|
|
75
|
+
super(`unknown ticker "${ticker}".`);
|
|
76
|
+
this.name = "UnknownTickerError";
|
|
77
|
+
this.ticker = ticker;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
var MissingKeyError = class extends Error {
|
|
81
|
+
constructor() {
|
|
82
|
+
super("no API key configured.");
|
|
83
|
+
this.name = "MissingKeyError";
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
function describeError(error, debug) {
|
|
87
|
+
const report = classify(error);
|
|
88
|
+
if (debug && error instanceof Error && error.stack) {
|
|
89
|
+
report.lines.push(error.stack);
|
|
90
|
+
}
|
|
91
|
+
return report;
|
|
92
|
+
}
|
|
93
|
+
function classify(error) {
|
|
94
|
+
if (error instanceof MissingKeyError) {
|
|
95
|
+
return {
|
|
96
|
+
exitCode: EXIT.AUTH,
|
|
97
|
+
lines: [
|
|
98
|
+
"error: no API key configured.",
|
|
99
|
+
`next: run "sentisense auth <key>", or set SENTISENSE_API_KEY. Get a key at ${KEY_URL}`
|
|
100
|
+
]
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (error instanceof UnknownTickerError) {
|
|
104
|
+
return {
|
|
105
|
+
exitCode: EXIT.NOT_FOUND,
|
|
106
|
+
lines: [
|
|
107
|
+
`error: ${error.message}`,
|
|
108
|
+
"next: check the symbol. Use canonical tickers, for example GOOGL rather than GOOG and BRK.B rather than BRK-B."
|
|
109
|
+
]
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (error instanceof CliUsageError) {
|
|
113
|
+
return {
|
|
114
|
+
exitCode: EXIT.USAGE,
|
|
115
|
+
lines: [
|
|
116
|
+
`error: ${error.message}`,
|
|
117
|
+
`next: ${error.hint ?? 'run "sentisense --help" for the command list.'}`
|
|
118
|
+
]
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (error instanceof AuthenticationError) {
|
|
122
|
+
return {
|
|
123
|
+
exitCode: EXIT.AUTH,
|
|
124
|
+
lines: [
|
|
125
|
+
`error: the API key was rejected (${error.status ?? 401}): ${error.message}`,
|
|
126
|
+
`next: check the stored key with "sentisense auth", or get a new one at ${KEY_URL}`
|
|
127
|
+
]
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (error instanceof NotFoundError) {
|
|
131
|
+
return {
|
|
132
|
+
exitCode: EXIT.NOT_FOUND,
|
|
133
|
+
lines: [
|
|
134
|
+
`error: not found: ${error.message}`,
|
|
135
|
+
"next: check the symbol. Use canonical tickers, for example GOOGL rather than GOOG."
|
|
136
|
+
]
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (error instanceof RateLimitError) {
|
|
140
|
+
const wait = error.retryAfter ? Math.ceil(error.retryAfter) : 60;
|
|
141
|
+
return {
|
|
142
|
+
exitCode: EXIT.RATE_LIMIT,
|
|
143
|
+
lines: [
|
|
144
|
+
`error: rate limited: ${error.message}`,
|
|
145
|
+
`next: wait ${wait} seconds and run it again. The CLI does not retry for you.`
|
|
146
|
+
]
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (error instanceof SentiSenseError) {
|
|
150
|
+
if (error.status === void 0) {
|
|
151
|
+
return {
|
|
152
|
+
exitCode: EXIT.NETWORK,
|
|
153
|
+
lines: [
|
|
154
|
+
`error: could not reach the API: ${error.message}`,
|
|
155
|
+
'next: check the network and the base URL, then run "sentisense health".'
|
|
156
|
+
]
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
exitCode: EXIT.ERROR,
|
|
161
|
+
lines: [
|
|
162
|
+
`error: request failed (${error.status}): ${error.message}`,
|
|
163
|
+
'next: run "sentisense health" to check the service, then try again.'
|
|
164
|
+
]
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
168
|
+
return {
|
|
169
|
+
exitCode: EXIT.ERROR,
|
|
170
|
+
lines: [
|
|
171
|
+
`error: ${message}`,
|
|
172
|
+
"next: run the same command with --debug for the stack trace."
|
|
173
|
+
]
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// src/cli/ticker.ts
|
|
178
|
+
function rejectSurplus(args, name, allowed) {
|
|
179
|
+
if (args.positionals.length <= allowed) return;
|
|
180
|
+
throw new CliUsageError(
|
|
181
|
+
`${name} takes one ticker, and got ${args.positionals.length}.`,
|
|
182
|
+
"run it once per ticker. Only quote accepts more than one."
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
function oneTicker(args, name) {
|
|
186
|
+
rejectSurplus(args, name, 1);
|
|
187
|
+
const ticker = args.positionals[0];
|
|
188
|
+
if (!ticker) {
|
|
189
|
+
throw new CliUsageError(
|
|
190
|
+
`${name} needs a ticker.`,
|
|
191
|
+
`for example: sentisense ${name} NVDA`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return ticker.toUpperCase();
|
|
195
|
+
}
|
|
196
|
+
function optionalTicker(args, name) {
|
|
197
|
+
rejectSurplus(args, name, 1);
|
|
198
|
+
return args.positionals[0]?.toUpperCase();
|
|
199
|
+
}
|
|
200
|
+
function rejectPositionals(args, name) {
|
|
201
|
+
if (args.positionals.length === 0) return;
|
|
202
|
+
throw new CliUsageError(
|
|
203
|
+
`${name} takes no ticker, and got ${args.positionals.length}.`,
|
|
204
|
+
`run "sentisense help ${name}" for what it accepts.`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
async function verifyTickerOnEmpty(api, ticker) {
|
|
208
|
+
try {
|
|
209
|
+
await api.stocks.getQuote(ticker);
|
|
210
|
+
return void 0;
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error instanceof NotFoundError) throw new UnknownTickerError(ticker);
|
|
213
|
+
return `could not verify ${ticker}, so the empty result is unconfirmed`;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
var EMPTY_VERIFY_NOTE = "An empty result verifies the ticker before reporting no data, so a typo exits 4 rather";
|
|
217
|
+
var EMPTY_VERIFY_NOTE_2 = "than looking like a company with nothing to report.";
|
|
218
|
+
|
|
219
|
+
// src/cli/render/doc.ts
|
|
220
|
+
function cell(text, tone) {
|
|
221
|
+
return tone ? { text, tone } : { text };
|
|
222
|
+
}
|
|
223
|
+
function field(label, text, tone) {
|
|
224
|
+
return { label, value: cell(text, tone) };
|
|
225
|
+
}
|
|
226
|
+
function fields(...items) {
|
|
227
|
+
return items.filter((item) => item !== void 0);
|
|
228
|
+
}
|
|
229
|
+
function doc(...blocks) {
|
|
230
|
+
return { blocks: blocks.filter((block) => block !== void 0) };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// src/cli/render/num.ts
|
|
234
|
+
var ABSENT = "n/a";
|
|
235
|
+
var SPARK_LEVELS = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
|
|
236
|
+
function isNum(value) {
|
|
237
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
238
|
+
}
|
|
239
|
+
function trim(text) {
|
|
240
|
+
return text.includes(".") ? text.replace(/\.?0+$/, "") : text;
|
|
241
|
+
}
|
|
242
|
+
function fixed(value, decimals = 2) {
|
|
243
|
+
return isNum(value) ? value.toFixed(decimals) : ABSENT;
|
|
244
|
+
}
|
|
245
|
+
function humanize(value, decimals = 2) {
|
|
246
|
+
if (!isNum(value)) return ABSENT;
|
|
247
|
+
const sign = value < 0 ? "-" : "";
|
|
248
|
+
const abs = Math.abs(value);
|
|
249
|
+
const units = [
|
|
250
|
+
[1e12, "T"],
|
|
251
|
+
[1e9, "B"],
|
|
252
|
+
[1e6, "M"],
|
|
253
|
+
[1e3, "k"]
|
|
254
|
+
];
|
|
255
|
+
for (const [scale, suffix] of units) {
|
|
256
|
+
if (abs >= scale) return `${sign}${trim((abs / scale).toFixed(decimals))}${suffix}`;
|
|
257
|
+
}
|
|
258
|
+
return `${sign}${trim(abs.toFixed(decimals))}`;
|
|
259
|
+
}
|
|
260
|
+
function signed(value, decimals = 2) {
|
|
261
|
+
if (!isNum(value)) return ABSENT;
|
|
262
|
+
return `${value > 0 ? "+" : ""}${value.toFixed(decimals)}`;
|
|
263
|
+
}
|
|
264
|
+
function signedPercent(value, decimals = 2) {
|
|
265
|
+
if (!isNum(value)) return ABSENT;
|
|
266
|
+
return `${value > 0 ? "+" : ""}${value.toFixed(decimals)}%`;
|
|
267
|
+
}
|
|
268
|
+
function percent(value, decimals = 1) {
|
|
269
|
+
return isNum(value) ? `${value.toFixed(decimals)}%` : ABSENT;
|
|
270
|
+
}
|
|
271
|
+
function money(value, decimals = 2) {
|
|
272
|
+
return isNum(value) ? `$${value.toFixed(decimals)}` : ABSENT;
|
|
273
|
+
}
|
|
274
|
+
function direction(value) {
|
|
275
|
+
if (!isNum(value) || value === 0) return void 0;
|
|
276
|
+
return value > 0 ? "up" : "down";
|
|
277
|
+
}
|
|
278
|
+
function sparkline(series) {
|
|
279
|
+
const numbers = series.filter(isNum);
|
|
280
|
+
if (numbers.length === 0) return "";
|
|
281
|
+
const min = Math.min(...numbers);
|
|
282
|
+
const max = Math.max(...numbers);
|
|
283
|
+
const span = max - min;
|
|
284
|
+
return series.map((point) => {
|
|
285
|
+
if (!isNum(point)) return " ";
|
|
286
|
+
if (span === 0) return SPARK_LEVELS[3];
|
|
287
|
+
const level = Math.round((point - min) / span * (SPARK_LEVELS.length - 1));
|
|
288
|
+
return SPARK_LEVELS[level];
|
|
289
|
+
}).join("");
|
|
290
|
+
}
|
|
291
|
+
function timestamp(ms) {
|
|
292
|
+
if (!isNum(ms)) return ABSENT;
|
|
293
|
+
const iso = new Date(ms).toISOString();
|
|
294
|
+
return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
|
|
295
|
+
}
|
|
296
|
+
function dateFromSeconds(seconds) {
|
|
297
|
+
if (!isNum(seconds)) return ABSENT;
|
|
298
|
+
return new Date(seconds * 1e3).toISOString().slice(0, 10);
|
|
299
|
+
}
|
|
300
|
+
function truncate(text, max) {
|
|
301
|
+
if (text.length <= max) return text;
|
|
302
|
+
return `${text.slice(0, Math.max(0, max - 3))}...`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// src/cli/commands/analysts.ts
|
|
306
|
+
var DEFAULT_ACTIONS = 5;
|
|
307
|
+
function actionTone(actionType) {
|
|
308
|
+
if (actionType === "UPGRADE") return "up";
|
|
309
|
+
if (actionType === "DOWNGRADE") return "down";
|
|
310
|
+
return void 0;
|
|
311
|
+
}
|
|
312
|
+
var analystsCommand = {
|
|
313
|
+
name: "analysts",
|
|
314
|
+
summary: "Wall Street consensus, price target band, and recent rating changes",
|
|
315
|
+
usage: "sentisense analysts <ticker> [--days N]",
|
|
316
|
+
examples: [
|
|
317
|
+
"sentisense analysts NVDA",
|
|
318
|
+
"sentisense analysts NVDA --days 180 --full",
|
|
319
|
+
"sentisense analysts NVDA --json"
|
|
320
|
+
],
|
|
321
|
+
notes: [
|
|
322
|
+
"The price target band and analyst count come back in full on every key.",
|
|
323
|
+
"The buy / hold / sell distribution and the longer action history are PRO.",
|
|
324
|
+
"A ticker with no analyst coverage exits 4 rather than returning an empty band."
|
|
325
|
+
],
|
|
326
|
+
flags: {
|
|
327
|
+
days: { type: "number", placeholder: "N", describe: "Days of rating history (default 90)" }
|
|
328
|
+
},
|
|
329
|
+
async run({ args, client, full }) {
|
|
330
|
+
const ticker = oneTicker(args, "analysts");
|
|
331
|
+
const lookbackDays = typeof args.flags.days === "number" ? args.flags.days : void 0;
|
|
332
|
+
const api = client();
|
|
333
|
+
const notes = [];
|
|
334
|
+
const [consensus, actions] = await Promise.all([
|
|
335
|
+
api.analyst.consensus(ticker),
|
|
336
|
+
// Supplementary: the consensus is the answer, so a failure here trims the output
|
|
337
|
+
// rather than failing the command, and says so on stderr instead of vanishing.
|
|
338
|
+
api.analyst.actions(ticker, lookbackDays === void 0 ? void 0 : { lookbackDays }).catch(() => {
|
|
339
|
+
notes.push("rating history unavailable, showing the consensus without it");
|
|
340
|
+
return null;
|
|
341
|
+
})
|
|
342
|
+
]);
|
|
343
|
+
const data = consensus.data;
|
|
344
|
+
const blocks = [
|
|
345
|
+
{
|
|
346
|
+
kind: "head",
|
|
347
|
+
title: field("ticker", ticker),
|
|
348
|
+
right: fields(
|
|
349
|
+
field("target mean", money(data?.targetMean)),
|
|
350
|
+
field(
|
|
351
|
+
"upside",
|
|
352
|
+
signedPercent(data?.upsidePercent),
|
|
353
|
+
direction(data?.upsidePercent)
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
kind: "facts",
|
|
359
|
+
items: fields(
|
|
360
|
+
field("Analysts", data?.numberOfAnalysts === void 0 ? "n/a" : String(data.numberOfAnalysts)),
|
|
361
|
+
field("Consensus", data?.consensusLabel ?? "n/a"),
|
|
362
|
+
field("Low", fixed(data?.targetLow)),
|
|
363
|
+
field("Median", fixed(data?.targetMedian)),
|
|
364
|
+
field("High", fixed(data?.targetHigh))
|
|
365
|
+
)
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
kind: "facts",
|
|
369
|
+
items: fields(
|
|
370
|
+
field("Strong buy", String(data?.strongBuy ?? 0)),
|
|
371
|
+
field("Buy", String(data?.buy ?? 0)),
|
|
372
|
+
field("Hold", String(data?.hold ?? 0)),
|
|
373
|
+
field("Sell", String(data?.sell ?? 0)),
|
|
374
|
+
field("Strong sell", String(data?.strongSell ?? 0))
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
];
|
|
378
|
+
const rows = actions?.data ?? [];
|
|
379
|
+
if (rows.length > 0) {
|
|
380
|
+
const shown = full ? rows : rows.slice(0, DEFAULT_ACTIONS);
|
|
381
|
+
blocks.push({ kind: "blank" });
|
|
382
|
+
blocks.push({
|
|
383
|
+
kind: "table",
|
|
384
|
+
head: ["DATE", "FIRM", "ACTION", "FROM", "TO"],
|
|
385
|
+
rows: shown.map((action) => [
|
|
386
|
+
cell(action.actionDate),
|
|
387
|
+
cell(action.firm),
|
|
388
|
+
cell(action.actionType, actionTone(action.actionType)),
|
|
389
|
+
cell(action.fromGrade ?? ""),
|
|
390
|
+
cell(action.toGrade ?? "")
|
|
391
|
+
])
|
|
392
|
+
});
|
|
393
|
+
if (!full && rows.length > shown.length) {
|
|
394
|
+
blocks.push({
|
|
395
|
+
kind: "text",
|
|
396
|
+
text: `Showing ${shown.length} of ${rows.length} rating changes. Add --full for the rest.`,
|
|
397
|
+
tone: "dim"
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (consensus.isPreview) {
|
|
402
|
+
blocks.push({
|
|
403
|
+
kind: "text",
|
|
404
|
+
text: "Preview response: the rating distribution reads zero and the history is trimmed on a free key.",
|
|
405
|
+
tone: "dim"
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
return { json: { consensus, actions }, doc: doc(...blocks), notes };
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
// src/cli/config.ts
|
|
413
|
+
var import_node_fs = require("fs");
|
|
414
|
+
var import_node_os = require("os");
|
|
415
|
+
var import_node_path = require("path");
|
|
416
|
+
var CONFIG_FILE = "config.json";
|
|
417
|
+
function resolveConfigDir(env, override) {
|
|
418
|
+
if (override) return override;
|
|
419
|
+
const explicit = env.SENTISENSE_CONFIG_DIR?.trim();
|
|
420
|
+
if (explicit) return explicit;
|
|
421
|
+
const xdg = env.XDG_CONFIG_HOME?.trim();
|
|
422
|
+
if (xdg) return (0, import_node_path.join)(xdg, "sentisense");
|
|
423
|
+
return (0, import_node_path.join)((0, import_node_os.homedir)(), ".config", "sentisense");
|
|
424
|
+
}
|
|
425
|
+
function configPath(dir) {
|
|
426
|
+
return (0, import_node_path.join)(dir, CONFIG_FILE);
|
|
427
|
+
}
|
|
428
|
+
function readConfig(dir) {
|
|
429
|
+
const path = configPath(dir);
|
|
430
|
+
if (!(0, import_node_fs.existsSync)(path)) return {};
|
|
431
|
+
try {
|
|
432
|
+
const parsed = JSON.parse((0, import_node_fs.readFileSync)(path, "utf8"));
|
|
433
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
434
|
+
const raw = parsed;
|
|
435
|
+
const config = {};
|
|
436
|
+
if (typeof raw.apiKey === "string") config.apiKey = raw.apiKey;
|
|
437
|
+
if (typeof raw.agentName === "string") config.agentName = raw.agentName;
|
|
438
|
+
if (typeof raw.baseUrl === "string") config.baseUrl = raw.baseUrl;
|
|
439
|
+
return config;
|
|
440
|
+
} catch {
|
|
441
|
+
return {};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function writeConfig(dir, config) {
|
|
445
|
+
(0, import_node_fs.mkdirSync)(dir, { recursive: true, mode: 448 });
|
|
446
|
+
const path = configPath(dir);
|
|
447
|
+
(0, import_node_fs.writeFileSync)(path, `${JSON.stringify(config, null, 2)}
|
|
448
|
+
`, { mode: 384 });
|
|
449
|
+
(0, import_node_fs.chmodSync)(path, 384);
|
|
450
|
+
return path;
|
|
451
|
+
}
|
|
452
|
+
function clearConfig(dir) {
|
|
453
|
+
const path = configPath(dir);
|
|
454
|
+
if (!(0, import_node_fs.existsSync)(path)) return false;
|
|
455
|
+
(0, import_node_fs.unlinkSync)(path);
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
function maskKey(key) {
|
|
459
|
+
if (key.length <= 8) return "*".repeat(key.length);
|
|
460
|
+
return `${key.slice(0, 4)}...${key.slice(-4)}`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// src/cli/commands/auth.ts
|
|
464
|
+
var authCommand = {
|
|
465
|
+
name: "auth",
|
|
466
|
+
summary: "Store an API key, show what is configured, or remove it",
|
|
467
|
+
usage: "sentisense auth [<key>] [--agent <name>] [--remove]",
|
|
468
|
+
examples: [
|
|
469
|
+
"sentisense auth $SENTISENSE_API_KEY",
|
|
470
|
+
"sentisense auth --agent research-desk",
|
|
471
|
+
"sentisense auth",
|
|
472
|
+
"sentisense auth --remove"
|
|
473
|
+
],
|
|
474
|
+
notes: [
|
|
475
|
+
"Settings live in config.json under $SENTISENSE_CONFIG_DIR, $XDG_CONFIG_HOME/sentisense,",
|
|
476
|
+
"or ~/.config/sentisense, written owner-readable only (0600).",
|
|
477
|
+
"The key is never printed back in full, and never has to be pasted into a command again.",
|
|
478
|
+
"An agent name is optional. When set, it rides along in the User-Agent so your own",
|
|
479
|
+
"traffic is easy to tell apart from everything else calling the API."
|
|
480
|
+
],
|
|
481
|
+
flags: {
|
|
482
|
+
remove: { type: "boolean", describe: "Delete the stored settings" }
|
|
483
|
+
},
|
|
484
|
+
async run({ args, context }) {
|
|
485
|
+
const dir = context.configDir;
|
|
486
|
+
const path = configPath(dir);
|
|
487
|
+
if (args.flags.remove === true) {
|
|
488
|
+
const removed = clearConfig(dir);
|
|
489
|
+
return {
|
|
490
|
+
json: { removed, path },
|
|
491
|
+
doc: doc(
|
|
492
|
+
{ kind: "text", text: removed ? `Removed ${path}` : `Nothing stored at ${path}` }
|
|
493
|
+
)
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
const key = args.positionals[0];
|
|
497
|
+
const agent = typeof args.flags.agent === "string" ? args.flags.agent : void 0;
|
|
498
|
+
const baseUrl = typeof args.flags["base-url"] === "string" ? args.flags["base-url"] : void 0;
|
|
499
|
+
if (key || agent || baseUrl) {
|
|
500
|
+
const stored2 = readConfig(dir);
|
|
501
|
+
const next = {
|
|
502
|
+
...stored2,
|
|
503
|
+
...key ? { apiKey: key } : {},
|
|
504
|
+
...agent ? { agentName: agent } : {},
|
|
505
|
+
...baseUrl ? { baseUrl } : {}
|
|
506
|
+
};
|
|
507
|
+
writeConfig(dir, next);
|
|
508
|
+
return {
|
|
509
|
+
json: {
|
|
510
|
+
path,
|
|
511
|
+
apiKey: next.apiKey ? maskKey(next.apiKey) : null,
|
|
512
|
+
agentName: next.agentName ?? null,
|
|
513
|
+
baseUrl: next.baseUrl ?? null
|
|
514
|
+
},
|
|
515
|
+
doc: doc(
|
|
516
|
+
{ kind: "text", text: `Saved to ${path}` },
|
|
517
|
+
{
|
|
518
|
+
kind: "kv",
|
|
519
|
+
items: fields(
|
|
520
|
+
next.apiKey ? field("api key", maskKey(next.apiKey)) : void 0,
|
|
521
|
+
next.agentName ? field("agent", next.agentName) : void 0,
|
|
522
|
+
next.baseUrl ? field("base url", next.baseUrl) : void 0
|
|
523
|
+
)
|
|
524
|
+
}
|
|
525
|
+
)
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
const stored = readConfig(dir);
|
|
529
|
+
const resolved = context.apiKey;
|
|
530
|
+
return {
|
|
531
|
+
json: {
|
|
532
|
+
path,
|
|
533
|
+
configured: Boolean(stored.apiKey),
|
|
534
|
+
apiKey: resolved ? maskKey(resolved) : null,
|
|
535
|
+
apiKeySource: context.apiKeySource,
|
|
536
|
+
agentName: context.agentName ?? null,
|
|
537
|
+
baseUrl: context.baseUrl ?? null
|
|
538
|
+
},
|
|
539
|
+
doc: doc(
|
|
540
|
+
{
|
|
541
|
+
kind: "kv",
|
|
542
|
+
items: fields(
|
|
543
|
+
field("api key", resolved ? maskKey(resolved) : "not configured"),
|
|
544
|
+
field("source", resolved ? context.apiKeySource : "none"),
|
|
545
|
+
field("config", path),
|
|
546
|
+
context.agentName ? field("agent", context.agentName) : void 0,
|
|
547
|
+
context.baseUrl ? field("base url", context.baseUrl) : void 0
|
|
548
|
+
)
|
|
549
|
+
},
|
|
550
|
+
resolved ? void 0 : {
|
|
551
|
+
kind: "text",
|
|
552
|
+
text: `Store one with "sentisense auth <key>". Get a key at ${KEY_URL}`
|
|
553
|
+
}
|
|
554
|
+
)
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
// src/cli/commands/congress.ts
|
|
560
|
+
var congressCommand = {
|
|
561
|
+
name: "congress",
|
|
562
|
+
summary: "Congressional stock disclosures, market-wide or for one ticker",
|
|
563
|
+
usage: "sentisense congress [ticker] [--days N] [--limit N]",
|
|
564
|
+
examples: [
|
|
565
|
+
"sentisense congress",
|
|
566
|
+
"sentisense congress NVDA",
|
|
567
|
+
"sentisense congress --days 30 --limit 50 --full",
|
|
568
|
+
"sentisense congress NVDA --json"
|
|
569
|
+
],
|
|
570
|
+
notes: [
|
|
571
|
+
"Disclosures are filed after the fact, so the gap between the trade date and the",
|
|
572
|
+
"disclosure date is part of the picture. The delay column carries it in days.",
|
|
573
|
+
"Amounts are the filed ranges, never exact figures: that is how the filings work.",
|
|
574
|
+
"A free key sees a short preview of either feed.",
|
|
575
|
+
EMPTY_VERIFY_NOTE,
|
|
576
|
+
EMPTY_VERIFY_NOTE_2
|
|
577
|
+
],
|
|
578
|
+
flags: {
|
|
579
|
+
days: { type: "number", placeholder: "N", describe: "Look-back window, 1 to 365 (default 90)" },
|
|
580
|
+
limit: { type: "number", placeholder: "N", describe: "Rows to request, market-wide feed only" }
|
|
581
|
+
},
|
|
582
|
+
async run({ args, client, full }) {
|
|
583
|
+
const api = client();
|
|
584
|
+
const ticker = optionalTicker(args, "congress");
|
|
585
|
+
const notes = [];
|
|
586
|
+
const lookbackDays = typeof args.flags.days === "number" ? args.flags.days : void 0;
|
|
587
|
+
const limit = typeof args.flags.limit === "number" ? args.flags.limit : void 0;
|
|
588
|
+
let envelope;
|
|
589
|
+
if (ticker) {
|
|
590
|
+
envelope = await api.politicians.getFilings(
|
|
591
|
+
ticker,
|
|
592
|
+
lookbackDays === void 0 ? void 0 : { lookbackDays }
|
|
593
|
+
);
|
|
594
|
+
} else {
|
|
595
|
+
const options = {
|
|
596
|
+
...lookbackDays === void 0 ? {} : { lookbackDays },
|
|
597
|
+
...limit === void 0 ? {} : { limit }
|
|
598
|
+
};
|
|
599
|
+
envelope = await api.politicians.getActivity(
|
|
600
|
+
Object.keys(options).length > 0 ? options : void 0
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
const trades = envelope.data ?? [];
|
|
604
|
+
if (ticker && trades.length === 0) {
|
|
605
|
+
const note = await verifyTickerOnEmpty(api, ticker);
|
|
606
|
+
if (note) notes.push(note);
|
|
607
|
+
}
|
|
608
|
+
const shown = full ? trades : trades.slice(0, 20);
|
|
609
|
+
const blocks = [
|
|
610
|
+
{
|
|
611
|
+
kind: "head",
|
|
612
|
+
title: field("scope", ticker ?? "All disclosures"),
|
|
613
|
+
right: fields(
|
|
614
|
+
field("rows", String(trades.length)),
|
|
615
|
+
envelope.totalCount === void 0 ? void 0 : field("matching", String(envelope.totalCount))
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
];
|
|
619
|
+
if (shown.length === 0) {
|
|
620
|
+
blocks.push({ kind: "text", text: "No disclosures in this window." });
|
|
621
|
+
} else {
|
|
622
|
+
const head = ticker ? ["TRADED", "MEMBER", "PARTY", "TYPE", "AMOUNT", "DELAY"] : ["TRADED", "MEMBER", "TICKER", "TYPE", "AMOUNT", "DELAY"];
|
|
623
|
+
blocks.push({
|
|
624
|
+
kind: "table",
|
|
625
|
+
head,
|
|
626
|
+
align: ["left", "left", "left", "left", "left", "right"],
|
|
627
|
+
rows: shown.map((trade) => [
|
|
628
|
+
cell(trade.transactionDate),
|
|
629
|
+
cell(truncate(trade.politicianName, full ? 40 : 22)),
|
|
630
|
+
// Party and state are absent on some disclosures, so build the label from what is
|
|
631
|
+
// actually there rather than printing a placeholder.
|
|
632
|
+
cell(ticker ? [trade.party, trade.state].filter(Boolean).join(" ") : trade.ticker),
|
|
633
|
+
cell(
|
|
634
|
+
trade.transactionType,
|
|
635
|
+
trade.transactionType === "PURCHASE" ? "up" : trade.transactionType === "SALE" ? "down" : void 0
|
|
636
|
+
),
|
|
637
|
+
cell(trade.amountRange),
|
|
638
|
+
cell(`${trade.disclosureDelayDays}d`)
|
|
639
|
+
])
|
|
640
|
+
});
|
|
641
|
+
if (!full && trades.length > shown.length) {
|
|
642
|
+
blocks.push({
|
|
643
|
+
kind: "text",
|
|
644
|
+
text: `Showing ${shown.length} of ${trades.length}. Add --full for the rest.`,
|
|
645
|
+
tone: "dim"
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (envelope.isPreview) {
|
|
650
|
+
blocks.push({
|
|
651
|
+
kind: "text",
|
|
652
|
+
text: "Preview response: a PRO key returns the full feed.",
|
|
653
|
+
tone: "dim"
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
return { json: envelope, doc: doc(...blocks), notes };
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
// src/cli/commands/earnings.ts
|
|
661
|
+
var TIME_LABEL = {
|
|
662
|
+
before_open: "pre-open",
|
|
663
|
+
after_close: "post-close",
|
|
664
|
+
during_market: "intraday",
|
|
665
|
+
unknown: "unknown"
|
|
666
|
+
};
|
|
667
|
+
var earningsCommand = {
|
|
668
|
+
name: "earnings",
|
|
669
|
+
summary: "Upcoming report dates, or the quarter-by-quarter analysis for one ticker",
|
|
670
|
+
usage: "sentisense earnings [ticker] [--week this|next] [--limit N]",
|
|
671
|
+
examples: [
|
|
672
|
+
"sentisense earnings",
|
|
673
|
+
"sentisense earnings --week next",
|
|
674
|
+
"sentisense earnings AAPL",
|
|
675
|
+
"sentisense earnings AAPL --limit 4 --full"
|
|
676
|
+
],
|
|
677
|
+
notes: [
|
|
678
|
+
"With no ticker this is the forward calendar: who reports, when, and the consensus EPS.",
|
|
679
|
+
"With a ticker it is the backward-looking analysis: one entry per reported quarter with",
|
|
680
|
+
"the editorial headline, and on a PRO key the written summary and guidance language.",
|
|
681
|
+
"A free key sees the current week of the calendar and the latest quarter of the analysis.",
|
|
682
|
+
"Coverage of the per-ticker analysis is not the whole market, so a tracked company can",
|
|
683
|
+
"have no stored quarter yet.",
|
|
684
|
+
EMPTY_VERIFY_NOTE,
|
|
685
|
+
EMPTY_VERIFY_NOTE_2
|
|
686
|
+
],
|
|
687
|
+
flags: {
|
|
688
|
+
week: { type: "string", placeholder: "this|next", describe: "Calendar window shorthand" },
|
|
689
|
+
from: { type: "string", placeholder: "YYYY-MM-DD", describe: "Calendar lower bound" },
|
|
690
|
+
to: { type: "string", placeholder: "YYYY-MM-DD", describe: "Calendar upper bound" },
|
|
691
|
+
confirmed: { type: "boolean", describe: "Calendar: only company-confirmed dates" },
|
|
692
|
+
limit: { type: "number", placeholder: "N", describe: "Quarters to return for a ticker" }
|
|
693
|
+
},
|
|
694
|
+
async run({ args, client, full }) {
|
|
695
|
+
const api = client();
|
|
696
|
+
const ticker = optionalTicker(args, "earnings");
|
|
697
|
+
const notes = [];
|
|
698
|
+
if (ticker) {
|
|
699
|
+
const limit = typeof args.flags.limit === "number" ? args.flags.limit : void 0;
|
|
700
|
+
const envelope2 = await api.earnings.getSummaries(
|
|
701
|
+
ticker,
|
|
702
|
+
limit === void 0 ? void 0 : { limit }
|
|
703
|
+
);
|
|
704
|
+
const quarters = envelope2.data ?? [];
|
|
705
|
+
if (quarters.length === 0) {
|
|
706
|
+
const note = await verifyTickerOnEmpty(api, ticker);
|
|
707
|
+
if (note) notes.push(note);
|
|
708
|
+
}
|
|
709
|
+
const blocks2 = [
|
|
710
|
+
{
|
|
711
|
+
kind: "head",
|
|
712
|
+
title: field("ticker", ticker),
|
|
713
|
+
right: fields(
|
|
714
|
+
field("quarters", String(quarters.length)),
|
|
715
|
+
envelope2.totalCount === void 0 ? void 0 : field("available", String(envelope2.totalCount))
|
|
716
|
+
)
|
|
717
|
+
}
|
|
718
|
+
];
|
|
719
|
+
if (quarters.length === 0) {
|
|
720
|
+
blocks2.push({ kind: "text", text: "No stored quarters for this ticker yet." });
|
|
721
|
+
} else {
|
|
722
|
+
blocks2.push({
|
|
723
|
+
kind: "table",
|
|
724
|
+
head: ["PERIOD", "REPORTED", "CALL", "HEADLINE"],
|
|
725
|
+
rows: quarters.map((quarter) => [
|
|
726
|
+
cell(quarter.fiscalPeriod),
|
|
727
|
+
cell(quarter.reportDate),
|
|
728
|
+
cell(quarter.hasTranscript ? "yes" : "no", quarter.hasTranscript ? "up" : "dim"),
|
|
729
|
+
cell(truncate(quarter.headline ?? "", full ? 200 : 60))
|
|
730
|
+
])
|
|
731
|
+
});
|
|
732
|
+
if (full) {
|
|
733
|
+
for (const quarter of quarters) {
|
|
734
|
+
blocks2.push({ kind: "blank" });
|
|
735
|
+
blocks2.push({
|
|
736
|
+
kind: "head",
|
|
737
|
+
title: field("period", quarter.fiscalPeriod),
|
|
738
|
+
subtitle: field("reported", quarter.reportDate)
|
|
739
|
+
});
|
|
740
|
+
const highlights = quarter.kpiHighlights ?? [];
|
|
741
|
+
if (highlights.length > 0) {
|
|
742
|
+
blocks2.push({
|
|
743
|
+
kind: "kv",
|
|
744
|
+
items: highlights.map(
|
|
745
|
+
(kpi) => field(kpi.label, kpi.yoy ? `${kpi.value} ${kpi.yoy}` : kpi.value)
|
|
746
|
+
)
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
if (quarter.guidance) {
|
|
750
|
+
blocks2.push({ kind: "text", text: `Guidance: ${quarter.guidance}` });
|
|
751
|
+
} else if (quarter.guidanceDirection) {
|
|
752
|
+
blocks2.push({ kind: "text", text: `Guidance direction: ${quarter.guidanceDirection}` });
|
|
753
|
+
}
|
|
754
|
+
if (quarter.summaryMd) blocks2.push({ kind: "text", text: quarter.summaryMd });
|
|
755
|
+
blocks2.push({
|
|
756
|
+
kind: "text",
|
|
757
|
+
text: `written ${dateFromSeconds(quarter.generatedAt)} from the ${quarter.source === "transcript" ? "earnings call" : "press release"}`,
|
|
758
|
+
tone: "dim"
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
if (envelope2.isPreview) {
|
|
764
|
+
blocks2.push({
|
|
765
|
+
kind: "text",
|
|
766
|
+
text: "Preview response: a PRO key returns every quarter with the written body and guidance.",
|
|
767
|
+
tone: "dim"
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
return { json: envelope2, doc: doc(...blocks2), notes };
|
|
771
|
+
}
|
|
772
|
+
const week = typeof args.flags.week === "string" ? args.flags.week : void 0;
|
|
773
|
+
if (week && week !== "this" && week !== "next") {
|
|
774
|
+
throw new CliUsageError(
|
|
775
|
+
`--week takes "this" or "next", got "${week}".`,
|
|
776
|
+
"for example: --week next"
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
const options = {
|
|
780
|
+
...week ? { week } : {},
|
|
781
|
+
...typeof args.flags.from === "string" ? { from: args.flags.from } : {},
|
|
782
|
+
...typeof args.flags.to === "string" ? { to: args.flags.to } : {},
|
|
783
|
+
...args.flags.confirmed === true ? { confirmed: true } : {}
|
|
784
|
+
};
|
|
785
|
+
const envelope = await api.calendar.getEarnings(
|
|
786
|
+
Object.keys(options).length > 0 ? options : void 0
|
|
787
|
+
);
|
|
788
|
+
const events = envelope.data?.earnings ?? [];
|
|
789
|
+
const meta = envelope.data?.metadata;
|
|
790
|
+
const shown = full ? events : events.slice(0, 25);
|
|
791
|
+
const blocks = [
|
|
792
|
+
{
|
|
793
|
+
kind: "head",
|
|
794
|
+
title: field("window", "Earnings calendar"),
|
|
795
|
+
right: fields(
|
|
796
|
+
field("from", meta?.windowStart ?? "n/a"),
|
|
797
|
+
field("to", meta?.windowEnd ?? "n/a"),
|
|
798
|
+
field("events", String(events.length))
|
|
799
|
+
)
|
|
800
|
+
}
|
|
801
|
+
];
|
|
802
|
+
if (shown.length === 0) {
|
|
803
|
+
blocks.push({ kind: "text", text: "No scheduled reports in this window." });
|
|
804
|
+
} else {
|
|
805
|
+
blocks.push({
|
|
806
|
+
kind: "table",
|
|
807
|
+
head: ["DATE", "TICKER", "WHEN", "PERIOD", "EPS EST", "CONFIRMED"],
|
|
808
|
+
align: ["left", "left", "left", "left", "right", "left"],
|
|
809
|
+
rows: shown.map((event) => [
|
|
810
|
+
cell(event.earningsDate),
|
|
811
|
+
cell(event.ticker),
|
|
812
|
+
cell(TIME_LABEL[event.earningsTime] ?? event.earningsTime),
|
|
813
|
+
cell(event.fiscalQuarter ?? ""),
|
|
814
|
+
cell(fixed(event.estimatedEps)),
|
|
815
|
+
cell(event.confirmed ? "yes" : "no", event.confirmed ? "up" : "dim")
|
|
816
|
+
])
|
|
817
|
+
});
|
|
818
|
+
if (!full && events.length > shown.length) {
|
|
819
|
+
blocks.push({
|
|
820
|
+
kind: "text",
|
|
821
|
+
text: `Showing ${shown.length} of ${events.length}. Add --full for the rest.`,
|
|
822
|
+
tone: "dim"
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
if (envelope.isPreview) {
|
|
827
|
+
blocks.push({
|
|
828
|
+
kind: "text",
|
|
829
|
+
text: "Preview response: a free key sees the current week, a PRO key sees about 30 days ahead.",
|
|
830
|
+
tone: "dim"
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
return { json: envelope, doc: doc(...blocks) };
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
// src/cli/commands/flows.ts
|
|
838
|
+
var DEFAULT_ROWS = 10;
|
|
839
|
+
function latestSettledQuarter(quarters) {
|
|
840
|
+
const dated = quarters.filter(
|
|
841
|
+
(q) => typeof q.reportDate === "string"
|
|
842
|
+
);
|
|
843
|
+
const newestFirst = [...dated].sort((a, b) => b.reportDate.localeCompare(a.reportDate));
|
|
844
|
+
const settled = newestFirst.find((q) => q.pending !== true);
|
|
845
|
+
return (settled ?? newestFirst[0])?.reportDate;
|
|
846
|
+
}
|
|
847
|
+
var flowsCommand = {
|
|
848
|
+
name: "flows",
|
|
849
|
+
summary: "Institutional 13F activity: market-wide flows, or one ticker's holders",
|
|
850
|
+
usage: "sentisense flows [ticker] [--limit N] [--quarter YYYY-MM-DD]",
|
|
851
|
+
examples: [
|
|
852
|
+
"sentisense flows",
|
|
853
|
+
"sentisense flows NVDA",
|
|
854
|
+
"sentisense flows NVDA --limit 25 --full",
|
|
855
|
+
"sentisense flows --json"
|
|
856
|
+
],
|
|
857
|
+
notes: [
|
|
858
|
+
"With no ticker this is the quarter's biggest net share inflows and outflows across the",
|
|
859
|
+
"market. With a ticker it is that stock's institutional ownership and the quarter's",
|
|
860
|
+
"notable position changes, since flows are only published market-wide.",
|
|
861
|
+
"13F filings land up to 45 days after quarter end, so a still-open quarter shows only",
|
|
862
|
+
"early filers and says so, and a ticker reads the newest quarter that has closed.",
|
|
863
|
+
EMPTY_VERIFY_NOTE,
|
|
864
|
+
EMPTY_VERIFY_NOTE_2
|
|
865
|
+
],
|
|
866
|
+
flags: {
|
|
867
|
+
limit: { type: "number", placeholder: "N", describe: `Rows per side (default ${DEFAULT_ROWS})` },
|
|
868
|
+
quarter: { type: "string", placeholder: "YYYY-MM-DD", describe: "Report date to read" }
|
|
869
|
+
},
|
|
870
|
+
async run({ args, client, full }) {
|
|
871
|
+
const api = client();
|
|
872
|
+
const ticker = optionalTicker(args, "flows");
|
|
873
|
+
const notes = [];
|
|
874
|
+
const limit = typeof args.flags.limit === "number" ? args.flags.limit : DEFAULT_ROWS;
|
|
875
|
+
const quarter = typeof args.flags.quarter === "string" ? args.flags.quarter : void 0;
|
|
876
|
+
if (ticker) {
|
|
877
|
+
let reportDate = quarter;
|
|
878
|
+
if (!reportDate) {
|
|
879
|
+
reportDate = latestSettledQuarter(await api.institutional.getQuarters());
|
|
880
|
+
}
|
|
881
|
+
if (!reportDate) {
|
|
882
|
+
return {
|
|
883
|
+
json: { quarters: [] },
|
|
884
|
+
doc: doc({ kind: "text", text: "No reporting quarters are available yet." })
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
const envelope2 = await api.institutional.getHolders(ticker, reportDate, {
|
|
888
|
+
limit: full ? Math.max(limit, 50) : limit,
|
|
889
|
+
sortBy: "valueUsd",
|
|
890
|
+
sortDir: "desc"
|
|
891
|
+
});
|
|
892
|
+
const data2 = envelope2.data;
|
|
893
|
+
const holders = data2?.holders ?? [];
|
|
894
|
+
if (holders.length === 0) {
|
|
895
|
+
const note = await verifyTickerOnEmpty(api, ticker);
|
|
896
|
+
if (note) notes.push(note);
|
|
897
|
+
}
|
|
898
|
+
const blocks2 = [
|
|
899
|
+
{
|
|
900
|
+
kind: "head",
|
|
901
|
+
title: field("ticker", ticker),
|
|
902
|
+
subtitle: data2?.companyName ? field("name", data2.companyName) : void 0,
|
|
903
|
+
right: fields(
|
|
904
|
+
field("quarter", data2?.reportDate ?? reportDate),
|
|
905
|
+
field("holders", String(data2?.holderCount ?? holders.length)),
|
|
906
|
+
field("value", humanize(data2?.totalInstitutionalValue))
|
|
907
|
+
)
|
|
908
|
+
}
|
|
909
|
+
];
|
|
910
|
+
if (holders.length === 0) {
|
|
911
|
+
blocks2.push({ kind: "text", text: "No institutional holders on file for this quarter." });
|
|
912
|
+
} else {
|
|
913
|
+
blocks2.push({
|
|
914
|
+
kind: "table",
|
|
915
|
+
head: ["FILER", "CATEGORY", "SHARES", "VALUE", "CHANGE", "CHANGE%"],
|
|
916
|
+
align: ["left", "left", "right", "right", "left", "right"],
|
|
917
|
+
rows: holders.map((holder) => [
|
|
918
|
+
cell(truncate(holder.filerName, full ? 44 : 28)),
|
|
919
|
+
cell(holder.filerCategory ?? ""),
|
|
920
|
+
cell(humanize(holder.shares, 1)),
|
|
921
|
+
cell(humanize(holder.valueUsd)),
|
|
922
|
+
cell(
|
|
923
|
+
holder.changeType,
|
|
924
|
+
holder.changeType === "NEW" || holder.changeType === "INCREASED" ? "up" : holder.changeType === "SOLD_OUT" || holder.changeType === "DECREASED" ? "down" : void 0
|
|
925
|
+
),
|
|
926
|
+
cell(percent(holder.sharesChangePct, 1))
|
|
927
|
+
])
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
const notable = data2?.notableChanges;
|
|
931
|
+
if (full && notable && notable.top.length > 0) {
|
|
932
|
+
blocks2.push({ kind: "blank" });
|
|
933
|
+
blocks2.push({
|
|
934
|
+
kind: "text",
|
|
935
|
+
text: `${notable.count} holders changed position materially this quarter. Top movers:`
|
|
936
|
+
});
|
|
937
|
+
blocks2.push({
|
|
938
|
+
kind: "table",
|
|
939
|
+
head: ["FILER", "CHANGE", "SHARES", "CHANGE%"],
|
|
940
|
+
align: ["left", "left", "right", "right"],
|
|
941
|
+
rows: notable.top.map((holder) => [
|
|
942
|
+
cell(truncate(holder.filerName, 44)),
|
|
943
|
+
cell(holder.changeType),
|
|
944
|
+
cell(humanize(holder.sharesChange, 1)),
|
|
945
|
+
cell(percent(holder.sharesChangePct, 1))
|
|
946
|
+
])
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
if (envelope2.isPreview) {
|
|
950
|
+
blocks2.push({
|
|
951
|
+
kind: "text",
|
|
952
|
+
text: "Preview response: a PRO key returns the whole holder list.",
|
|
953
|
+
tone: "dim"
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
return { json: envelope2, doc: doc(...blocks2), notes };
|
|
957
|
+
}
|
|
958
|
+
const envelope = await api.institutional.getFlows(quarter, { limit });
|
|
959
|
+
const data = envelope.data;
|
|
960
|
+
const blocks = [
|
|
961
|
+
{
|
|
962
|
+
kind: "head",
|
|
963
|
+
title: field("scope", "Institutional flows"),
|
|
964
|
+
right: fields(
|
|
965
|
+
field("quarter", data?.reportDate ?? quarter ?? "latest"),
|
|
966
|
+
data?.isPending ? field("filers", `${data.filerCount ?? "?"} of ${data.baselineFilerCount ?? "?"}`) : void 0
|
|
967
|
+
)
|
|
968
|
+
}
|
|
969
|
+
];
|
|
970
|
+
if (data?.isPending) {
|
|
971
|
+
blocks.push({
|
|
972
|
+
kind: "text",
|
|
973
|
+
text: "This quarter is still open, so only early filers are represented and the totals are partial.",
|
|
974
|
+
tone: "dim"
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
const side = (title, rows, tone) => {
|
|
978
|
+
blocks.push({ kind: "blank" });
|
|
979
|
+
blocks.push({ kind: "text", text: title });
|
|
980
|
+
blocks.push({
|
|
981
|
+
kind: "table",
|
|
982
|
+
head: ["TICKER", "COMPANY", "NET SHARES", "DOLLAR FLOW", "NEW", "SOLD OUT"],
|
|
983
|
+
align: ["left", "left", "right", "right", "right", "right"],
|
|
984
|
+
rows: rows.map((flow) => [
|
|
985
|
+
cell(flow.ticker),
|
|
986
|
+
cell(truncate(flow.companyName ?? "", full ? 40 : 26)),
|
|
987
|
+
cell(humanize(flow.netSharesChange, 1), tone),
|
|
988
|
+
cell(humanize(flow.dollarFlowUsd), tone),
|
|
989
|
+
cell(String(flow.newPositions)),
|
|
990
|
+
cell(String(flow.soldOutPositions))
|
|
991
|
+
])
|
|
992
|
+
});
|
|
993
|
+
};
|
|
994
|
+
if (data?.inflows?.length) side("Largest net buying", data.inflows, "up");
|
|
995
|
+
if (data?.outflows?.length) side("Largest net selling", data.outflows, "down");
|
|
996
|
+
if (!data?.inflows?.length && !data?.outflows?.length) {
|
|
997
|
+
blocks.push({ kind: "text", text: "No flows recorded for this quarter." });
|
|
998
|
+
}
|
|
999
|
+
if (envelope.isPreview) {
|
|
1000
|
+
blocks.push({
|
|
1001
|
+
kind: "text",
|
|
1002
|
+
text: "Preview response: a PRO key returns the full flow tables.",
|
|
1003
|
+
tone: "dim"
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
return { json: envelope, doc: doc(...blocks) };
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
// src/version.ts
|
|
1011
|
+
var VERSION = "0.43.0";
|
|
1012
|
+
|
|
1013
|
+
// src/resources/analyst.ts
|
|
1014
|
+
var Analyst = class {
|
|
1015
|
+
constructor(client) {
|
|
1016
|
+
this.client = client;
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Get the aggregate Wall Street consensus for a ticker. Returns 404 if no
|
|
1020
|
+
* coverage exists.
|
|
1021
|
+
*/
|
|
1022
|
+
async consensus(ticker) {
|
|
1023
|
+
return this.client.get(
|
|
1024
|
+
`/api/v1/analyst/${encodeURIComponent(ticker.toUpperCase())}/consensus`
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Get recent analyst upgrade/downgrade actions for a ticker, newest first.
|
|
1029
|
+
* Free users receive the 3 most recent.
|
|
1030
|
+
*/
|
|
1031
|
+
async actions(ticker, options) {
|
|
1032
|
+
return this.client.get(
|
|
1033
|
+
`/api/v1/analyst/${encodeURIComponent(ticker.toUpperCase())}/actions`,
|
|
1034
|
+
options
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Get forward EPS estimates and earnings surprise history for a ticker.
|
|
1039
|
+
* Free users receive 1 estimate (current quarter) plus the 2 most recent surprises.
|
|
1040
|
+
*/
|
|
1041
|
+
async estimates(ticker) {
|
|
1042
|
+
return this.client.get(
|
|
1043
|
+
`/api/v1/analyst/${encodeURIComponent(ticker.toUpperCase())}/estimates`
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Get market-wide recent analyst actions across all covered tickers, newest first.
|
|
1048
|
+
* Free users receive the 5 most recent.
|
|
1049
|
+
*/
|
|
1050
|
+
async marketActivity(options) {
|
|
1051
|
+
return this.client.get("/api/v1/analyst/activity", options);
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
// src/resources/calendar.ts
|
|
1056
|
+
var Calendar = class {
|
|
1057
|
+
constructor(client) {
|
|
1058
|
+
this.client = client;
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Upcoming company earnings, sorted by date.
|
|
1062
|
+
*
|
|
1063
|
+
* Key-required. A FREE key returns the current week (`isPreview: true`); a PRO
|
|
1064
|
+
* key returns the full forward window (about 30 days). Field richness is
|
|
1065
|
+
* identical across tiers: the gate is how far ahead you can see, not which
|
|
1066
|
+
* columns you get. On a preview, `totalCount` is the full-window event count.
|
|
1067
|
+
*/
|
|
1068
|
+
async getEarnings(options) {
|
|
1069
|
+
return this.client.get("/api/v1/calendar/earnings", options);
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
// src/resources/documents.ts
|
|
1074
|
+
var Documents = class {
|
|
1075
|
+
constructor(client) {
|
|
1076
|
+
this.client = client;
|
|
1077
|
+
}
|
|
1078
|
+
/** Get document metrics for a stock. The rows are in `documents`. */
|
|
1079
|
+
async getByTicker(ticker, options) {
|
|
1080
|
+
return this.client.get(`/api/v1/documents/ticker/${encodeURIComponent(ticker)}`, options);
|
|
1081
|
+
}
|
|
1082
|
+
/** Get document metrics for a stock within a date range. The rows are in `documents`. */
|
|
1083
|
+
async getByTickerRange(ticker, options) {
|
|
1084
|
+
return this.client.get(
|
|
1085
|
+
`/api/v1/documents/ticker/${encodeURIComponent(ticker)}/range`,
|
|
1086
|
+
options
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
/** Get document metrics for a KB entity. The rows are in `documents`. */
|
|
1090
|
+
async getByEntity(entityId, options) {
|
|
1091
|
+
return this.client.get(
|
|
1092
|
+
`/api/v1/documents/entity/${encodeURIComponent(entityId)}`,
|
|
1093
|
+
options
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
/** Smart search with natural language query parsing. The rows are in `documents`. */
|
|
1097
|
+
async search(query, options) {
|
|
1098
|
+
return this.client.get("/api/v1/documents/search", { query, ...options });
|
|
1099
|
+
}
|
|
1100
|
+
/** Get latest document metrics from a source type. The rows are in `documents`. */
|
|
1101
|
+
async getBySource(source, options) {
|
|
1102
|
+
return this.client.get(
|
|
1103
|
+
`/api/v1/documents/source/${encodeURIComponent(source)}`,
|
|
1104
|
+
options
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
/** Get AI-curated news story clusters. */
|
|
1108
|
+
async getStories(options) {
|
|
1109
|
+
return this.client.get("/api/v1/documents/stories", options);
|
|
1110
|
+
}
|
|
1111
|
+
/** Get full story detail by cluster ID. */
|
|
1112
|
+
async getStoryDetail(clusterId) {
|
|
1113
|
+
return this.client.get(`/api/v1/documents/stories/${encodeURIComponent(clusterId)}`);
|
|
1114
|
+
}
|
|
1115
|
+
/** Get stories for a specific stock. */
|
|
1116
|
+
async getStoriesByTicker(ticker, options) {
|
|
1117
|
+
return this.client.get(
|
|
1118
|
+
`/api/v1/documents/stories/ticker/${encodeURIComponent(ticker)}`,
|
|
1119
|
+
options
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
// src/resources/earnings.ts
|
|
1125
|
+
var Earnings = class {
|
|
1126
|
+
constructor(client) {
|
|
1127
|
+
this.client = client;
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Per-quarter earnings analysis report for one ticker, newest first.
|
|
1131
|
+
*
|
|
1132
|
+
* Each quarter carries the editorial headline, the KPI cards that matter for
|
|
1133
|
+
* that company with year-over-year deltas, the guidance language as
|
|
1134
|
+
* management phrased it, and a summary of the earnings call.
|
|
1135
|
+
*
|
|
1136
|
+
* Branch on `isPreview`: a PRO key receives every hydrated quarter in full, a
|
|
1137
|
+
* FREE key receives the latest quarter shaped rather than truncated, plus
|
|
1138
|
+
* `totalCount`. {@link EarningsQuarter} documents which fields each tier
|
|
1139
|
+
* carries.
|
|
1140
|
+
*
|
|
1141
|
+
* A quarter typically appears within 48 hours of the company reporting, and
|
|
1142
|
+
* the call summary can arrive after the press-release content for the same
|
|
1143
|
+
* quarter, so read `generatedAt` and `transcriptGeneratedAt` rather than
|
|
1144
|
+
* assuming a fixed lag. A ticker with no stored quarter answers with an empty
|
|
1145
|
+
* `data` array, not a 404.
|
|
1146
|
+
*
|
|
1147
|
+
* Use canonical ticker symbols: `GOOGL` (not `GOOG`), `BRK.B` (not `BRK-B`).
|
|
1148
|
+
*/
|
|
1149
|
+
async getSummaries(ticker, options) {
|
|
1150
|
+
return this.client.get(
|
|
1151
|
+
`/api/v1/stocks/${encodeURIComponent(ticker.toUpperCase())}/earnings-summaries`,
|
|
1152
|
+
options
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Which covered companies reported on or after `today - days`, newest first.
|
|
1157
|
+
*
|
|
1158
|
+
* Every API key receives the full window it asks for, so `isPreview` is
|
|
1159
|
+
* always `false` here. The window is bounded by `reportDate`, so a quarter
|
|
1160
|
+
* reported inside it appears even when its call summary lands later, and an
|
|
1161
|
+
* empty `data` array means nobody in the covered set reported in that window.
|
|
1162
|
+
*
|
|
1163
|
+
* This is the backward-looking feed; `client.calendar.getEarnings()` is the
|
|
1164
|
+
* forward-looking one.
|
|
1165
|
+
*/
|
|
1166
|
+
async getRecent(options) {
|
|
1167
|
+
return this.client.get("/api/v1/earnings/recent", options);
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
|
|
1171
|
+
// src/resources/entityMetrics.ts
|
|
1172
|
+
var EntityMetrics = class {
|
|
1173
|
+
constructor(client) {
|
|
1174
|
+
this.client = client;
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Get time-series metric data for an entity using the v2 Serving Metrics API.
|
|
1178
|
+
*
|
|
1179
|
+
* @param symbol Ticker symbol (e.g. "AAPL") or entity urlSlug (e.g. "Nancy-Pelosi",
|
|
1180
|
+
* case-insensitive; discover slugs via stocks.getEntities()).
|
|
1181
|
+
* @param options Metric type and optional time range / resolution.
|
|
1182
|
+
*/
|
|
1183
|
+
async getMetrics(symbol, options = {}) {
|
|
1184
|
+
const { metricType = "sentiment", startTime, endTime, maxDataPoints } = options;
|
|
1185
|
+
return this.client.get(
|
|
1186
|
+
`/api/v2/metrics/entity/${encodeURIComponent(symbol)}/metric/${encodeURIComponent(metricType)}`,
|
|
1187
|
+
{
|
|
1188
|
+
...startTime !== void 0 && { startTime },
|
|
1189
|
+
...endTime !== void 0 && { endTime },
|
|
1190
|
+
...maxDataPoints !== void 0 && { maxDataPoints }
|
|
1191
|
+
}
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Get distribution data for a metric, broken down by a dimension (default: source).
|
|
1196
|
+
*
|
|
1197
|
+
* @param symbol Ticker symbol (e.g. "AAPL") or entity urlSlug.
|
|
1198
|
+
* @param metricType The metric to break down (e.g. "mentions", "sentiment").
|
|
1199
|
+
* @param options Optional dimension parameter.
|
|
1200
|
+
*/
|
|
1201
|
+
async getDistribution(symbol, metricType, options = {}) {
|
|
1202
|
+
const { dimension = "source" } = options;
|
|
1203
|
+
return this.client.get(
|
|
1204
|
+
`/api/v2/metrics/entity/${encodeURIComponent(symbol)}/distribution/${encodeURIComponent(metricType)}`,
|
|
1205
|
+
{ dimension }
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
};
|
|
1209
|
+
|
|
1210
|
+
// src/resources/etfs.ts
|
|
1211
|
+
var Etfs = class {
|
|
1212
|
+
constructor(client) {
|
|
1213
|
+
this.client = client;
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* List every ETF tracked by SentiSense, sorted by ticker.
|
|
1217
|
+
*/
|
|
1218
|
+
async list() {
|
|
1219
|
+
return this.client.get("/api/v1/etfs");
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Get the full holdings composition for an ETF, including per-holding weights
|
|
1223
|
+
* and freshness metadata. Returns 404 for unknown ETFs or commodity-only funds.
|
|
1224
|
+
*/
|
|
1225
|
+
async holdings(ticker) {
|
|
1226
|
+
return this.client.get(
|
|
1227
|
+
`/api/v1/etfs/${encodeURIComponent(ticker.toUpperCase())}/holdings`
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Get the holdings-weighted analyst consensus for an ETF, including the
|
|
1232
|
+
* top per-holding contributors that drive the weighted upside.
|
|
1233
|
+
*/
|
|
1234
|
+
async analystAggregate(ticker) {
|
|
1235
|
+
return this.client.get(
|
|
1236
|
+
`/api/v1/etfs/${encodeURIComponent(ticker.toUpperCase())}/aggregates/analyst`
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Get the holdings-weighted SEC Form 4 insider aggregate for an ETF over a
|
|
1241
|
+
* configurable trailing window, including per-holding `topContributors` with
|
|
1242
|
+
* signed contribution to the weighted headline.
|
|
1243
|
+
*/
|
|
1244
|
+
async insiderAggregate(ticker, options) {
|
|
1245
|
+
return this.client.get(
|
|
1246
|
+
`/api/v1/etfs/${encodeURIComponent(ticker.toUpperCase())}/aggregates/insider`,
|
|
1247
|
+
options
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Get two SentiSense Score readings side-by-side: `constituentsWeighted`
|
|
1252
|
+
* (precomputed daily weighted average across the fund's holdings) and `direct`
|
|
1253
|
+
* (score from mentions of the fund's own ticker). The two can diverge, and the
|
|
1254
|
+
* gap is itself information.
|
|
1255
|
+
*/
|
|
1256
|
+
async sentimentAggregate(ticker) {
|
|
1257
|
+
return this.client.get(
|
|
1258
|
+
`/api/v1/etfs/${encodeURIComponent(ticker.toUpperCase())}/aggregates/sentiment`
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
// src/resources/insider.ts
|
|
1264
|
+
var Insider = class {
|
|
1265
|
+
constructor(client) {
|
|
1266
|
+
this.client = client;
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* Get market-wide insider activity: top buys and sells aggregated by ticker.
|
|
1270
|
+
*
|
|
1271
|
+
* PRO-gated. Free-tier users receive a preview (top 5 per direction)
|
|
1272
|
+
* with `isPreview: true` in the response.
|
|
1273
|
+
*/
|
|
1274
|
+
async getActivity(options) {
|
|
1275
|
+
return this.client.get("/api/v1/insider/activity", options);
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Get individual insider transactions for a specific stock.
|
|
1279
|
+
*
|
|
1280
|
+
* PRO-gated. Free users receive a preview of the top 5 transactions.
|
|
1281
|
+
*/
|
|
1282
|
+
async getTrades(ticker, options) {
|
|
1283
|
+
return this.client.get(
|
|
1284
|
+
`/api/v1/insider/trades/${encodeURIComponent(ticker.toUpperCase())}`,
|
|
1285
|
+
options
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Get cluster buy signals: stocks where 3+ distinct insiders bought recently.
|
|
1290
|
+
*
|
|
1291
|
+
* PRO-gated. Free users receive a preview of the top 3 signals.
|
|
1292
|
+
*/
|
|
1293
|
+
async getClusterBuys(options) {
|
|
1294
|
+
return this.client.get("/api/v1/insider/cluster-buys", options);
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1297
|
+
|
|
1298
|
+
// src/resources/politicians.ts
|
|
1299
|
+
var Politicians = class {
|
|
1300
|
+
constructor(client) {
|
|
1301
|
+
this.client = client;
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Get recent congressional STOCK Act trading activity across all politicians.
|
|
1305
|
+
*
|
|
1306
|
+
* PRO-gated. Free-tier users receive a preview (top 5 trades)
|
|
1307
|
+
* with `isPreview: true` in the response.
|
|
1308
|
+
*
|
|
1309
|
+
* The feed is longer than one response: a default 90-day window is routinely well over a
|
|
1310
|
+
* thousand disclosures, and without `limit` the server sends the first 200 with no marker
|
|
1311
|
+
* that it stopped. `totalCount` on the envelope is the real size on every tier, so page
|
|
1312
|
+
* with `limit` and `offset` rather than reading `data.length` as the total.
|
|
1313
|
+
*
|
|
1314
|
+
* ```typescript
|
|
1315
|
+
* const first = await client.politicians.getActivity({ limit: 100 });
|
|
1316
|
+
* for (let offset = 100; offset < first.totalCount!; offset += 100) {
|
|
1317
|
+
* const page = await client.politicians.getActivity({ limit: 100, offset });
|
|
1318
|
+
* // ... page.data
|
|
1319
|
+
* }
|
|
1320
|
+
* ```
|
|
1321
|
+
*/
|
|
1322
|
+
async getActivity(options) {
|
|
1323
|
+
return this.client.get("/api/v1/politicians/activity", options);
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Get congressional trades for a specific stock.
|
|
1327
|
+
*
|
|
1328
|
+
* PRO-gated. Free users receive a preview of the top 3 trades.
|
|
1329
|
+
*/
|
|
1330
|
+
async getFilings(ticker, options) {
|
|
1331
|
+
return this.client.get(
|
|
1332
|
+
`/api/v1/politicians/filings/${encodeURIComponent(ticker.toUpperCase())}`,
|
|
1333
|
+
options
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Discover tracked members of Congress and the page slug identifying each, so you
|
|
1338
|
+
* can find who to query without knowing slugs upfront.
|
|
1339
|
+
*
|
|
1340
|
+
* Summary only, no trade data; use `getMember` for a member's filings.
|
|
1341
|
+
*
|
|
1342
|
+
* Unlike `getMembers`, this includes members who have **left Congress**, carrying
|
|
1343
|
+
* `former` and `servedUntil`. That roster lists who currently holds office, so a
|
|
1344
|
+
* former member is otherwise reachable only if you already know their slug.
|
|
1345
|
+
*
|
|
1346
|
+
* Requires an API key but does not consume monthly quota (per-minute rate limits
|
|
1347
|
+
* still apply), and is not tier-gated. Returns the unwrapped list payload.
|
|
1348
|
+
*/
|
|
1349
|
+
async getDirectory(options) {
|
|
1350
|
+
const resp = await this.client.get(
|
|
1351
|
+
"/api/v1/politicians/directory",
|
|
1352
|
+
{ ...options }
|
|
1353
|
+
);
|
|
1354
|
+
return resp.data;
|
|
1355
|
+
}
|
|
1356
|
+
/**
|
|
1357
|
+
* Get all tracked politicians with trading summary statistics.
|
|
1358
|
+
*
|
|
1359
|
+
* PRO-gated. Free users receive a preview of the top 5 members.
|
|
1360
|
+
*
|
|
1361
|
+
* Serves only members currently in office. Use `getDirectory` to enumerate
|
|
1362
|
+
* everyone tracked, former members included.
|
|
1363
|
+
*/
|
|
1364
|
+
async getMembers() {
|
|
1365
|
+
return this.client.get("/api/v1/politicians/members");
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Get detailed profile for a single politician: summary, recent trades, top tickers.
|
|
1369
|
+
*
|
|
1370
|
+
* PRO-gated. Free users receive a preview-wrapped response.
|
|
1371
|
+
*
|
|
1372
|
+
* `data.recentTrades` is one page of the member's history, not all of it. Most members
|
|
1373
|
+
* have a few dozen disclosures and arrive complete in the default page; a handful have
|
|
1374
|
+
* thousands. `totalCount` on the envelope is the size of the whole history on every
|
|
1375
|
+
* tier, so page with `limit` and `offset` rather than reading `recentTrades.length` as
|
|
1376
|
+
* the total. `data.profile` and `data.topTickers` describe the whole history whatever
|
|
1377
|
+
* page you ask for, so `profile.totalTrades` does not shrink with a small `limit`.
|
|
1378
|
+
*
|
|
1379
|
+
* ```typescript
|
|
1380
|
+
* const first = await client.politicians.getMember("Ro-Khanna", { limit: 500 });
|
|
1381
|
+
* for (let offset = 500; offset < first.totalCount!; offset += 500) {
|
|
1382
|
+
* const page = await client.politicians.getMember("Ro-Khanna", { limit: 500, offset });
|
|
1383
|
+
* // ... page.data.recentTrades
|
|
1384
|
+
* }
|
|
1385
|
+
* ```
|
|
1386
|
+
*/
|
|
1387
|
+
async getMember(slug, options) {
|
|
1388
|
+
return this.client.get(
|
|
1389
|
+
`/api/v1/politicians/member/${encodeURIComponent(slug)}`,
|
|
1390
|
+
options
|
|
1391
|
+
);
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
// src/resources/insights.ts
|
|
1396
|
+
var Insights = class {
|
|
1397
|
+
constructor(client) {
|
|
1398
|
+
this.client = client;
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* Get AI-generated insights for a specific stock, sorted by urgency then confidence.
|
|
1402
|
+
*
|
|
1403
|
+
* Returns the preview envelope: read the insights as `.data`. PRO callers get the
|
|
1404
|
+
* full list with `isPreview: false`; free callers get the top 3 with `isPreview: true`
|
|
1405
|
+
* and `totalCount` carrying the untruncated size.
|
|
1406
|
+
*/
|
|
1407
|
+
async stock(ticker, options) {
|
|
1408
|
+
return this.client.get(
|
|
1409
|
+
`/api/v1/insights/stock/${encodeURIComponent(ticker.toUpperCase())}`,
|
|
1410
|
+
options
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Get AI insights for a stock within a date range.
|
|
1415
|
+
*
|
|
1416
|
+
* Returns the preview envelope: read the insights as `.data`. Free callers receive
|
|
1417
|
+
* the top 3, PRO callers the full list. The server returns 400 if `startDate` is
|
|
1418
|
+
* after `endDate`.
|
|
1419
|
+
*/
|
|
1420
|
+
async stockRange(ticker, options) {
|
|
1421
|
+
return this.client.get(
|
|
1422
|
+
`/api/v1/insights/stock/${encodeURIComponent(ticker.toUpperCase())}/range`,
|
|
1423
|
+
options
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Get AI-generated market-level insights, sorted by urgency then confidence.
|
|
1428
|
+
*
|
|
1429
|
+
* Returns the preview envelope: read the insights as `.data`. PRO callers get the
|
|
1430
|
+
* full list with `isPreview: false`; free callers get the top 5 with `isPreview: true`
|
|
1431
|
+
* and `totalCount` carrying the untruncated size.
|
|
1432
|
+
*/
|
|
1433
|
+
async market() {
|
|
1434
|
+
return this.client.get("/api/v1/insights/market");
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Get the latest AI insights across all tracked stocks, newest first.
|
|
1438
|
+
*
|
|
1439
|
+
* Returns the preview envelope: read the insights as `.data`. Free callers receive
|
|
1440
|
+
* the top 5, PRO callers up to `limit` (clamped to 1-200).
|
|
1441
|
+
*/
|
|
1442
|
+
async latest(options) {
|
|
1443
|
+
return this.client.get("/api/v1/insights/latest", options);
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Get personalized insights for the authenticated user.
|
|
1447
|
+
*
|
|
1448
|
+
* Biased toward the user's watchlist and portfolio when available; falls back
|
|
1449
|
+
* to market-level insights otherwise. API key authentication required.
|
|
1450
|
+
* Returns the preview envelope: read the insights as `.data`.
|
|
1451
|
+
*/
|
|
1452
|
+
async user(options) {
|
|
1453
|
+
return this.client.get("/api/v1/insights/user", options);
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Get available insight types for a specific stock.
|
|
1457
|
+
* API key required.
|
|
1458
|
+
*
|
|
1459
|
+
* Returns an array of insight type strings (e.g., `["sentiment_shift", "options_activity"]`).
|
|
1460
|
+
*/
|
|
1461
|
+
async types(ticker) {
|
|
1462
|
+
return this.client.get(
|
|
1463
|
+
`/api/v1/insights/stock/${encodeURIComponent(ticker.toUpperCase())}/types`
|
|
1464
|
+
);
|
|
1465
|
+
}
|
|
1466
|
+
};
|
|
1467
|
+
|
|
1468
|
+
// src/resources/institutional.ts
|
|
1469
|
+
var Institutional = class {
|
|
1470
|
+
constructor(client) {
|
|
1471
|
+
this.client = client;
|
|
1472
|
+
}
|
|
1473
|
+
/** Get available 13F reporting quarters. */
|
|
1474
|
+
async getQuarters() {
|
|
1475
|
+
return this.client.get("/api/v1/institutional/quarters");
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Get aggregate institutional activity per ticker for a quarter.
|
|
1479
|
+
*
|
|
1480
|
+
* `reportDate` is optional: omit it to get the latest available quarter, which may be
|
|
1481
|
+
* a still-open one holding only early filers. The response then carries `reportDate`
|
|
1482
|
+
* plus `isPending` and filer coverage counts so a partial quarter is clearly labeled.
|
|
1483
|
+
*
|
|
1484
|
+
* Returns the preview envelope, so the flows are one level down:
|
|
1485
|
+
* `const { data } = await client.institutional.getFlows(); data.inflows`.
|
|
1486
|
+
*/
|
|
1487
|
+
async getFlows(reportDate, options) {
|
|
1488
|
+
return this.client.get("/api/v1/institutional/flows", {
|
|
1489
|
+
reportDate,
|
|
1490
|
+
...options
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Get institutional holders for a specific stock.
|
|
1495
|
+
*
|
|
1496
|
+
* Returns the preview envelope wrapping a {@link TickerHolders} object, so the rows
|
|
1497
|
+
* are two levels down: `(await getHolders(t, d)).data.holders`, alongside ticker-level
|
|
1498
|
+
* totals like `holderCount`. Free callers get a truncated `holders` array with
|
|
1499
|
+
* `isPreview: true`.
|
|
1500
|
+
*
|
|
1501
|
+
* A widely held ticker returns thousands of rows: a megacap quarter is about
|
|
1502
|
+
* 6,000 holders and 1.5 MB. Pass `limit` unless you really want all of them.
|
|
1503
|
+
* Omitting `options` sends the original unbounded request.
|
|
1504
|
+
*
|
|
1505
|
+
* `limit` is the switch for the whole option set. With it, the response also carries
|
|
1506
|
+
* `returnedCount`, `offset`, and a `notableChanges` summary, so you can walk the list
|
|
1507
|
+
* without re-counting it. Without it, `offset` / `sortBy` / `sortDir` are ignored by the
|
|
1508
|
+
* server and you get the full unsorted list back with a 200.
|
|
1509
|
+
*/
|
|
1510
|
+
async getHolders(ticker, reportDate, options) {
|
|
1511
|
+
return this.client.get(
|
|
1512
|
+
`/api/v1/institutional/holders/${encodeURIComponent(ticker)}`,
|
|
1513
|
+
{ reportDate, ...options }
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Get activist investor positions (NEW or INCREASED).
|
|
1518
|
+
*
|
|
1519
|
+
* Returns the preview envelope, so read the rows as `.data`.
|
|
1520
|
+
*/
|
|
1521
|
+
async getActivists(reportDate) {
|
|
1522
|
+
return this.client.get("/api/v1/institutional/activist", { reportDate });
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Discover institutions: a paginated, AUM-ranked list of filers (slug + metadata)
|
|
1526
|
+
* so you can find what to query without knowing slugs upfront.
|
|
1527
|
+
*
|
|
1528
|
+
* Each institution is rolled up by parent filer, so a multi-filer manager
|
|
1529
|
+
* (e.g. Vanguard) appears once with combined AUM. Summary only; use
|
|
1530
|
+
* `getInstitutionDetail` for a filer's full holdings.
|
|
1531
|
+
*
|
|
1532
|
+
* Requires an API key but does not consume monthly quota (per-minute rate
|
|
1533
|
+
* limits still apply). Returns the unwrapped list payload.
|
|
1534
|
+
*/
|
|
1535
|
+
async listInstitutions(options) {
|
|
1536
|
+
const resp = await this.client.get(
|
|
1537
|
+
"/api/v1/institutional/institutions",
|
|
1538
|
+
{ ...options }
|
|
1539
|
+
);
|
|
1540
|
+
return resp.data;
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Get the full profile, summary stats, and current-quarter holdings for a
|
|
1544
|
+
* specific institutional filer.
|
|
1545
|
+
*
|
|
1546
|
+
* Resolved by URL slug (e.g. `Berkshire-Hathaway`) or numeric SEC CIK.
|
|
1547
|
+
* Free users receive the profile and top 10 holdings; PRO users receive the
|
|
1548
|
+
* full holdings array. Returns 404 if the slug or CIK is unknown.
|
|
1549
|
+
*/
|
|
1550
|
+
async getInstitutionDetail(slugOrCik) {
|
|
1551
|
+
return this.client.get(
|
|
1552
|
+
`/api/v1/institutional/institution/${encodeURIComponent(slugOrCik)}`
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1555
|
+
};
|
|
1556
|
+
|
|
1557
|
+
// src/resources/kb.ts
|
|
1558
|
+
var KB = class {
|
|
1559
|
+
constructor(client) {
|
|
1560
|
+
this.client = client;
|
|
1561
|
+
}
|
|
1562
|
+
/** Get popular entities for search suggestions. */
|
|
1563
|
+
async getPopularEntities() {
|
|
1564
|
+
return this.client.get("/api/v1/kb/entities/popular");
|
|
1565
|
+
}
|
|
1566
|
+
};
|
|
1567
|
+
|
|
1568
|
+
// src/resources/marketMood.ts
|
|
1569
|
+
var MarketMoodResource = class {
|
|
1570
|
+
constructor(client) {
|
|
1571
|
+
this.client = client;
|
|
1572
|
+
}
|
|
1573
|
+
/** Get market mood data (scores, history, sectors). */
|
|
1574
|
+
async get() {
|
|
1575
|
+
return this.client.get("/api/v2/market-mood");
|
|
1576
|
+
}
|
|
1577
|
+
// TODO: accept a `days` param to control history length (the endpoint supports ?days=N).
|
|
1578
|
+
//
|
|
1579
|
+
// Market Mood is also reachable through `client.indexes`, which serves it in the shared
|
|
1580
|
+
// index envelope alongside fed-sentiment and ai-sentiment. Use this resource when you want
|
|
1581
|
+
// the phase band, weekly change, per-signal breakdown and per-sector map; use `indexes`
|
|
1582
|
+
// when you want every index to answer the same shape. Both report the same headline number.
|
|
1583
|
+
};
|
|
1584
|
+
|
|
1585
|
+
// src/resources/marketSummary.ts
|
|
1586
|
+
var MarketSummaryResource = class {
|
|
1587
|
+
constructor(client) {
|
|
1588
|
+
this.client = client;
|
|
1589
|
+
}
|
|
1590
|
+
/** Get the AI-generated market summary with headline and analysis. */
|
|
1591
|
+
async get() {
|
|
1592
|
+
return this.client.get("/api/v1/market-summary");
|
|
1593
|
+
}
|
|
1594
|
+
};
|
|
1595
|
+
|
|
1596
|
+
// src/resources/screener.ts
|
|
1597
|
+
var Screener = class {
|
|
1598
|
+
constructor(client) {
|
|
1599
|
+
this.client = client;
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Every filterable field, with its unit, operators and description, for both
|
|
1603
|
+
* universes.
|
|
1604
|
+
*
|
|
1605
|
+
* Build a filter UI from this rather than hardcoding the list and you inherit
|
|
1606
|
+
* new fields as they ship. The ETF `STRING` fields (`ISSUER`, `ASSET_CLASS`,
|
|
1607
|
+
* `TRACKED_INDEX`) come back with their `values` populated from the live
|
|
1608
|
+
* universe, so pickers stay current without a redeploy.
|
|
1609
|
+
*/
|
|
1610
|
+
async fields() {
|
|
1611
|
+
return this.client.get("/api/v1/screener/fields");
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* The curated screens shipped in the product, each with a runnable plan.
|
|
1615
|
+
*
|
|
1616
|
+
* Each `plan` round-trips straight into {@link run} (or {@link runEtfs} when
|
|
1617
|
+
* `plan.universe === "ETF"`) with nothing to rebuild.
|
|
1618
|
+
*
|
|
1619
|
+
* Their filters identify the field with `field` rather than `fieldName`.
|
|
1620
|
+
* Both keys are accepted on the way in, so read either when inspecting a plan
|
|
1621
|
+
* you did not build yourself.
|
|
1622
|
+
*/
|
|
1623
|
+
async screens() {
|
|
1624
|
+
return this.client.get("/api/v1/screener/screens");
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* Run a screen against the stock universe.
|
|
1628
|
+
*
|
|
1629
|
+
* `tickers` is optional: omit it to screen the whole tracked universe, pass a
|
|
1630
|
+
* list to screen a watchlist. `limit` sits next to the plan rather than
|
|
1631
|
+
* inside it, because a plan is a stored object and paging is a transport
|
|
1632
|
+
* concern; it defaults to 100 and caps at 500.
|
|
1633
|
+
*
|
|
1634
|
+
* Read `matched` before you read `results`: it is the count before `limit`
|
|
1635
|
+
* was applied, so a `matched` above your `limit` means you are holding the
|
|
1636
|
+
* top slice under the plan's sort, not the whole answer.
|
|
1637
|
+
*
|
|
1638
|
+
* @example
|
|
1639
|
+
* ```ts
|
|
1640
|
+
* const res = await client.screener.run({
|
|
1641
|
+
* plan: {
|
|
1642
|
+
* filters: [
|
|
1643
|
+
* { fieldName: "SENTI_SCORE_7D", op: "GTE", value: 13 },
|
|
1644
|
+
* { fieldName: "ANALYST_BUY_RATIO_PCT", op: "LTE", value: 30 },
|
|
1645
|
+
* { fieldName: "ANALYST_COUNT", op: "GTE", value: 5 },
|
|
1646
|
+
* ],
|
|
1647
|
+
* sort: { fieldName: "SENTI_SCORE_7D", dir: "DESC" },
|
|
1648
|
+
* },
|
|
1649
|
+
* limit: 25,
|
|
1650
|
+
* });
|
|
1651
|
+
* ```
|
|
1652
|
+
*/
|
|
1653
|
+
async run(options) {
|
|
1654
|
+
return this.client.post("/api/v1/screener/execute", options);
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* Run a screen against the ETF universe.
|
|
1658
|
+
*
|
|
1659
|
+
* Same request shape as {@link run}, against a different field vocabulary:
|
|
1660
|
+
* take the ETF names from `fields().etf`. `IN` / `NOT_IN` take a `values`
|
|
1661
|
+
* array instead of `value` and are the operators for the string fields
|
|
1662
|
+
* (`ISSUER`, `ASSET_CLASS`, `TRACKED_INDEX`).
|
|
1663
|
+
*
|
|
1664
|
+
* `CONSTITUENTS_WEIGHTED_SENTISENSE` is the holdings-weighted SentiSense
|
|
1665
|
+
* Score across what the fund owns and is usually the one you want;
|
|
1666
|
+
* `DIRECT_SENTISENSE` is the Score from chatter about the fund ticker itself,
|
|
1667
|
+
* which on a broad index fund is mostly macro noise. `WEIGHT_COVERED_PCT`
|
|
1668
|
+
* tells you how much of the fund's weight had constituent data behind the
|
|
1669
|
+
* weighted number.
|
|
1670
|
+
*/
|
|
1671
|
+
async runEtfs(options) {
|
|
1672
|
+
return this.client.post("/api/v1/screener/etfs/execute", options);
|
|
1673
|
+
}
|
|
1674
|
+
};
|
|
1675
|
+
|
|
1676
|
+
// src/resources/stocks.ts
|
|
1677
|
+
var Stocks = class {
|
|
1678
|
+
constructor(client) {
|
|
1679
|
+
this.client = client;
|
|
1680
|
+
}
|
|
1681
|
+
/** List all available ticker symbols. */
|
|
1682
|
+
async list() {
|
|
1683
|
+
return this.client.get("/api/v1/stocks");
|
|
1684
|
+
}
|
|
1685
|
+
/** List all stocks with name, kbEntityId, urlSlug. */
|
|
1686
|
+
async listDetailed() {
|
|
1687
|
+
return this.client.get("/api/v1/stocks/detailed");
|
|
1688
|
+
}
|
|
1689
|
+
/** Get popular ticker symbols. */
|
|
1690
|
+
async listPopular() {
|
|
1691
|
+
return this.client.get("/api/v1/stocks/popular");
|
|
1692
|
+
}
|
|
1693
|
+
/** Get popular stocks with details. */
|
|
1694
|
+
async listPopularDetailed() {
|
|
1695
|
+
return this.client.get("/api/v1/stocks/popular/detailed");
|
|
1696
|
+
}
|
|
1697
|
+
/** Get real-time price for a single ticker. */
|
|
1698
|
+
async getPrice(ticker) {
|
|
1699
|
+
return this.client.get("/api/v1/stocks/price", { ticker });
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Get aggregate quote snapshot: live price, today OHLC, 52-week range,
|
|
1703
|
+
* market cap, P/E, EPS TTM, and dividend yield in a single call.
|
|
1704
|
+
* All fields except `ticker` may be null when upstream data is unavailable.
|
|
1705
|
+
*/
|
|
1706
|
+
async getQuote(ticker) {
|
|
1707
|
+
return this.client.get(`/api/v1/stocks/${encodeURIComponent(ticker)}/quote`);
|
|
1708
|
+
}
|
|
1709
|
+
/** Get real-time prices for multiple tickers. */
|
|
1710
|
+
async getPrices(tickers) {
|
|
1711
|
+
return this.client.get("/api/v1/stocks/prices", {
|
|
1712
|
+
tickers: tickers.join(",")
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
/** Get batch company logo URLs. */
|
|
1716
|
+
async getImages(tickers, options) {
|
|
1717
|
+
return this.client.get("/api/v1/stocks/images", {
|
|
1718
|
+
tickers: tickers.join(","),
|
|
1719
|
+
...options
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
/** Get company profiles with branding, market cap, sector. */
|
|
1723
|
+
async getDescriptions(tickers, options) {
|
|
1724
|
+
return this.client.get("/api/v1/stocks/descriptions", {
|
|
1725
|
+
tickers: tickers.join(","),
|
|
1726
|
+
...options
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
/** Get peer/similar stocks. */
|
|
1730
|
+
async getSimilar(ticker, options) {
|
|
1731
|
+
return this.client.get(`/api/v1/stocks/${encodeURIComponent(ticker)}/similar`, options);
|
|
1732
|
+
}
|
|
1733
|
+
/** Get company profile (CEO, sector, industry, market data). */
|
|
1734
|
+
async getProfile(ticker, options) {
|
|
1735
|
+
return this.client.get(`/api/v1/stocks/${encodeURIComponent(ticker)}/profile`, options);
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* Get the headline sentiment picture for a stock in one call.
|
|
1739
|
+
*
|
|
1740
|
+
* Returns the SentiSense Score with its 30-day regime, mention volume and social
|
|
1741
|
+
* dominance, per-source tone in `bySource`, plus related tickers, story drivers, a
|
|
1742
|
+
* narrative and an FAQ. Available in full on every API-key tier.
|
|
1743
|
+
*
|
|
1744
|
+
* Use `entityMetrics.getMetrics(ticker, "sentiment", ...)` instead when you need a time
|
|
1745
|
+
* series over a specific window rather than the headline read. Returns 404 for tickers
|
|
1746
|
+
* with no sentiment coverage.
|
|
1747
|
+
*/
|
|
1748
|
+
async getSentiment(ticker) {
|
|
1749
|
+
return this.client.get(
|
|
1750
|
+
`/api/v1/stocks/${encodeURIComponent(ticker)}/sentiment`
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1753
|
+
/** Get related KB entities (people, products, partners). */
|
|
1754
|
+
async getEntities(ticker) {
|
|
1755
|
+
return this.client.get(`/api/v1/stocks/${encodeURIComponent(ticker)}/entities`);
|
|
1756
|
+
}
|
|
1757
|
+
/**
|
|
1758
|
+
* Get AI-generated stock analysis report. Requires PRO tier.
|
|
1759
|
+
*
|
|
1760
|
+
* `depth: "deep"` returns the full curated report and consumes one report view on
|
|
1761
|
+
* metered tiers; the default `"basic"` returns the one-paragraph summary.
|
|
1762
|
+
*
|
|
1763
|
+
* The deprecated `forceRefresh` option is accepted and discarded, not forwarded.
|
|
1764
|
+
*/
|
|
1765
|
+
async getAISummary(ticker, options) {
|
|
1766
|
+
const { forceRefresh: _forceRefresh, ...params } = options ?? {};
|
|
1767
|
+
return this.client.get(`/api/v1/stocks/${encodeURIComponent(ticker)}/ai-summary`, params);
|
|
1768
|
+
}
|
|
1769
|
+
/** Get sentiment/mention metrics breakdown by entity. */
|
|
1770
|
+
async getMetricsBreakdown(ticker, metricType, options) {
|
|
1771
|
+
return this.client.get(
|
|
1772
|
+
`/api/v1/stocks/${encodeURIComponent(ticker)}/metrics/${encodeURIComponent(metricType)}/breakdown`,
|
|
1773
|
+
options
|
|
1774
|
+
);
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* Get historical OHLCV chart data.
|
|
1778
|
+
*
|
|
1779
|
+
* The API returns a bare array of points; this normalizes it to
|
|
1780
|
+
* `{ ticker, timeframe, data }`. `timeframe` echoes the requested value
|
|
1781
|
+
* (defaulting to "1M", matching the server default when omitted).
|
|
1782
|
+
*/
|
|
1783
|
+
async getChart(ticker, options) {
|
|
1784
|
+
const data = await this.client.get("/api/v1/stocks/chart", {
|
|
1785
|
+
ticker,
|
|
1786
|
+
...options
|
|
1787
|
+
});
|
|
1788
|
+
return { ticker, timeframe: options?.timeframe ?? "1M", data };
|
|
1789
|
+
}
|
|
1790
|
+
/** Get current market open/closed/pre-market/after-hours status. */
|
|
1791
|
+
async getMarketStatus() {
|
|
1792
|
+
return this.client.get("/api/v1/stocks/market-status");
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Get financial statement data for one reporting period: income statement, balance sheet,
|
|
1796
|
+
* and cash flow, including `capitalExpenditure` and `freeCashFlow`.
|
|
1797
|
+
*
|
|
1798
|
+
* Capital expenditure is signed as filed, so normally negative. See {@link Fundamentals}
|
|
1799
|
+
* for the free-cash-flow relationship and when it is `null`.
|
|
1800
|
+
*/
|
|
1801
|
+
async getFundamentals(ticker, options) {
|
|
1802
|
+
return this.client.get("/api/v1/stocks/fundamentals", { ticker, ...options });
|
|
1803
|
+
}
|
|
1804
|
+
/** Get available fiscal periods. The periods are in `periods`. */
|
|
1805
|
+
async getFundamentalsPeriods(ticker) {
|
|
1806
|
+
return this.client.get("/api/v1/stocks/fundamentals/periods", { ticker });
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* Get the trailing-twelve-month fundamentals snapshot: TTM ratios, a different
|
|
1810
|
+
* shape from the per-period statement data `getFundamentals()` returns.
|
|
1811
|
+
*/
|
|
1812
|
+
async getCurrentFundamentals(ticker) {
|
|
1813
|
+
return this.client.get("/api/v1/stocks/fundamentals/current", { ticker });
|
|
1814
|
+
}
|
|
1815
|
+
/** Get historical revenue data. */
|
|
1816
|
+
async getHistoricalRevenue(ticker) {
|
|
1817
|
+
return this.client.get("/api/v1/stocks/fundamentals/historical/revenue", { ticker });
|
|
1818
|
+
}
|
|
1819
|
+
/** Get short interest metrics (FINRA). */
|
|
1820
|
+
async getShortInterest(ticker) {
|
|
1821
|
+
return this.client.get("/api/v1/stocks/short-interest", { ticker });
|
|
1822
|
+
}
|
|
1823
|
+
/** Get float information. */
|
|
1824
|
+
async getFloat(ticker) {
|
|
1825
|
+
return this.client.get("/api/v1/stocks/float", { ticker });
|
|
1826
|
+
}
|
|
1827
|
+
/** Get short volume trading data. */
|
|
1828
|
+
async getShortVolume(ticker) {
|
|
1829
|
+
return this.client.get("/api/v1/stocks/short-volume", { ticker });
|
|
1830
|
+
}
|
|
1831
|
+
/**
|
|
1832
|
+
* Get company-specific KPI time-series for a ticker. Returns curated GAAP and
|
|
1833
|
+
* non-GAAP metrics from earnings filings (e.g. iPhone unit sales, Tesla deliveries,
|
|
1834
|
+
* AWS revenue).
|
|
1835
|
+
*
|
|
1836
|
+
* Free users receive metadata only with an empty `kpis` list; PRO users receive
|
|
1837
|
+
* the full series. Returns 404 for tickers that do not yet have curated coverage.
|
|
1838
|
+
*
|
|
1839
|
+
* Coverage today: near-complete for the S&P 500 plus extended universe
|
|
1840
|
+
* (~500 tickers). Use `listKpiCoverage()` to enumerate.
|
|
1841
|
+
*/
|
|
1842
|
+
async getKpis(ticker) {
|
|
1843
|
+
return this.client.get(
|
|
1844
|
+
`/api/v1/stocks/${encodeURIComponent(ticker.toUpperCase())}/kpis`
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
/**
|
|
1848
|
+
* List every ticker with curated KPI coverage. Returns `{count, tickers: [...]}`
|
|
1849
|
+
* with lightweight metadata (ticker, companyName, lastUpdated, kpiCount).
|
|
1850
|
+
* Sorted alphabetically by ticker.
|
|
1851
|
+
*
|
|
1852
|
+
* Auth: API key required, but the call does NOT consume your monthly quota
|
|
1853
|
+
* (rate-limit-per-minute still applies).
|
|
1854
|
+
*/
|
|
1855
|
+
async listKpiCoverage() {
|
|
1856
|
+
return this.client.get("/api/v1/stocks/with-kpis");
|
|
1857
|
+
}
|
|
1858
|
+
/**
|
|
1859
|
+
* List the KPI metadata tuples available for a ticker (`id, name, category,
|
|
1860
|
+
* chartType`) without paying the cost of the full series payload. Mirrors
|
|
1861
|
+
* the `/api/v1/insights/stock/{ticker}/types` precedent.
|
|
1862
|
+
*
|
|
1863
|
+
* Auth: API key required, no quota cost. 404 if the ticker has no curated KPIs.
|
|
1864
|
+
*/
|
|
1865
|
+
async getKpiTypes(ticker) {
|
|
1866
|
+
return this.client.get(
|
|
1867
|
+
`/api/v1/stocks/${encodeURIComponent(ticker.toUpperCase())}/kpis/types`
|
|
1868
|
+
);
|
|
1869
|
+
}
|
|
1870
|
+
/**
|
|
1871
|
+
* Get the end-of-day options dossier for one stock or ETF: the session's aggregate, its
|
|
1872
|
+
* percentile context, the open-interest wall structure with max pain, and the contracts
|
|
1873
|
+
* whose volume ran far ahead of their open interest.
|
|
1874
|
+
*
|
|
1875
|
+
* End of day, not live. `asOf` is the prior trading session and the data refreshes the
|
|
1876
|
+
* following morning, so this is positioning, not a quote feed.
|
|
1877
|
+
*
|
|
1878
|
+
* **`data` is `null` for a ticker outside the covered universe**, which is the most
|
|
1879
|
+
* actively optioned US names plus the tracked ETFs, and for a covered ticker with no
|
|
1880
|
+
* snapshot yet. An unknown symbol behaves the same way rather than answering 404, so treat
|
|
1881
|
+
* a null as "no coverage", never as an error. A covered ticker still building its baseline
|
|
1882
|
+
* returns its raw readings with the percentiles omitted.
|
|
1883
|
+
*
|
|
1884
|
+
* Percentiles compare a ticker to its own trailing history, never to another ticker, so an
|
|
1885
|
+
* ETF's readings are not comparable with a single stock's.
|
|
1886
|
+
*
|
|
1887
|
+
* Tiering: a PRO key always receives the full dossier. A FREE key receives it for the first
|
|
1888
|
+
* ten calls each calendar month and a headline-only preview after that, with `isPreview`
|
|
1889
|
+
* true; calls that return a null `data` never spend that allowance.
|
|
1890
|
+
*/
|
|
1891
|
+
async getOptionsSummary(ticker) {
|
|
1892
|
+
return this.client.get(
|
|
1893
|
+
`/api/v1/stocks/${encodeURIComponent(ticker.toUpperCase())}/options/summary`
|
|
1894
|
+
);
|
|
1895
|
+
}
|
|
1896
|
+
};
|
|
1897
|
+
|
|
1898
|
+
// src/resources/indexes.ts
|
|
1899
|
+
var Indexes = class {
|
|
1900
|
+
constructor(client) {
|
|
1901
|
+
this.client = client;
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* List every index the platform publishes: id, display name, one-line
|
|
1905
|
+
* description, the scale it lives on, its access tier, and where its richest
|
|
1906
|
+
* view lives.
|
|
1907
|
+
*
|
|
1908
|
+
* Iterate this rather than hardcoding ids. Every `indexId` it advertises
|
|
1909
|
+
* resolves on {@link get} and {@link history}.
|
|
1910
|
+
*/
|
|
1911
|
+
async list() {
|
|
1912
|
+
return this.client.get("/api/v1/indexes");
|
|
1913
|
+
}
|
|
1914
|
+
/**
|
|
1915
|
+
* Latest reading for one index.
|
|
1916
|
+
*
|
|
1917
|
+
* Check `constituents` for `null` before iterating: it is `null` on a
|
|
1918
|
+
* composite index like `market-mood`, which has no constituents by
|
|
1919
|
+
* construction. For Market Mood this is the narrowed view; the phase band,
|
|
1920
|
+
* weekly change, per-signal breakdown and per-sector map live on
|
|
1921
|
+
* `client.marketMood.get()`, and both report the same headline number.
|
|
1922
|
+
*
|
|
1923
|
+
* @param indexId slug from {@link list}, e.g. `"fed-sentiment"`.
|
|
1924
|
+
*/
|
|
1925
|
+
async get(indexId) {
|
|
1926
|
+
return this.client.get(`/api/v1/indexes/${indexId}`);
|
|
1927
|
+
}
|
|
1928
|
+
/**
|
|
1929
|
+
* Historical scalar series for one index, for charting.
|
|
1930
|
+
*
|
|
1931
|
+
* Thin or low-coverage buckets are withheld, so the series can be shorter
|
|
1932
|
+
* than `days` and can contain gaps. Plot against each point's `date`.
|
|
1933
|
+
*
|
|
1934
|
+
* @param indexId slug from {@link list}.
|
|
1935
|
+
* @param days days of history to return. Defaults to the API's own 180.
|
|
1936
|
+
*/
|
|
1937
|
+
async history(indexId, days) {
|
|
1938
|
+
return this.client.get(
|
|
1939
|
+
`/api/v1/indexes/${indexId}/history`,
|
|
1940
|
+
days === void 0 ? void 0 : { days }
|
|
1941
|
+
);
|
|
1942
|
+
}
|
|
1943
|
+
};
|
|
1944
|
+
|
|
1945
|
+
// src/resources/trackers.ts
|
|
1946
|
+
var Trackers = class {
|
|
1947
|
+
constructor(client) {
|
|
1948
|
+
this.client = client;
|
|
1949
|
+
}
|
|
1950
|
+
/**
|
|
1951
|
+
* List every publicly-visible tracker: id, display name, category,
|
|
1952
|
+
* one-line description, and the methodology anchor to link out to.
|
|
1953
|
+
*/
|
|
1954
|
+
async list() {
|
|
1955
|
+
return this.client.get("/api/v1/trackers");
|
|
1956
|
+
}
|
|
1957
|
+
/**
|
|
1958
|
+
* Standardized snapshot envelope for one tracker.
|
|
1959
|
+
*
|
|
1960
|
+
* Returns the envelope as-is: `{ isPreview, previewReason, totalCount?, data }`.
|
|
1961
|
+
* When `data.viewType === "table"` the rows live at `data.rows[]`; when
|
|
1962
|
+
* `"choropleth"` they live at `data.geo[]`; etc. Dispatch on `viewType`
|
|
1963
|
+
* in your renderer.
|
|
1964
|
+
*
|
|
1965
|
+
* @param trackerId slug from {@link list}, e.g. `"institution-concentration"`.
|
|
1966
|
+
* @param params provider-specific query params (e.g. `{ scope: "us" }` for
|
|
1967
|
+
* geographically-scoped trackers like hantavirus). Unknown keys are ignored.
|
|
1968
|
+
*/
|
|
1969
|
+
async get(trackerId, params) {
|
|
1970
|
+
return this.client.get(`/api/v1/trackers/${trackerId}`, params);
|
|
1971
|
+
}
|
|
1972
|
+
};
|
|
1973
|
+
|
|
1974
|
+
// src/client.ts
|
|
1975
|
+
var DEFAULT_BASE_URL = "https://app.sentisense.ai";
|
|
1976
|
+
var DEFAULT_TIMEOUT = 3e4;
|
|
1977
|
+
var DEFAULT_MAX_RETRIES = 3;
|
|
1978
|
+
var BASE_DELAY_MS = 1e3;
|
|
1979
|
+
var MAX_DELAY_MS = 6e4;
|
|
1980
|
+
var DEEP_HISTORY_FALLBACK_WAIT_S = 3;
|
|
1981
|
+
var MAX_DEEP_HISTORY_WAIT_S = 30;
|
|
1982
|
+
var MAX_RATE_LIMIT_WAIT_S = 120;
|
|
1983
|
+
var RATE_LIMIT_FALLBACK_WAIT_S = 60;
|
|
1984
|
+
function clampRetryAfter(raw, maxWaitS) {
|
|
1985
|
+
if (!raw) return void 0;
|
|
1986
|
+
const parsed = Number(raw);
|
|
1987
|
+
if (!Number.isFinite(parsed)) return void 0;
|
|
1988
|
+
return Math.min(Math.max(0.5, parsed), maxWaitS);
|
|
1989
|
+
}
|
|
1990
|
+
function retryAfterSeconds(raw, defaultS, maxWaitS) {
|
|
1991
|
+
return clampRetryAfter(raw, maxWaitS) ?? defaultS;
|
|
1992
|
+
}
|
|
1993
|
+
function sleep(ms) {
|
|
1994
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1995
|
+
}
|
|
1996
|
+
var SentiSense = class {
|
|
1997
|
+
constructor(options = {}) {
|
|
1998
|
+
this.apiKey = options.apiKey;
|
|
1999
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
2000
|
+
this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
|
2001
|
+
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
2002
|
+
const suffix = options.userAgentSuffix?.replace(/[\r\n]+/g, " ").trim();
|
|
2003
|
+
this.userAgent = suffix ? `sentisense-node/${VERSION} ${suffix}` : `sentisense-node/${VERSION}`;
|
|
2004
|
+
this.stocks = new Stocks(this);
|
|
2005
|
+
this.documents = new Documents(this);
|
|
2006
|
+
this.etfs = new Etfs(this);
|
|
2007
|
+
this.institutional = new Institutional(this);
|
|
2008
|
+
this.insider = new Insider(this);
|
|
2009
|
+
this.politicians = new Politicians(this);
|
|
2010
|
+
this.insights = new Insights(this);
|
|
2011
|
+
this.analyst = new Analyst(this);
|
|
2012
|
+
this.entityMetrics = new EntityMetrics(this);
|
|
2013
|
+
this.marketMood = new MarketMoodResource(this);
|
|
2014
|
+
this.marketSummary = new MarketSummaryResource(this);
|
|
2015
|
+
this.kb = new KB(this);
|
|
2016
|
+
this.indexes = new Indexes(this);
|
|
2017
|
+
this.trackers = new Trackers(this);
|
|
2018
|
+
this.calendar = new Calendar(this);
|
|
2019
|
+
this.earnings = new Earnings(this);
|
|
2020
|
+
this.screener = new Screener(this);
|
|
2021
|
+
}
|
|
2022
|
+
/** @internal */
|
|
2023
|
+
async get(path, params) {
|
|
2024
|
+
const url = this.buildUrl(path, params);
|
|
2025
|
+
const headers = {
|
|
2026
|
+
"Accept": "application/json"
|
|
2027
|
+
};
|
|
2028
|
+
if (this.apiKey) {
|
|
2029
|
+
headers["X-SentiSense-API-Key"] = this.apiKey;
|
|
2030
|
+
}
|
|
2031
|
+
if (typeof process !== "undefined" && process.versions?.node) {
|
|
2032
|
+
headers["User-Agent"] = this.userAgent;
|
|
2033
|
+
}
|
|
2034
|
+
let delayMs = 0;
|
|
2035
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
2036
|
+
if (delayMs > 0) {
|
|
2037
|
+
await sleep(delayMs);
|
|
2038
|
+
delayMs = 0;
|
|
2039
|
+
}
|
|
2040
|
+
const controller = new AbortController();
|
|
2041
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
2042
|
+
try {
|
|
2043
|
+
const response = await fetch(url, {
|
|
2044
|
+
method: "GET",
|
|
2045
|
+
headers,
|
|
2046
|
+
signal: controller.signal
|
|
2047
|
+
});
|
|
2048
|
+
if (response.status === 202) {
|
|
2049
|
+
const waitSeconds = retryAfterSeconds(
|
|
2050
|
+
response.headers.get("Retry-After"),
|
|
2051
|
+
DEEP_HISTORY_FALLBACK_WAIT_S,
|
|
2052
|
+
MAX_DEEP_HISTORY_WAIT_S
|
|
2053
|
+
);
|
|
2054
|
+
try {
|
|
2055
|
+
await response.body?.cancel();
|
|
2056
|
+
} catch {
|
|
2057
|
+
}
|
|
2058
|
+
if (attempt < this.maxRetries) {
|
|
2059
|
+
delayMs = waitSeconds * 1e3;
|
|
2060
|
+
continue;
|
|
2061
|
+
}
|
|
2062
|
+
throw new DeepHistoryUnavailableError(
|
|
2063
|
+
"Deep history is still being assembled. Retry in a few seconds.",
|
|
2064
|
+
waitSeconds
|
|
2065
|
+
);
|
|
2066
|
+
}
|
|
2067
|
+
if (!response.ok) {
|
|
2068
|
+
const isRetryable = response.status === 429 || response.status >= 500;
|
|
2069
|
+
if (isRetryable && attempt < this.maxRetries) {
|
|
2070
|
+
if (response.status === 429) {
|
|
2071
|
+
delayMs = retryAfterSeconds(
|
|
2072
|
+
response.headers.get("Retry-After"),
|
|
2073
|
+
RATE_LIMIT_FALLBACK_WAIT_S,
|
|
2074
|
+
MAX_RATE_LIMIT_WAIT_S
|
|
2075
|
+
) * 1e3;
|
|
2076
|
+
} else {
|
|
2077
|
+
delayMs = Math.min(BASE_DELAY_MS * Math.pow(2, attempt), MAX_DELAY_MS) + Math.random() * 1e3;
|
|
2078
|
+
}
|
|
2079
|
+
try {
|
|
2080
|
+
await response.body?.cancel();
|
|
2081
|
+
} catch {
|
|
2082
|
+
}
|
|
2083
|
+
continue;
|
|
2084
|
+
}
|
|
2085
|
+
await this.handleErrorResponse(response);
|
|
2086
|
+
}
|
|
2087
|
+
return await response.json();
|
|
2088
|
+
} catch (error) {
|
|
2089
|
+
if (error instanceof SentiSenseError) throw error;
|
|
2090
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
2091
|
+
throw new SentiSenseError(`Request timed out after ${this.timeout}ms`);
|
|
2092
|
+
}
|
|
2093
|
+
throw new SentiSenseError(
|
|
2094
|
+
error instanceof Error ? error.message : "Unknown error"
|
|
2095
|
+
);
|
|
2096
|
+
} finally {
|
|
2097
|
+
clearTimeout(timer);
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
throw new SentiSenseError("All retries exhausted");
|
|
2101
|
+
}
|
|
2102
|
+
/** @internal */
|
|
2103
|
+
async post(path, body) {
|
|
2104
|
+
const url = this.buildUrl(path);
|
|
2105
|
+
const headers = {
|
|
2106
|
+
"Accept": "application/json",
|
|
2107
|
+
"Content-Type": "application/json"
|
|
2108
|
+
};
|
|
2109
|
+
if (this.apiKey) {
|
|
2110
|
+
headers["X-SentiSense-API-Key"] = this.apiKey;
|
|
2111
|
+
}
|
|
2112
|
+
if (typeof process !== "undefined" && process.versions?.node) {
|
|
2113
|
+
headers["User-Agent"] = this.userAgent;
|
|
2114
|
+
}
|
|
2115
|
+
const controller = new AbortController();
|
|
2116
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
2117
|
+
try {
|
|
2118
|
+
const response = await fetch(url, {
|
|
2119
|
+
method: "POST",
|
|
2120
|
+
headers,
|
|
2121
|
+
body: JSON.stringify(body),
|
|
2122
|
+
signal: controller.signal
|
|
2123
|
+
});
|
|
2124
|
+
if (!response.ok) {
|
|
2125
|
+
await this.handleErrorResponse(response);
|
|
2126
|
+
}
|
|
2127
|
+
return await response.json();
|
|
2128
|
+
} catch (error) {
|
|
2129
|
+
if (error instanceof SentiSenseError) throw error;
|
|
2130
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
2131
|
+
throw new SentiSenseError(`Request timed out after ${this.timeout}ms`);
|
|
2132
|
+
}
|
|
2133
|
+
throw new SentiSenseError(
|
|
2134
|
+
error instanceof Error ? error.message : "Unknown error"
|
|
2135
|
+
);
|
|
2136
|
+
} finally {
|
|
2137
|
+
clearTimeout(timer);
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
buildUrl(path, params) {
|
|
2141
|
+
const url = new URL(path, this.baseUrl);
|
|
2142
|
+
if (params) {
|
|
2143
|
+
for (const [key, value] of Object.entries(params)) {
|
|
2144
|
+
if (value !== void 0 && value !== null) {
|
|
2145
|
+
url.searchParams.set(key, String(value));
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
return url.toString();
|
|
2150
|
+
}
|
|
2151
|
+
async handleErrorResponse(response) {
|
|
2152
|
+
let body = {};
|
|
2153
|
+
try {
|
|
2154
|
+
body = await response.json();
|
|
2155
|
+
} catch {
|
|
2156
|
+
}
|
|
2157
|
+
const message = body.message ?? response.statusText ?? "API request failed";
|
|
2158
|
+
const code = body.error;
|
|
2159
|
+
switch (response.status) {
|
|
2160
|
+
case 401:
|
|
2161
|
+
case 403:
|
|
2162
|
+
throw new AuthenticationError(message, response.status, code);
|
|
2163
|
+
case 404:
|
|
2164
|
+
throw new NotFoundError(message, code);
|
|
2165
|
+
case 429: {
|
|
2166
|
+
const retryAfter = clampRetryAfter(
|
|
2167
|
+
response.headers.get("Retry-After"),
|
|
2168
|
+
MAX_RATE_LIMIT_WAIT_S
|
|
2169
|
+
);
|
|
2170
|
+
throw new RateLimitError(message, code, retryAfter);
|
|
2171
|
+
}
|
|
2172
|
+
default:
|
|
2173
|
+
throw new APIError(message, response.status, code);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
};
|
|
2177
|
+
|
|
2178
|
+
// src/cli/parse.ts
|
|
2179
|
+
var GLOBAL_FLAGS = {
|
|
2180
|
+
json: { type: "boolean", describe: "Print the exact API response as JSON" },
|
|
2181
|
+
plain: { type: "boolean", describe: "Force plain text with no colour" },
|
|
2182
|
+
pretty: { type: "boolean", describe: "Force the terminal layout" },
|
|
2183
|
+
"no-color": { type: "boolean", describe: "Drop colour from the terminal layout" },
|
|
2184
|
+
full: { type: "boolean", describe: "Show more rows and more detail" },
|
|
2185
|
+
debug: { type: "boolean", describe: "Print stack traces on failure" },
|
|
2186
|
+
help: { type: "boolean", describe: "Show help for this command" },
|
|
2187
|
+
version: { type: "boolean", describe: "Print the version" },
|
|
2188
|
+
"api-key": { type: "string", placeholder: "key", describe: "API key for this call only" },
|
|
2189
|
+
"base-url": { type: "string", placeholder: "url", describe: "Override the API base URL" },
|
|
2190
|
+
agent: {
|
|
2191
|
+
type: "string",
|
|
2192
|
+
placeholder: "name",
|
|
2193
|
+
describe: "Label this call in the User-Agent"
|
|
2194
|
+
}
|
|
2195
|
+
};
|
|
2196
|
+
var SHORT_FLAGS = {
|
|
2197
|
+
"-h": "--help",
|
|
2198
|
+
"-v": "--version"
|
|
2199
|
+
};
|
|
2200
|
+
function editDistance(a, b) {
|
|
2201
|
+
const rows = a.length + 1;
|
|
2202
|
+
const cols = b.length + 1;
|
|
2203
|
+
let previous = Array.from({ length: cols }, (_, i) => i);
|
|
2204
|
+
for (let i = 1; i < rows; i++) {
|
|
2205
|
+
const current = [i];
|
|
2206
|
+
for (let j = 1; j < cols; j++) {
|
|
2207
|
+
current[j] = Math.min(
|
|
2208
|
+
previous[j] + 1,
|
|
2209
|
+
current[j - 1] + 1,
|
|
2210
|
+
previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
|
|
2211
|
+
);
|
|
2212
|
+
}
|
|
2213
|
+
previous = current;
|
|
2214
|
+
}
|
|
2215
|
+
return previous[cols - 1];
|
|
2216
|
+
}
|
|
2217
|
+
function nearest(word, candidates) {
|
|
2218
|
+
let best;
|
|
2219
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
2220
|
+
for (const candidate of candidates) {
|
|
2221
|
+
const score = editDistance(word.toLowerCase(), candidate.toLowerCase());
|
|
2222
|
+
if (score < bestScore) {
|
|
2223
|
+
bestScore = score;
|
|
2224
|
+
best = candidate;
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
const budget = Math.max(2, Math.floor(word.length / 3));
|
|
2228
|
+
return best !== void 0 && bestScore <= budget ? best : void 0;
|
|
2229
|
+
}
|
|
2230
|
+
function splitCommand(argv) {
|
|
2231
|
+
const first = argv[0];
|
|
2232
|
+
if (first === void 0) return { rest: [] };
|
|
2233
|
+
if (first.startsWith("-")) return { rest: argv };
|
|
2234
|
+
return { command: first, rest: argv.slice(1) };
|
|
2235
|
+
}
|
|
2236
|
+
function parseArgs(argv, specs) {
|
|
2237
|
+
const positionals = [];
|
|
2238
|
+
const flags = {};
|
|
2239
|
+
const names = Object.keys(specs);
|
|
2240
|
+
for (let i = 0; i < argv.length; i++) {
|
|
2241
|
+
let token = argv[i];
|
|
2242
|
+
if (token === "--") {
|
|
2243
|
+
positionals.push(...argv.slice(i + 1));
|
|
2244
|
+
break;
|
|
2245
|
+
}
|
|
2246
|
+
if (SHORT_FLAGS[token]) token = SHORT_FLAGS[token];
|
|
2247
|
+
if (!token.startsWith("--")) {
|
|
2248
|
+
if (token.startsWith("-") && token.length > 1 && !/^-\d/.test(token)) {
|
|
2249
|
+
throw new CliUsageError(
|
|
2250
|
+
`unknown flag "${token}".`,
|
|
2251
|
+
'run "sentisense --help" for the flag list.'
|
|
2252
|
+
);
|
|
2253
|
+
}
|
|
2254
|
+
positionals.push(token);
|
|
2255
|
+
continue;
|
|
2256
|
+
}
|
|
2257
|
+
const equals = token.indexOf("=");
|
|
2258
|
+
const name = equals === -1 ? token.slice(2) : token.slice(2, equals);
|
|
2259
|
+
const inline = equals === -1 ? void 0 : token.slice(equals + 1);
|
|
2260
|
+
const spec = specs[name];
|
|
2261
|
+
if (!spec) {
|
|
2262
|
+
const suggestion = nearest(name, names);
|
|
2263
|
+
throw new CliUsageError(
|
|
2264
|
+
`unknown flag "--${name}".`,
|
|
2265
|
+
suggestion ? `did you mean "--${suggestion}"?` : 'run "sentisense help <command>" for the flags it accepts.'
|
|
2266
|
+
);
|
|
2267
|
+
}
|
|
2268
|
+
if (spec.type === "boolean") {
|
|
2269
|
+
if (inline !== void 0 && inline !== "true" && inline !== "false") {
|
|
2270
|
+
throw new CliUsageError(
|
|
2271
|
+
`--${name} is a switch and takes no value.`,
|
|
2272
|
+
`drop the "=${inline}".`
|
|
2273
|
+
);
|
|
2274
|
+
}
|
|
2275
|
+
flags[name] = inline !== "false";
|
|
2276
|
+
continue;
|
|
2277
|
+
}
|
|
2278
|
+
const raw = inline ?? argv[++i];
|
|
2279
|
+
if (raw === void 0 || inline === void 0 && raw.startsWith("--")) {
|
|
2280
|
+
throw new CliUsageError(
|
|
2281
|
+
`--${name} needs a value.`,
|
|
2282
|
+
`for example: --${name} ${spec.placeholder ?? "value"}`
|
|
2283
|
+
);
|
|
2284
|
+
}
|
|
2285
|
+
if (spec.type === "number") {
|
|
2286
|
+
const value = Number(raw);
|
|
2287
|
+
if (!Number.isFinite(value)) {
|
|
2288
|
+
throw new CliUsageError(
|
|
2289
|
+
`--${name} expects a number, got "${raw}".`,
|
|
2290
|
+
`for example: --${name} 30`
|
|
2291
|
+
);
|
|
2292
|
+
}
|
|
2293
|
+
flags[name] = value;
|
|
2294
|
+
continue;
|
|
2295
|
+
}
|
|
2296
|
+
if (spec.repeat) {
|
|
2297
|
+
const existing = Array.isArray(flags[name]) ? flags[name] : [];
|
|
2298
|
+
flags[name] = [...existing, raw];
|
|
2299
|
+
} else {
|
|
2300
|
+
flags[name] = raw;
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
return { positionals, flags };
|
|
2304
|
+
}
|
|
2305
|
+
function flagString(flags, name) {
|
|
2306
|
+
const value = flags[name];
|
|
2307
|
+
return typeof value === "string" ? value : void 0;
|
|
2308
|
+
}
|
|
2309
|
+
function flagList(flags, name) {
|
|
2310
|
+
const value = flags[name];
|
|
2311
|
+
if (Array.isArray(value)) return value;
|
|
2312
|
+
return typeof value === "string" ? [value] : [];
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
// src/cli/context.ts
|
|
2316
|
+
function resolveContext({ flags, env, configDir }) {
|
|
2317
|
+
const dir = resolveConfigDir(env, configDir);
|
|
2318
|
+
const stored = readConfig(dir);
|
|
2319
|
+
const pick = (flagValue, envValue, configValue) => {
|
|
2320
|
+
if (flagValue) return { value: flagValue, source: "flag" };
|
|
2321
|
+
if (envValue && envValue.trim()) return { value: envValue.trim(), source: "env" };
|
|
2322
|
+
if (configValue) return { value: configValue, source: "config" };
|
|
2323
|
+
return { source: "default" };
|
|
2324
|
+
};
|
|
2325
|
+
const key = pick(flagString(flags, "api-key"), env.SENTISENSE_API_KEY, stored.apiKey);
|
|
2326
|
+
const base = pick(flagString(flags, "base-url"), env.SENTISENSE_BASE_URL, stored.baseUrl);
|
|
2327
|
+
const agent = pick(flagString(flags, "agent"), env.SENTISENSE_AGENT_NAME, stored.agentName);
|
|
2328
|
+
return {
|
|
2329
|
+
configDir: dir,
|
|
2330
|
+
apiKey: key.value,
|
|
2331
|
+
apiKeySource: key.source,
|
|
2332
|
+
baseUrl: base.value,
|
|
2333
|
+
baseUrlSource: base.source,
|
|
2334
|
+
agentName: agent.value,
|
|
2335
|
+
agentSource: agent.source
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
2338
|
+
var DEFAULT_BASE_URL2 = "https://app.sentisense.ai";
|
|
2339
|
+
function effectiveBaseUrl(context) {
|
|
2340
|
+
return context.baseUrl ?? DEFAULT_BASE_URL2;
|
|
2341
|
+
}
|
|
2342
|
+
function sanitizeAgent(name) {
|
|
2343
|
+
return name.trim().replace(/\s+/g, "-").replace(/[^A-Za-z0-9._-]/g, "");
|
|
2344
|
+
}
|
|
2345
|
+
function userAgentSuffix(context) {
|
|
2346
|
+
const parts = [`sentisense-cli/${VERSION}`];
|
|
2347
|
+
const agent = context.agentName ? sanitizeAgent(context.agentName) : "";
|
|
2348
|
+
if (agent) parts.push(`agent/${agent}`);
|
|
2349
|
+
return parts.join(" ");
|
|
2350
|
+
}
|
|
2351
|
+
function createClient(context, options = {}) {
|
|
2352
|
+
if (!options.anonymous && !context.apiKey) throw new MissingKeyError();
|
|
2353
|
+
return new SentiSense({
|
|
2354
|
+
apiKey: options.anonymous ? void 0 : context.apiKey,
|
|
2355
|
+
baseUrl: context.baseUrl,
|
|
2356
|
+
maxRetries: 0,
|
|
2357
|
+
userAgentSuffix: userAgentSuffix(context)
|
|
2358
|
+
});
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
// src/cli/commands/health.ts
|
|
2362
|
+
async function probe(run) {
|
|
2363
|
+
const started = Date.now();
|
|
2364
|
+
try {
|
|
2365
|
+
await run();
|
|
2366
|
+
return { ok: true, detail: "ok", latencyMs: Date.now() - started };
|
|
2367
|
+
} catch (error) {
|
|
2368
|
+
const latencyMs = Date.now() - started;
|
|
2369
|
+
if (error instanceof AuthenticationError) {
|
|
2370
|
+
return { ok: false, detail: `rejected (${error.status ?? 401})`, latencyMs };
|
|
2371
|
+
}
|
|
2372
|
+
if (error instanceof SentiSenseError && error.status === void 0) {
|
|
2373
|
+
return { ok: false, detail: `unreachable: ${error.message}`, latencyMs };
|
|
2374
|
+
}
|
|
2375
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2376
|
+
return { ok: false, detail: message, latencyMs };
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
var healthCommand = {
|
|
2380
|
+
name: "health",
|
|
2381
|
+
summary: "Check that the API is reachable and the key works",
|
|
2382
|
+
usage: "sentisense health",
|
|
2383
|
+
examples: ["sentisense health", "sentisense health --json"],
|
|
2384
|
+
notes: [
|
|
2385
|
+
"Run this first on a new machine. It answers three questions in one call each:",
|
|
2386
|
+
"is the host up, is the key accepted, and how slow is the round trip.",
|
|
2387
|
+
"Exits 3 when the key is missing or rejected, 6 when the host cannot be reached."
|
|
2388
|
+
],
|
|
2389
|
+
flags: {},
|
|
2390
|
+
async run({ args, context }) {
|
|
2391
|
+
rejectPositionals(args, "health");
|
|
2392
|
+
const baseUrl = effectiveBaseUrl(context);
|
|
2393
|
+
const anonymous = createClient(context, { anonymous: true });
|
|
2394
|
+
const reach = await probe(() => anonymous.stocks.getMarketStatus());
|
|
2395
|
+
const reachable = reach.ok || reach.detail.startsWith("rejected");
|
|
2396
|
+
let keyState;
|
|
2397
|
+
if (!context.apiKey) {
|
|
2398
|
+
keyState = { ok: false, detail: "not configured" };
|
|
2399
|
+
} else if (!reachable) {
|
|
2400
|
+
keyState = { ok: false, detail: "not checked" };
|
|
2401
|
+
} else {
|
|
2402
|
+
const keyed = createClient(context);
|
|
2403
|
+
keyState = await probe(() => keyed.stocks.getMarketStatus());
|
|
2404
|
+
}
|
|
2405
|
+
const exitCode = !reachable ? EXIT.NETWORK : keyState.ok ? EXIT.OK : EXIT.AUTH;
|
|
2406
|
+
const latency = keyState.latencyMs ?? reach.latencyMs;
|
|
2407
|
+
const nextStep = !reachable ? `check the network and the base URL (${baseUrl}).` : !context.apiKey ? `run "sentisense auth <key>", or set SENTISENSE_API_KEY. Get a key at ${KEY_URL}` : keyState.ok ? void 0 : `the stored key was refused. Replace it with "sentisense auth <key>" or get a new one at ${KEY_URL}`;
|
|
2408
|
+
return {
|
|
2409
|
+
exitCode,
|
|
2410
|
+
json: {
|
|
2411
|
+
ok: exitCode === EXIT.OK,
|
|
2412
|
+
baseUrl,
|
|
2413
|
+
reachable,
|
|
2414
|
+
apiKey: context.apiKey ? maskKey(context.apiKey) : null,
|
|
2415
|
+
apiKeySource: context.apiKeySource,
|
|
2416
|
+
apiKeyValid: keyState.ok,
|
|
2417
|
+
latencyMs: latency ?? null,
|
|
2418
|
+
cliVersion: VERSION,
|
|
2419
|
+
sdkVersion: VERSION
|
|
2420
|
+
},
|
|
2421
|
+
doc: doc(
|
|
2422
|
+
{
|
|
2423
|
+
kind: "kv",
|
|
2424
|
+
items: [
|
|
2425
|
+
field(
|
|
2426
|
+
"reachable",
|
|
2427
|
+
reachable ? "yes" : reach.detail,
|
|
2428
|
+
reachable ? "up" : "down"
|
|
2429
|
+
),
|
|
2430
|
+
field(
|
|
2431
|
+
"api key",
|
|
2432
|
+
keyState.ok ? `ok (${context.apiKey ? maskKey(context.apiKey) : ""})` : keyState.detail,
|
|
2433
|
+
keyState.ok ? "up" : "down"
|
|
2434
|
+
),
|
|
2435
|
+
field("latency", latency === void 0 ? "n/a" : `${latency} ms`),
|
|
2436
|
+
field("base url", baseUrl),
|
|
2437
|
+
field("cli", `sentisense-cli/${VERSION}`),
|
|
2438
|
+
field("sdk", `sentisense-node/${VERSION}`)
|
|
2439
|
+
]
|
|
2440
|
+
},
|
|
2441
|
+
nextStep ? { kind: "text", text: `next: ${nextStep}` } : void 0
|
|
2442
|
+
)
|
|
2443
|
+
};
|
|
2444
|
+
}
|
|
2445
|
+
};
|
|
2446
|
+
|
|
2447
|
+
// src/cli/commands/insiders.ts
|
|
2448
|
+
var insidersCommand = {
|
|
2449
|
+
name: "insiders",
|
|
2450
|
+
summary: "Form 4 insider transactions for one ticker",
|
|
2451
|
+
usage: "sentisense insiders <ticker> [--days N]",
|
|
2452
|
+
examples: [
|
|
2453
|
+
"sentisense insiders NVDA",
|
|
2454
|
+
"sentisense insiders NVDA --days 180 --full",
|
|
2455
|
+
"sentisense insiders NVDA --json"
|
|
2456
|
+
],
|
|
2457
|
+
notes: [
|
|
2458
|
+
"Rows are individual filed transactions, newest first, not a net total.",
|
|
2459
|
+
"The plan column says whether the trade was under a confirmed pre-arranged 10b5-1 plan,",
|
|
2460
|
+
"which is the difference between a scheduled sale and a discretionary one.",
|
|
2461
|
+
"A free key sees the top few transactions; a PRO key sees the window you asked for.",
|
|
2462
|
+
EMPTY_VERIFY_NOTE,
|
|
2463
|
+
EMPTY_VERIFY_NOTE_2
|
|
2464
|
+
],
|
|
2465
|
+
flags: {
|
|
2466
|
+
days: { type: "number", placeholder: "N", describe: "Look-back window, 1 to 365 (default 90)" }
|
|
2467
|
+
},
|
|
2468
|
+
async run({ args, client, full }) {
|
|
2469
|
+
const ticker = oneTicker(args, "insiders");
|
|
2470
|
+
const lookbackDays = typeof args.flags.days === "number" ? args.flags.days : void 0;
|
|
2471
|
+
const api = client();
|
|
2472
|
+
const notes = [];
|
|
2473
|
+
const envelope = await api.insider.getTrades(
|
|
2474
|
+
ticker,
|
|
2475
|
+
lookbackDays === void 0 ? void 0 : { lookbackDays }
|
|
2476
|
+
);
|
|
2477
|
+
const trades = envelope.data ?? [];
|
|
2478
|
+
if (trades.length === 0) {
|
|
2479
|
+
const note = await verifyTickerOnEmpty(api, ticker);
|
|
2480
|
+
if (note) notes.push(note);
|
|
2481
|
+
}
|
|
2482
|
+
const shown = full ? trades : trades.slice(0, 15);
|
|
2483
|
+
const buys = trades.filter((trade) => trade.transactionType === "BUY");
|
|
2484
|
+
const sells = trades.filter((trade) => trade.transactionType === "SELL");
|
|
2485
|
+
const sum = (rows) => rows.reduce((total, row) => total + (row.totalValue || 0), 0);
|
|
2486
|
+
const blocks = [
|
|
2487
|
+
{
|
|
2488
|
+
kind: "head",
|
|
2489
|
+
title: field("ticker", ticker),
|
|
2490
|
+
right: fields(
|
|
2491
|
+
field("trades", String(trades.length)),
|
|
2492
|
+
field("bought", humanize(sum(buys)), buys.length > 0 ? "up" : void 0),
|
|
2493
|
+
field("sold", humanize(sum(sells)), sells.length > 0 ? "down" : void 0)
|
|
2494
|
+
)
|
|
2495
|
+
}
|
|
2496
|
+
];
|
|
2497
|
+
if (shown.length === 0) {
|
|
2498
|
+
blocks.push({ kind: "text", text: "No filed insider transactions in this window." });
|
|
2499
|
+
} else {
|
|
2500
|
+
blocks.push({
|
|
2501
|
+
kind: "table",
|
|
2502
|
+
head: ["DATE", "INSIDER", "ROLE", "TYPE", "SHARES", "VALUE", "PLAN"],
|
|
2503
|
+
align: ["left", "left", "left", "left", "right", "right", "left"],
|
|
2504
|
+
rows: shown.map((trade) => [
|
|
2505
|
+
cell(trade.transactionDate),
|
|
2506
|
+
cell(truncate(trade.insiderName, full ? 40 : 22)),
|
|
2507
|
+
cell(truncate(trade.insiderTitle ?? "", full ? 40 : 18)),
|
|
2508
|
+
cell(
|
|
2509
|
+
trade.transactionType,
|
|
2510
|
+
trade.transactionType === "BUY" ? "up" : trade.transactionType === "SELL" ? "down" : void 0
|
|
2511
|
+
),
|
|
2512
|
+
cell(humanize(trade.sharesTransacted, 1)),
|
|
2513
|
+
cell(humanize(trade.totalValue)),
|
|
2514
|
+
cell(trade.rule10b51 ? "10b5-1" : "")
|
|
2515
|
+
])
|
|
2516
|
+
});
|
|
2517
|
+
if (!full && trades.length > shown.length) {
|
|
2518
|
+
blocks.push({
|
|
2519
|
+
kind: "text",
|
|
2520
|
+
text: `Showing ${shown.length} of ${trades.length}. Add --full for the rest.`,
|
|
2521
|
+
tone: "dim"
|
|
2522
|
+
});
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
if (envelope.isPreview) {
|
|
2526
|
+
blocks.push({
|
|
2527
|
+
kind: "text",
|
|
2528
|
+
text: `Preview response: ${envelope.totalCount ?? "more"} transactions exist, a PRO key returns them all.`,
|
|
2529
|
+
tone: "dim"
|
|
2530
|
+
});
|
|
2531
|
+
}
|
|
2532
|
+
return { json: envelope, doc: doc(...blocks), notes };
|
|
2533
|
+
}
|
|
2534
|
+
};
|
|
2535
|
+
|
|
2536
|
+
// src/cli/commands/insights.ts
|
|
2537
|
+
function urgencyTone(urgency) {
|
|
2538
|
+
if (urgency === "high") return "accent";
|
|
2539
|
+
if (urgency === "low") return "dim";
|
|
2540
|
+
return void 0;
|
|
2541
|
+
}
|
|
2542
|
+
var insightsCommand = {
|
|
2543
|
+
name: "insights",
|
|
2544
|
+
summary: "Generated signals for one ticker, most urgent first",
|
|
2545
|
+
usage: "sentisense insights <ticker> [--urgency low|medium|high] [--type <name>]",
|
|
2546
|
+
examples: [
|
|
2547
|
+
"sentisense insights NVDA",
|
|
2548
|
+
"sentisense insights NVDA --urgency high",
|
|
2549
|
+
"sentisense insights NVDA --type institutional_position_change --full"
|
|
2550
|
+
],
|
|
2551
|
+
notes: [
|
|
2552
|
+
"Signals are generated observations about filings, flows, and attention, ordered by",
|
|
2553
|
+
"urgency then confidence. They describe what the data shows, not what to do about it.",
|
|
2554
|
+
"A free key sees the top three; a PRO key sees the whole list.",
|
|
2555
|
+
"Signal types vary by ticker and over time, so take --type from what a plain run reports",
|
|
2556
|
+
"rather than guessing a name.",
|
|
2557
|
+
EMPTY_VERIFY_NOTE,
|
|
2558
|
+
EMPTY_VERIFY_NOTE_2
|
|
2559
|
+
],
|
|
2560
|
+
flags: {
|
|
2561
|
+
urgency: { type: "string", placeholder: "level", describe: "Filter to low, medium, or high" },
|
|
2562
|
+
type: { type: "string", placeholder: "name", describe: "Filter to one signal type" }
|
|
2563
|
+
},
|
|
2564
|
+
async run({ args, client, full }) {
|
|
2565
|
+
const ticker = oneTicker(args, "insights");
|
|
2566
|
+
const urgency = typeof args.flags.urgency === "string" ? args.flags.urgency : void 0;
|
|
2567
|
+
if (urgency && !["low", "medium", "high"].includes(urgency)) {
|
|
2568
|
+
throw new CliUsageError(
|
|
2569
|
+
`--urgency takes low, medium, or high, got "${urgency}".`,
|
|
2570
|
+
"for example: --urgency high"
|
|
2571
|
+
);
|
|
2572
|
+
}
|
|
2573
|
+
const options = {
|
|
2574
|
+
...urgency ? { urgency } : {},
|
|
2575
|
+
...typeof args.flags.type === "string" ? { insightType: args.flags.type } : {}
|
|
2576
|
+
};
|
|
2577
|
+
const api = client();
|
|
2578
|
+
const notes = [];
|
|
2579
|
+
const envelope = await api.insights.stock(
|
|
2580
|
+
ticker,
|
|
2581
|
+
Object.keys(options).length > 0 ? options : void 0
|
|
2582
|
+
);
|
|
2583
|
+
const insights = envelope.data ?? [];
|
|
2584
|
+
if (insights.length === 0) {
|
|
2585
|
+
const note = await verifyTickerOnEmpty(api, ticker);
|
|
2586
|
+
if (note) notes.push(note);
|
|
2587
|
+
}
|
|
2588
|
+
const shown = full ? insights : insights.slice(0, 8);
|
|
2589
|
+
const blocks = [
|
|
2590
|
+
{
|
|
2591
|
+
kind: "head",
|
|
2592
|
+
title: field("ticker", ticker),
|
|
2593
|
+
right: fields(
|
|
2594
|
+
field("signals", String(insights.length)),
|
|
2595
|
+
envelope.totalCount === void 0 ? void 0 : field("available", String(envelope.totalCount))
|
|
2596
|
+
)
|
|
2597
|
+
}
|
|
2598
|
+
];
|
|
2599
|
+
if (shown.length === 0) {
|
|
2600
|
+
blocks.push({ kind: "text", text: "No signals match that filter right now." });
|
|
2601
|
+
} else if (full) {
|
|
2602
|
+
for (const insight of shown) {
|
|
2603
|
+
blocks.push({ kind: "blank" });
|
|
2604
|
+
blocks.push({
|
|
2605
|
+
kind: "head",
|
|
2606
|
+
title: field("type", insight.insightType),
|
|
2607
|
+
right: fields(
|
|
2608
|
+
field("urgency", insight.urgency, urgencyTone(insight.urgency)),
|
|
2609
|
+
field("confidence", percent(insight.confidence * 100, 0)),
|
|
2610
|
+
field("generated", dateFromSeconds(insight.generatedAt))
|
|
2611
|
+
)
|
|
2612
|
+
});
|
|
2613
|
+
blocks.push({ kind: "text", text: insight.insightText });
|
|
2614
|
+
}
|
|
2615
|
+
} else {
|
|
2616
|
+
blocks.push({
|
|
2617
|
+
kind: "table",
|
|
2618
|
+
head: ["URGENCY", "CONF", "TYPE", "SIGNAL"],
|
|
2619
|
+
align: ["left", "right", "left", "left"],
|
|
2620
|
+
rows: shown.map((insight) => [
|
|
2621
|
+
cell(insight.urgency, urgencyTone(insight.urgency)),
|
|
2622
|
+
cell(percent(insight.confidence * 100, 0)),
|
|
2623
|
+
cell(insight.insightType),
|
|
2624
|
+
cell(truncate(insight.insightText, 60))
|
|
2625
|
+
])
|
|
2626
|
+
});
|
|
2627
|
+
blocks.push({
|
|
2628
|
+
kind: "text",
|
|
2629
|
+
text: "Add --full for the complete text of each signal.",
|
|
2630
|
+
tone: "dim"
|
|
2631
|
+
});
|
|
2632
|
+
}
|
|
2633
|
+
if (envelope.isPreview) {
|
|
2634
|
+
blocks.push({
|
|
2635
|
+
kind: "text",
|
|
2636
|
+
text: "Preview response: a PRO key returns every signal.",
|
|
2637
|
+
tone: "dim"
|
|
2638
|
+
});
|
|
2639
|
+
}
|
|
2640
|
+
return { json: envelope, doc: doc(...blocks), notes };
|
|
2641
|
+
}
|
|
2642
|
+
};
|
|
2643
|
+
|
|
2644
|
+
// src/cli/commands/mood.ts
|
|
2645
|
+
function readMarket(payload) {
|
|
2646
|
+
if (!payload || typeof payload !== "object") return {};
|
|
2647
|
+
const market = payload.market;
|
|
2648
|
+
return market && typeof market === "object" ? market : {};
|
|
2649
|
+
}
|
|
2650
|
+
function readSectors(payload) {
|
|
2651
|
+
if (!payload || typeof payload !== "object") return [];
|
|
2652
|
+
const sectors = payload.sectors;
|
|
2653
|
+
if (!sectors || typeof sectors !== "object") return [];
|
|
2654
|
+
return Object.entries(sectors).sort(
|
|
2655
|
+
(a, b) => (b[1]?.currentScore ?? 0) - (a[1]?.currentScore ?? 0)
|
|
2656
|
+
);
|
|
2657
|
+
}
|
|
2658
|
+
var moodCommand = {
|
|
2659
|
+
name: "mood",
|
|
2660
|
+
summary: "Composite market sentiment, its signals, and the sector map",
|
|
2661
|
+
usage: "sentisense mood",
|
|
2662
|
+
examples: ["sentisense mood", "sentisense mood --full", "sentisense mood --json"],
|
|
2663
|
+
notes: [
|
|
2664
|
+
"One 0 to 100 score for the whole market, with the six signals behind it and a",
|
|
2665
|
+
"per-sector breakdown. Bands: 0-15 extreme fear, 16-30 fear, 31-45 anxiety,",
|
|
2666
|
+
"46-55 neutral, 56-70 optimism, 71-85 greed, 86-100 extreme greed."
|
|
2667
|
+
],
|
|
2668
|
+
flags: {},
|
|
2669
|
+
async run({ args, client, full }) {
|
|
2670
|
+
rejectPositionals(args, "mood");
|
|
2671
|
+
const payload = await client().marketMood.get();
|
|
2672
|
+
const market = readMarket(payload);
|
|
2673
|
+
const sectors = readSectors(payload);
|
|
2674
|
+
const history = (market.history ?? []).map((point) => point?.score).filter((score) => typeof score === "number" && Number.isFinite(score));
|
|
2675
|
+
const blocks = [
|
|
2676
|
+
{
|
|
2677
|
+
kind: "head",
|
|
2678
|
+
title: field("index", "Market Mood"),
|
|
2679
|
+
right: fields(
|
|
2680
|
+
field("score", fixed(market.currentScore, 1), "accent"),
|
|
2681
|
+
market.phase ? field("phase", String(market.phase)) : void 0,
|
|
2682
|
+
field(
|
|
2683
|
+
"weekly change",
|
|
2684
|
+
`${signed(market.weeklyChange, 1)} wk`,
|
|
2685
|
+
direction(market.weeklyChange)
|
|
2686
|
+
)
|
|
2687
|
+
)
|
|
2688
|
+
}
|
|
2689
|
+
];
|
|
2690
|
+
const signals = market.signals ?? [];
|
|
2691
|
+
if (signals.length > 0) {
|
|
2692
|
+
blocks.push({
|
|
2693
|
+
kind: "facts",
|
|
2694
|
+
items: signals.map(
|
|
2695
|
+
(signal) => field(signal.label ?? signal.key ?? "signal", fixed(signal.value, 1))
|
|
2696
|
+
)
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
if (history.length > 0) {
|
|
2700
|
+
const tail = full ? history : history.slice(-60);
|
|
2701
|
+
blocks.push({
|
|
2702
|
+
kind: "spark",
|
|
2703
|
+
label: `${tail.length}d`,
|
|
2704
|
+
series: tail,
|
|
2705
|
+
note: market.history?.[market.history.length - 1]?.date ? `to ${market.history[market.history.length - 1].date}` : void 0
|
|
2706
|
+
});
|
|
2707
|
+
}
|
|
2708
|
+
if (sectors.length > 0) {
|
|
2709
|
+
blocks.push({ kind: "blank" });
|
|
2710
|
+
blocks.push({
|
|
2711
|
+
kind: "table",
|
|
2712
|
+
head: ["SECTOR", "SCORE", "PHASE", "WEEK"],
|
|
2713
|
+
align: ["left", "right", "left", "right"],
|
|
2714
|
+
rows: sectors.map(([name, band]) => [
|
|
2715
|
+
cell(name),
|
|
2716
|
+
cell(fixed(band?.currentScore, 1)),
|
|
2717
|
+
cell(band?.phase ?? "n/a"),
|
|
2718
|
+
cell(signed(band?.weeklyChange, 1), direction(band?.weeklyChange))
|
|
2719
|
+
])
|
|
2720
|
+
});
|
|
2721
|
+
}
|
|
2722
|
+
if (full && signals.length > 0) {
|
|
2723
|
+
blocks.push({ kind: "blank" });
|
|
2724
|
+
blocks.push({
|
|
2725
|
+
kind: "table",
|
|
2726
|
+
head: ["SIGNAL", "VALUE", "CHANGE"],
|
|
2727
|
+
align: ["left", "right", "right"],
|
|
2728
|
+
rows: signals.map((signal) => [
|
|
2729
|
+
cell(signal.label ?? signal.key ?? "signal"),
|
|
2730
|
+
cell(fixed(signal.value, 1)),
|
|
2731
|
+
cell(signed(signal.change, 1), direction(signal.change))
|
|
2732
|
+
])
|
|
2733
|
+
});
|
|
2734
|
+
}
|
|
2735
|
+
return { json: payload, doc: doc(...blocks) };
|
|
2736
|
+
}
|
|
2737
|
+
};
|
|
2738
|
+
|
|
2739
|
+
// src/cli/commands/news.ts
|
|
2740
|
+
var DEFAULT_LIMIT = 10;
|
|
2741
|
+
var newsCommand = {
|
|
2742
|
+
name: "news",
|
|
2743
|
+
summary: "Clustered news stories, market-wide or for one ticker",
|
|
2744
|
+
usage: "sentisense news [ticker] [--limit N] [--days N]",
|
|
2745
|
+
examples: [
|
|
2746
|
+
"sentisense news",
|
|
2747
|
+
"sentisense news NVDA",
|
|
2748
|
+
"sentisense news --limit 25 --full",
|
|
2749
|
+
"sentisense news NVDA --json"
|
|
2750
|
+
],
|
|
2751
|
+
notes: [
|
|
2752
|
+
"A story is a cluster of articles covering the same event, not a single headline, so",
|
|
2753
|
+
"the size column is how many sources picked it up and impact ranks how much it moved.",
|
|
2754
|
+
"Tone is the average sentiment across the cluster, between -1 and 1.",
|
|
2755
|
+
"--days only applies to the market-wide feed.",
|
|
2756
|
+
EMPTY_VERIFY_NOTE,
|
|
2757
|
+
EMPTY_VERIFY_NOTE_2
|
|
2758
|
+
],
|
|
2759
|
+
flags: {
|
|
2760
|
+
limit: { type: "number", placeholder: "N", describe: `Stories to return (default ${DEFAULT_LIMIT})` },
|
|
2761
|
+
days: { type: "number", placeholder: "N", describe: "Look-back window, market-wide feed only" }
|
|
2762
|
+
},
|
|
2763
|
+
async run({ args, client, full }) {
|
|
2764
|
+
const api = client();
|
|
2765
|
+
const ticker = optionalTicker(args, "news");
|
|
2766
|
+
const notes = [];
|
|
2767
|
+
const limit = typeof args.flags.limit === "number" ? args.flags.limit : DEFAULT_LIMIT;
|
|
2768
|
+
const days = typeof args.flags.days === "number" ? args.flags.days : void 0;
|
|
2769
|
+
const stories = ticker ? await api.documents.getStoriesByTicker(ticker, { limit }) : await api.documents.getStories({ limit, ...days === void 0 ? {} : { days } });
|
|
2770
|
+
if (ticker && stories.length === 0) {
|
|
2771
|
+
const note = await verifyTickerOnEmpty(api, ticker);
|
|
2772
|
+
if (note) notes.push(note);
|
|
2773
|
+
}
|
|
2774
|
+
const blocks = [
|
|
2775
|
+
{
|
|
2776
|
+
kind: "head",
|
|
2777
|
+
title: field("scope", ticker ?? "Top stories"),
|
|
2778
|
+
right: fields(field("stories", String(stories.length)))
|
|
2779
|
+
}
|
|
2780
|
+
];
|
|
2781
|
+
if (stories.length === 0) {
|
|
2782
|
+
blocks.push({ kind: "text", text: "No stories in this window." });
|
|
2783
|
+
} else if (full) {
|
|
2784
|
+
for (const story of stories) {
|
|
2785
|
+
blocks.push({ kind: "blank" });
|
|
2786
|
+
blocks.push({
|
|
2787
|
+
kind: "head",
|
|
2788
|
+
title: field("story", truncate(story.cluster.title, 70)),
|
|
2789
|
+
right: fields(
|
|
2790
|
+
field("impact", fixed(story.impactScore, 1), "accent"),
|
|
2791
|
+
field(
|
|
2792
|
+
"tone",
|
|
2793
|
+
signed(story.cluster.averageSentiment, 2),
|
|
2794
|
+
story.cluster.averageSentiment > 0 ? "up" : story.cluster.averageSentiment < 0 ? "down" : void 0
|
|
2795
|
+
)
|
|
2796
|
+
)
|
|
2797
|
+
});
|
|
2798
|
+
blocks.push({
|
|
2799
|
+
kind: "kv",
|
|
2800
|
+
items: fields(
|
|
2801
|
+
field("id", story.cluster.id),
|
|
2802
|
+
field("sources", String(story.cluster.clusterSize)),
|
|
2803
|
+
field("broke", dateFromSeconds(story.brokeAt)),
|
|
2804
|
+
field("tickers", story.tickers.join(", ") || "none")
|
|
2805
|
+
)
|
|
2806
|
+
});
|
|
2807
|
+
}
|
|
2808
|
+
} else {
|
|
2809
|
+
blocks.push({
|
|
2810
|
+
kind: "table",
|
|
2811
|
+
head: ["BROKE", "IMPACT", "TONE", "SRC", "TICKERS", "STORY"],
|
|
2812
|
+
align: ["left", "right", "right", "right", "left", "left"],
|
|
2813
|
+
rows: stories.map((story) => [
|
|
2814
|
+
cell(dateFromSeconds(story.brokeAt)),
|
|
2815
|
+
cell(fixed(story.impactScore, 1)),
|
|
2816
|
+
cell(
|
|
2817
|
+
signed(story.cluster.averageSentiment, 2),
|
|
2818
|
+
story.cluster.averageSentiment > 0 ? "up" : story.cluster.averageSentiment < 0 ? "down" : void 0
|
|
2819
|
+
),
|
|
2820
|
+
cell(String(story.cluster.clusterSize)),
|
|
2821
|
+
cell(truncate(story.tickers.join(","), 14)),
|
|
2822
|
+
cell(truncate(story.cluster.title, 44))
|
|
2823
|
+
])
|
|
2824
|
+
});
|
|
2825
|
+
blocks.push({
|
|
2826
|
+
kind: "text",
|
|
2827
|
+
text: "Add --full for story ids, source counts, and the full ticker list.",
|
|
2828
|
+
tone: "dim"
|
|
2829
|
+
});
|
|
2830
|
+
}
|
|
2831
|
+
return { json: stories, doc: doc(...blocks), notes };
|
|
2832
|
+
}
|
|
2833
|
+
};
|
|
2834
|
+
|
|
2835
|
+
// src/cli/commands/options.ts
|
|
2836
|
+
function contractTone(type) {
|
|
2837
|
+
const side = type?.toUpperCase();
|
|
2838
|
+
if (side === "CALL") return "up";
|
|
2839
|
+
if (side === "PUT") return "down";
|
|
2840
|
+
return void 0;
|
|
2841
|
+
}
|
|
2842
|
+
function walls(rows) {
|
|
2843
|
+
if (!rows || rows.length === 0) return "n/a";
|
|
2844
|
+
return rows.map((wall) => `${fixed(wall.strike, 0)} (${humanize(wall.oi, 1)})`).join(" ");
|
|
2845
|
+
}
|
|
2846
|
+
var optionsCommand = {
|
|
2847
|
+
name: "options",
|
|
2848
|
+
summary: "End-of-day options positioning for one stock or ETF",
|
|
2849
|
+
usage: "sentisense options <ticker>",
|
|
2850
|
+
examples: [
|
|
2851
|
+
"sentisense options NVDA",
|
|
2852
|
+
"sentisense options SPY --full",
|
|
2853
|
+
"sentisense options NVDA --json"
|
|
2854
|
+
],
|
|
2855
|
+
notes: [
|
|
2856
|
+
"End of day, not live: readings describe the latest completed session and refresh the",
|
|
2857
|
+
"following morning. Percentiles are against that ticker's own trailing history, so they",
|
|
2858
|
+
"compare a stock to its past self, never to another stock.",
|
|
2859
|
+
"Coverage is the most actively optioned names plus the tracked ETFs. A real ticker",
|
|
2860
|
+
"outside that set reports no coverage and exits 0, the same as any other empty result.",
|
|
2861
|
+
EMPTY_VERIFY_NOTE,
|
|
2862
|
+
EMPTY_VERIFY_NOTE_2,
|
|
2863
|
+
"A free key gets the full dossier for the first ten calls each month, then a headline",
|
|
2864
|
+
"preview. Calls that return no dossier do not count against that."
|
|
2865
|
+
],
|
|
2866
|
+
flags: {},
|
|
2867
|
+
async run({ args, client, full }) {
|
|
2868
|
+
const ticker = oneTicker(args, "options");
|
|
2869
|
+
const api = client();
|
|
2870
|
+
const envelope = await api.stocks.getOptionsSummary(ticker);
|
|
2871
|
+
const data = envelope.data;
|
|
2872
|
+
if (!data) {
|
|
2873
|
+
const note = await verifyTickerOnEmpty(api, ticker);
|
|
2874
|
+
return {
|
|
2875
|
+
json: envelope,
|
|
2876
|
+
doc: doc({ kind: "text", text: `No options coverage for ${ticker}.` }),
|
|
2877
|
+
notes: note ? [note] : void 0
|
|
2878
|
+
};
|
|
2879
|
+
}
|
|
2880
|
+
const latest = data.latest ?? {};
|
|
2881
|
+
const context = data.context ?? {};
|
|
2882
|
+
const blocks = [
|
|
2883
|
+
{
|
|
2884
|
+
kind: "head",
|
|
2885
|
+
title: field("ticker", ticker),
|
|
2886
|
+
right: fields(
|
|
2887
|
+
field("positioning", signed(data.sentiment, 2), direction(data.sentiment)),
|
|
2888
|
+
field("iv rank 1y", fixed(context.ivRank1y, 1)),
|
|
2889
|
+
field("put/call vol", fixed(latest.pcVol, 2))
|
|
2890
|
+
)
|
|
2891
|
+
},
|
|
2892
|
+
{
|
|
2893
|
+
kind: "facts",
|
|
2894
|
+
items: fields(
|
|
2895
|
+
field("ATM IV", fixed(latest.atmIv, 4)),
|
|
2896
|
+
field("Skew 25d", signed(latest.skew25d, 4)),
|
|
2897
|
+
field("Call vol", humanize(latest.callVol, 1)),
|
|
2898
|
+
field("Put vol", humanize(latest.putVol, 1)),
|
|
2899
|
+
field("Notional", humanize(latest.notionalVol))
|
|
2900
|
+
)
|
|
2901
|
+
},
|
|
2902
|
+
{
|
|
2903
|
+
kind: "facts",
|
|
2904
|
+
items: fields(
|
|
2905
|
+
field("Max pain", fixed(data.oiWalls?.maxPain, 0)),
|
|
2906
|
+
field("Wall expiry", data.oiWalls?.expiry ?? "n/a"),
|
|
2907
|
+
field("Call OI", humanize(latest.callOi, 1)),
|
|
2908
|
+
field("Put OI", humanize(latest.putOi, 1)),
|
|
2909
|
+
field("Contracts", humanize(latest.contracts, 0))
|
|
2910
|
+
)
|
|
2911
|
+
},
|
|
2912
|
+
{
|
|
2913
|
+
kind: "kv",
|
|
2914
|
+
items: fields(
|
|
2915
|
+
field("call walls", walls(data.oiWalls?.callWalls)),
|
|
2916
|
+
field("put walls", walls(data.oiWalls?.putWalls))
|
|
2917
|
+
)
|
|
2918
|
+
}
|
|
2919
|
+
];
|
|
2920
|
+
if (full) {
|
|
2921
|
+
blocks.push({
|
|
2922
|
+
kind: "kv",
|
|
2923
|
+
items: fields(
|
|
2924
|
+
field("term structure", `60d ${fixed(latest.atmIv60, 4)} 90d ${fixed(latest.atmIv90, 4)}`),
|
|
2925
|
+
field("net delta", humanize(latest.netDelta, 1)),
|
|
2926
|
+
field("put/call OI", fixed(latest.pcOi, 2)),
|
|
2927
|
+
field("pc vol percentile 1y", fixed(context.pcVolPctl1y, 1)),
|
|
2928
|
+
field("skew percentile 1y", fixed(context.skewPctl1y, 1)),
|
|
2929
|
+
field("observations 1y", fixed(context.observations1y, 0))
|
|
2930
|
+
)
|
|
2931
|
+
});
|
|
2932
|
+
}
|
|
2933
|
+
const unusual = data.unusual ?? [];
|
|
2934
|
+
if (unusual.length > 0) {
|
|
2935
|
+
blocks.push({ kind: "blank" });
|
|
2936
|
+
blocks.push({
|
|
2937
|
+
kind: "table",
|
|
2938
|
+
head: ["CONTRACT", "TYPE", "STRIKE", "EXPIRY", "DTE", "VOLUME", "OI", "VOL/OI", "PREMIUM"],
|
|
2939
|
+
align: ["left", "left", "right", "left", "right", "right", "right", "right", "right"],
|
|
2940
|
+
rows: unusual.map((row) => [
|
|
2941
|
+
cell(row.contract ?? ""),
|
|
2942
|
+
cell(row.type ?? "", contractTone(row.type)),
|
|
2943
|
+
cell(fixed(row.strike, 0)),
|
|
2944
|
+
cell(row.expiry ?? ""),
|
|
2945
|
+
cell(row.dte === void 0 ? "" : String(row.dte)),
|
|
2946
|
+
cell(humanize(row.volume, 0)),
|
|
2947
|
+
cell(humanize(row.oi, 0)),
|
|
2948
|
+
cell(fixed(row.volOiRatio, 1)),
|
|
2949
|
+
cell(humanize(row.premium))
|
|
2950
|
+
])
|
|
2951
|
+
});
|
|
2952
|
+
}
|
|
2953
|
+
blocks.push({
|
|
2954
|
+
kind: "text",
|
|
2955
|
+
text: `session of ${data.asOf ?? latest.date ?? "an unrecorded date"}`,
|
|
2956
|
+
tone: "dim"
|
|
2957
|
+
});
|
|
2958
|
+
if (envelope.isPreview) {
|
|
2959
|
+
blocks.push({
|
|
2960
|
+
kind: "text",
|
|
2961
|
+
text: "Preview response: the free monthly dossier allowance is spent, so only the headline readings are shown.",
|
|
2962
|
+
tone: "dim"
|
|
2963
|
+
});
|
|
2964
|
+
}
|
|
2965
|
+
return { json: envelope, doc: doc(...blocks) };
|
|
2966
|
+
}
|
|
2967
|
+
};
|
|
2968
|
+
|
|
2969
|
+
// src/cli/commands/quote.ts
|
|
2970
|
+
function single(row, full) {
|
|
2971
|
+
const { quote, profile } = row;
|
|
2972
|
+
const tone = direction(quote.changePercent);
|
|
2973
|
+
const blocks = [
|
|
2974
|
+
{
|
|
2975
|
+
kind: "head",
|
|
2976
|
+
title: field("ticker", row.ticker),
|
|
2977
|
+
subtitle: profile?.name ? field("name", profile.name) : void 0,
|
|
2978
|
+
right: fields(
|
|
2979
|
+
field("price", money(quote.currentPrice)),
|
|
2980
|
+
field(
|
|
2981
|
+
"change",
|
|
2982
|
+
`${signed(quote.change)} (${signedPercent(quote.changePercent)})`,
|
|
2983
|
+
tone
|
|
2984
|
+
)
|
|
2985
|
+
)
|
|
2986
|
+
},
|
|
2987
|
+
{
|
|
2988
|
+
kind: "facts",
|
|
2989
|
+
items: fields(
|
|
2990
|
+
field("Open", fixed(quote.open)),
|
|
2991
|
+
field("High", fixed(quote.dayHigh)),
|
|
2992
|
+
field("Low", fixed(quote.dayLow)),
|
|
2993
|
+
field("Volume", humanize(quote.volume, 1)),
|
|
2994
|
+
field("Prev close", fixed(quote.previousClose))
|
|
2995
|
+
)
|
|
2996
|
+
},
|
|
2997
|
+
{
|
|
2998
|
+
kind: "facts",
|
|
2999
|
+
items: fields(
|
|
3000
|
+
field("Mkt cap", humanize(quote.marketCap)),
|
|
3001
|
+
field("P/E", fixed(quote.peRatio)),
|
|
3002
|
+
field("EPS TTM", fixed(quote.epsTTM)),
|
|
3003
|
+
field("Div yield", percent(quote.dividendYield, 2)),
|
|
3004
|
+
field(
|
|
3005
|
+
"52w",
|
|
3006
|
+
`${fixed(quote.week52Low)} to ${fixed(quote.week52High)}`
|
|
3007
|
+
)
|
|
3008
|
+
)
|
|
3009
|
+
}
|
|
3010
|
+
];
|
|
3011
|
+
if (quote.extendedHours) {
|
|
3012
|
+
const ext = quote.extendedHours;
|
|
3013
|
+
blocks.push({
|
|
3014
|
+
kind: "facts",
|
|
3015
|
+
items: fields(
|
|
3016
|
+
field(
|
|
3017
|
+
ext.session === "pre" ? "Pre-market" : "After hours",
|
|
3018
|
+
money(ext.price)
|
|
3019
|
+
),
|
|
3020
|
+
field(
|
|
3021
|
+
"Extended change",
|
|
3022
|
+
`${signed(ext.change)} (${signedPercent(ext.changePercent)})`,
|
|
3023
|
+
direction(ext.changePercent)
|
|
3024
|
+
)
|
|
3025
|
+
)
|
|
3026
|
+
});
|
|
3027
|
+
}
|
|
3028
|
+
if (full) {
|
|
3029
|
+
blocks.push({
|
|
3030
|
+
kind: "kv",
|
|
3031
|
+
items: fields(
|
|
3032
|
+
field("200d average", fixed(quote.movingAverage200Day)),
|
|
3033
|
+
quote.reportedCurrency ? field("reported currency", quote.reportedCurrency) : void 0,
|
|
3034
|
+
profile?.sector ? field("sector", String(profile.sector)) : void 0,
|
|
3035
|
+
profile?.industry ? field("industry", String(profile.industry)) : void 0,
|
|
3036
|
+
profile?.ceo ? field("ceo", String(profile.ceo)) : void 0
|
|
3037
|
+
)
|
|
3038
|
+
});
|
|
3039
|
+
}
|
|
3040
|
+
if (quote.listingStatus === "DELISTED") {
|
|
3041
|
+
blocks.push({
|
|
3042
|
+
kind: "text",
|
|
3043
|
+
text: `Delisted on ${quote.delistedDate ?? "an unrecorded date"}. Every figure above is frozen at the last trade, not a live market move.`,
|
|
3044
|
+
tone: "down"
|
|
3045
|
+
});
|
|
3046
|
+
} else if (quote.listingStatus === "PENDING_DELISTING") {
|
|
3047
|
+
blocks.push({
|
|
3048
|
+
kind: "text",
|
|
3049
|
+
text: "A merger or take-private is scheduled. The stock still trades, so these figures are current.",
|
|
3050
|
+
tone: "dim"
|
|
3051
|
+
});
|
|
3052
|
+
}
|
|
3053
|
+
blocks.push({
|
|
3054
|
+
kind: "text",
|
|
3055
|
+
text: quote.priceAsOf ? `price as of ${timestamp(quote.priceAsOf)}` : `served ${timestamp(quote.timestamp)}, price age not reported`,
|
|
3056
|
+
tone: "dim"
|
|
3057
|
+
});
|
|
3058
|
+
return blocks;
|
|
3059
|
+
}
|
|
3060
|
+
function table(rows, full) {
|
|
3061
|
+
const head = full ? ["TICKER", "NAME", "PRICE", "CHANGE", "CHANGE%", "VOLUME", "MKT CAP"] : ["TICKER", "PRICE", "CHANGE", "CHANGE%", "VOLUME", "MKT CAP"];
|
|
3062
|
+
return {
|
|
3063
|
+
kind: "table",
|
|
3064
|
+
head,
|
|
3065
|
+
align: full ? ["left", "left", "right", "right", "right", "right", "right"] : ["left", "right", "right", "right", "right", "right"],
|
|
3066
|
+
rows: rows.map((row) => {
|
|
3067
|
+
const tone = direction(row.quote.changePercent);
|
|
3068
|
+
const tail = [
|
|
3069
|
+
cell(fixed(row.quote.currentPrice), void 0),
|
|
3070
|
+
cell(signed(row.quote.change), tone),
|
|
3071
|
+
cell(signedPercent(row.quote.changePercent), tone),
|
|
3072
|
+
cell(humanize(row.quote.volume, 1)),
|
|
3073
|
+
cell(humanize(row.quote.marketCap))
|
|
3074
|
+
];
|
|
3075
|
+
return full ? [cell(row.ticker), cell(row.profile?.name ?? ""), ...tail] : [cell(row.ticker), ...tail];
|
|
3076
|
+
})
|
|
3077
|
+
};
|
|
3078
|
+
}
|
|
3079
|
+
var quoteCommand = {
|
|
3080
|
+
name: "quote",
|
|
3081
|
+
summary: "Price, day range, and valuation for one or more tickers",
|
|
3082
|
+
usage: "sentisense quote <ticker> [ticker...]",
|
|
3083
|
+
examples: [
|
|
3084
|
+
"sentisense quote NVDA",
|
|
3085
|
+
"sentisense quote NVDA AAPL MSFT",
|
|
3086
|
+
"sentisense quote NVDA --json",
|
|
3087
|
+
"sentisense quote NVDA --full"
|
|
3088
|
+
],
|
|
3089
|
+
notes: [
|
|
3090
|
+
"Several tickers in one invocation is one process and one set of round trips, which is",
|
|
3091
|
+
"cheaper than calling the command once per symbol.",
|
|
3092
|
+
"Use canonical symbols, for example GOOGL rather than GOOG and BRK.B rather than BRK-B.",
|
|
3093
|
+
"One request per ticker. The company name costs a second request, so it is fetched only",
|
|
3094
|
+
"for the terminal layout: piped and --json output carry the quote alone.",
|
|
3095
|
+
"JSON is the exact quote response for one ticker, and an object keyed by ticker for more."
|
|
3096
|
+
],
|
|
3097
|
+
flags: {},
|
|
3098
|
+
async run({ args, client, full, mode }) {
|
|
3099
|
+
const tickers = args.positionals.map((t) => t.toUpperCase());
|
|
3100
|
+
if (tickers.length === 0) {
|
|
3101
|
+
throw new CliUsageError("quote needs at least one ticker.", "for example: sentisense quote NVDA");
|
|
3102
|
+
}
|
|
3103
|
+
const api = client();
|
|
3104
|
+
const wantName = mode === "pretty";
|
|
3105
|
+
const rows = await Promise.all(
|
|
3106
|
+
tickers.map(async (ticker) => {
|
|
3107
|
+
const [quote, profile] = await Promise.all([
|
|
3108
|
+
api.stocks.getQuote(ticker),
|
|
3109
|
+
// A name is a nicety, not the answer. A ticker with no profile still gets a quote.
|
|
3110
|
+
wantName ? api.stocks.getProfile(ticker).catch(() => null) : Promise.resolve(null)
|
|
3111
|
+
]);
|
|
3112
|
+
return { ticker, quote, profile };
|
|
3113
|
+
})
|
|
3114
|
+
);
|
|
3115
|
+
if (rows.length === 1) {
|
|
3116
|
+
return { json: rows[0].quote, doc: doc(...single(rows[0], full)) };
|
|
3117
|
+
}
|
|
3118
|
+
const json = {};
|
|
3119
|
+
for (const row of rows) json[row.ticker] = row.quote;
|
|
3120
|
+
return { json, doc: doc(table(rows, full)) };
|
|
3121
|
+
}
|
|
3122
|
+
};
|
|
3123
|
+
|
|
3124
|
+
// src/cli/commands/screen.ts
|
|
3125
|
+
var OPS = ["GTE", "LTE", "GT", "LT", "EQ", "NEQ", "IN", "NOT_IN"];
|
|
3126
|
+
function parseFilter(raw) {
|
|
3127
|
+
const parts = raw.split(":");
|
|
3128
|
+
if (parts.length < 3) {
|
|
3129
|
+
throw new CliUsageError(
|
|
3130
|
+
`--filter takes FIELD:OP:VALUE, got "${raw}".`,
|
|
3131
|
+
"for example: --filter SENTI_SCORE_7D:GTE:13"
|
|
3132
|
+
);
|
|
3133
|
+
}
|
|
3134
|
+
const fieldName = parts[0].toUpperCase();
|
|
3135
|
+
const op = parts[1].toUpperCase();
|
|
3136
|
+
const value = parts.slice(2).join(":");
|
|
3137
|
+
if (!OPS.includes(op)) {
|
|
3138
|
+
throw new CliUsageError(
|
|
3139
|
+
`unknown filter operator "${parts[1]}".`,
|
|
3140
|
+
`operators are ${OPS.join(", ")}.`
|
|
3141
|
+
);
|
|
3142
|
+
}
|
|
3143
|
+
if (op === "IN" || op === "NOT_IN") {
|
|
3144
|
+
return { fieldName, op, values: value.split(",").map((v) => v.trim()).filter(Boolean) };
|
|
3145
|
+
}
|
|
3146
|
+
const numeric = Number(value);
|
|
3147
|
+
if (!Number.isFinite(numeric)) {
|
|
3148
|
+
throw new CliUsageError(
|
|
3149
|
+
`--filter ${fieldName}:${op} expects a number, got "${value}".`,
|
|
3150
|
+
"for example: --filter SENTI_SCORE_7D:GTE:13"
|
|
3151
|
+
);
|
|
3152
|
+
}
|
|
3153
|
+
return { fieldName, op, value: numeric };
|
|
3154
|
+
}
|
|
3155
|
+
function parseSort(raw) {
|
|
3156
|
+
const [name, dirRaw] = raw.split(":");
|
|
3157
|
+
const dir = (dirRaw ?? "DESC").toUpperCase();
|
|
3158
|
+
if (dir !== "ASC" && dir !== "DESC") {
|
|
3159
|
+
throw new CliUsageError(
|
|
3160
|
+
`--sort direction takes ASC or DESC, got "${dirRaw}".`,
|
|
3161
|
+
"for example: --sort SENTI_SCORE_7D:DESC"
|
|
3162
|
+
);
|
|
3163
|
+
}
|
|
3164
|
+
return { fieldName: name.toUpperCase(), dir };
|
|
3165
|
+
}
|
|
3166
|
+
var screenCommand = {
|
|
3167
|
+
name: "screen",
|
|
3168
|
+
summary: "Filter the tracked universe on Score, analyst, technical, and price fields",
|
|
3169
|
+
usage: "sentisense screen [--filter FIELD:OP:VALUE]... [--sort FIELD:DIR] [--limit N]",
|
|
3170
|
+
examples: [
|
|
3171
|
+
"sentisense screen --list",
|
|
3172
|
+
"sentisense screen --fields",
|
|
3173
|
+
"sentisense screen --filter SENTI_SCORE_7D:GTE:13 --filter ANALYST_COUNT:GTE:5",
|
|
3174
|
+
"sentisense screen --filter SENTI_SCORE_7D:GTE:13 --sort SENTI_SCORE_7D:DESC --limit 25",
|
|
3175
|
+
"sentisense screen --etf --filter ISSUER:IN:Vanguard,iShares"
|
|
3176
|
+
],
|
|
3177
|
+
notes: [
|
|
3178
|
+
"Filters are ANDed. Operators: GTE, LTE, GT, LT, EQ, NEQ, IN, NOT_IN.",
|
|
3179
|
+
"Start from --fields for the real field names and their operators, or --list for the",
|
|
3180
|
+
"curated screens, which are also worked examples of the plan shape.",
|
|
3181
|
+
"Two field semantics catch people out: ANALYST_RATING_MEAN runs 1 to 5 with 1 as strong",
|
|
3182
|
+
"buy, so bullish is LTE 2.5, and the Score fields are banded at 5, 13, and 23 either side",
|
|
3183
|
+
"of zero, so filtering on 0.5 means any positive score.",
|
|
3184
|
+
"Nulls never match in either direction, so two opposite filters do not partition the",
|
|
3185
|
+
"universe: a stock with no data for that field is in neither result.",
|
|
3186
|
+
"Rows read a snapshot that refreshes every 20 minutes, so prices here are not live."
|
|
3187
|
+
],
|
|
3188
|
+
flags: {
|
|
3189
|
+
filter: {
|
|
3190
|
+
type: "string",
|
|
3191
|
+
placeholder: "FIELD:OP:VALUE",
|
|
3192
|
+
repeat: true,
|
|
3193
|
+
describe: "Filter leg, may be repeated"
|
|
3194
|
+
},
|
|
3195
|
+
sort: { type: "string", placeholder: "FIELD:DIR", describe: "Sort field and direction" },
|
|
3196
|
+
limit: { type: "number", placeholder: "N", describe: "Rows to return, caps at 500" },
|
|
3197
|
+
tickers: { type: "string", placeholder: "A,B,C", describe: "Screen only these symbols" },
|
|
3198
|
+
screen: { type: "string", placeholder: "id", describe: "Run a curated screen by id" },
|
|
3199
|
+
list: { type: "boolean", describe: "List the curated screens and exit" },
|
|
3200
|
+
fields: { type: "boolean", describe: "List the filterable fields and exit" },
|
|
3201
|
+
etf: { type: "boolean", describe: "Screen the ETF universe" }
|
|
3202
|
+
},
|
|
3203
|
+
async run({ args, client, full }) {
|
|
3204
|
+
rejectPositionals(args, "screen");
|
|
3205
|
+
const api = client();
|
|
3206
|
+
const etf = args.flags.etf === true;
|
|
3207
|
+
if (args.flags.fields === true) {
|
|
3208
|
+
const catalog = await api.screener.fields();
|
|
3209
|
+
const list = etf ? catalog.etf : catalog.stock;
|
|
3210
|
+
return {
|
|
3211
|
+
json: catalog,
|
|
3212
|
+
doc: doc(
|
|
3213
|
+
{
|
|
3214
|
+
kind: "head",
|
|
3215
|
+
title: field("universe", etf ? "ETF fields" : "Stock fields"),
|
|
3216
|
+
right: fields(field("count", String(list.length)))
|
|
3217
|
+
},
|
|
3218
|
+
{
|
|
3219
|
+
kind: "table",
|
|
3220
|
+
head: ["FIELD", "GROUP", "TYPE", "UNIT", "OPS"],
|
|
3221
|
+
rows: list.map((descriptor) => [
|
|
3222
|
+
cell(descriptor.name),
|
|
3223
|
+
cell(descriptor.group),
|
|
3224
|
+
cell(descriptor.type),
|
|
3225
|
+
cell(descriptor.unit ?? ""),
|
|
3226
|
+
cell(descriptor.ops.join(","))
|
|
3227
|
+
])
|
|
3228
|
+
}
|
|
3229
|
+
)
|
|
3230
|
+
};
|
|
3231
|
+
}
|
|
3232
|
+
if (args.flags.list === true) {
|
|
3233
|
+
const response2 = await api.screener.screens();
|
|
3234
|
+
return {
|
|
3235
|
+
json: response2,
|
|
3236
|
+
doc: doc(
|
|
3237
|
+
{
|
|
3238
|
+
kind: "head",
|
|
3239
|
+
title: field("scope", "Curated screens"),
|
|
3240
|
+
right: fields(field("count", String(response2.screens.length)))
|
|
3241
|
+
},
|
|
3242
|
+
{
|
|
3243
|
+
kind: "table",
|
|
3244
|
+
head: ["ID", "NAME", "SUMMARY"],
|
|
3245
|
+
rows: response2.screens.map((screen) => [
|
|
3246
|
+
cell(screen.id),
|
|
3247
|
+
cell(screen.name),
|
|
3248
|
+
cell(truncate(screen.summary, full ? 160 : 60))
|
|
3249
|
+
])
|
|
3250
|
+
},
|
|
3251
|
+
{ kind: "text", text: 'Run one with "sentisense screen --screen <id>".', tone: "dim" }
|
|
3252
|
+
)
|
|
3253
|
+
};
|
|
3254
|
+
}
|
|
3255
|
+
const limit = typeof args.flags.limit === "number" ? args.flags.limit : void 0;
|
|
3256
|
+
const tickers = typeof args.flags.tickers === "string" ? args.flags.tickers.split(",").map((t) => t.trim().toUpperCase()).filter(Boolean) : void 0;
|
|
3257
|
+
let plan;
|
|
3258
|
+
let planName;
|
|
3259
|
+
let runEtfUniverse = etf;
|
|
3260
|
+
const screenId = typeof args.flags.screen === "string" ? args.flags.screen : void 0;
|
|
3261
|
+
if (screenId) {
|
|
3262
|
+
const response2 = await api.screener.screens();
|
|
3263
|
+
const found = response2.screens.find((screen) => screen.id === screenId);
|
|
3264
|
+
if (!found) {
|
|
3265
|
+
throw new CliUsageError(
|
|
3266
|
+
`no curated screen with id "${screenId}".`,
|
|
3267
|
+
'run "sentisense screen --list" for the ids.'
|
|
3268
|
+
);
|
|
3269
|
+
}
|
|
3270
|
+
plan = found.plan;
|
|
3271
|
+
planName = found.name;
|
|
3272
|
+
runEtfUniverse = etf || found.plan.universe === "ETF";
|
|
3273
|
+
} else {
|
|
3274
|
+
const legs = flagList(args.flags, "filter").map(parseFilter);
|
|
3275
|
+
if (legs.length === 0) {
|
|
3276
|
+
throw new CliUsageError(
|
|
3277
|
+
"screen needs at least one --filter, or a --screen id.",
|
|
3278
|
+
'try "sentisense screen --list", or --filter SENTI_SCORE_7D:GTE:13'
|
|
3279
|
+
);
|
|
3280
|
+
}
|
|
3281
|
+
plan = {
|
|
3282
|
+
filters: legs,
|
|
3283
|
+
...typeof args.flags.sort === "string" ? { sort: parseSort(args.flags.sort) } : {}
|
|
3284
|
+
};
|
|
3285
|
+
planName = "custom";
|
|
3286
|
+
}
|
|
3287
|
+
const request = {
|
|
3288
|
+
plan,
|
|
3289
|
+
...limit === void 0 ? {} : { limit },
|
|
3290
|
+
...tickers ? { tickers } : {}
|
|
3291
|
+
};
|
|
3292
|
+
if (runEtfUniverse) {
|
|
3293
|
+
const response2 = await api.screener.runEtfs(request);
|
|
3294
|
+
const rows2 = response2.results ?? [];
|
|
3295
|
+
const blocks2 = [
|
|
3296
|
+
{
|
|
3297
|
+
kind: "head",
|
|
3298
|
+
title: field("screen", planName),
|
|
3299
|
+
right: fields(
|
|
3300
|
+
field("matched", String(response2.matched)),
|
|
3301
|
+
field("returned", String(rows2.length))
|
|
3302
|
+
)
|
|
3303
|
+
}
|
|
3304
|
+
];
|
|
3305
|
+
if (rows2.length === 0) {
|
|
3306
|
+
blocks2.push({ kind: "text", text: "No ETFs match this plan." });
|
|
3307
|
+
} else {
|
|
3308
|
+
blocks2.push({
|
|
3309
|
+
kind: "table",
|
|
3310
|
+
head: ["TICKER", "NAME", "ISSUER", "PRICE", "CHANGE%", "AUM", "EXPENSE", "SCORE"],
|
|
3311
|
+
align: ["left", "left", "left", "right", "right", "right", "right", "right"],
|
|
3312
|
+
rows: rows2.map((row) => [
|
|
3313
|
+
cell(row.ticker),
|
|
3314
|
+
cell(truncate(row.name ?? "", full ? 40 : 24)),
|
|
3315
|
+
cell(truncate(row.issuer ?? "", 14)),
|
|
3316
|
+
cell(fixed(row.currentPrice)),
|
|
3317
|
+
cell(fixed(row.changePercent), direction(row.changePercent)),
|
|
3318
|
+
cell(humanize(row.marketCap)),
|
|
3319
|
+
cell(percent(row.expenseRatio, 2)),
|
|
3320
|
+
cell(fixed(row.constituentsWeightedSentisense, 1), "accent")
|
|
3321
|
+
])
|
|
3322
|
+
});
|
|
3323
|
+
}
|
|
3324
|
+
if (response2.matched > rows2.length) {
|
|
3325
|
+
blocks2.push({
|
|
3326
|
+
kind: "text",
|
|
3327
|
+
text: `Matched ${response2.matched}, showing the top ${rows2.length} under the plan sort. Raise --limit for more.`,
|
|
3328
|
+
tone: "dim"
|
|
3329
|
+
});
|
|
3330
|
+
}
|
|
3331
|
+
return { json: response2, doc: doc(...blocks2) };
|
|
3332
|
+
}
|
|
3333
|
+
const response = await api.screener.run(request);
|
|
3334
|
+
const rows = response.results ?? [];
|
|
3335
|
+
const blocks = [
|
|
3336
|
+
{
|
|
3337
|
+
kind: "head",
|
|
3338
|
+
title: field("screen", planName),
|
|
3339
|
+
right: fields(
|
|
3340
|
+
field("matched", String(response.matched)),
|
|
3341
|
+
field("returned", String(rows.length))
|
|
3342
|
+
)
|
|
3343
|
+
}
|
|
3344
|
+
];
|
|
3345
|
+
if (rows.length === 0) {
|
|
3346
|
+
blocks.push({ kind: "text", text: "No stocks match this plan." });
|
|
3347
|
+
} else {
|
|
3348
|
+
blocks.push({
|
|
3349
|
+
kind: "table",
|
|
3350
|
+
head: ["TICKER", "PRICE", "CHANGE%", "SCORE 7D", "VS 1M", "MKT CAP", "BUY%", "ANALYSTS"],
|
|
3351
|
+
align: ["left", "right", "right", "right", "right", "right", "right", "right"],
|
|
3352
|
+
rows: rows.map((row) => [
|
|
3353
|
+
cell(row.ticker),
|
|
3354
|
+
cell(fixed(row.currentPrice)),
|
|
3355
|
+
cell(fixed(row.changePercent), direction(row.changePercent)),
|
|
3356
|
+
cell(fixed(row.sentiSenseScore7D, 1), "accent"),
|
|
3357
|
+
cell(signed(row.scoreChange7D, 1), direction(row.scoreChange7D)),
|
|
3358
|
+
cell(humanize(row.marketCap)),
|
|
3359
|
+
cell(fixed(row.analystBuyRatioPct, 0)),
|
|
3360
|
+
cell(row.analystCount === null || row.analystCount === void 0 ? "n/a" : String(row.analystCount))
|
|
3361
|
+
])
|
|
3362
|
+
});
|
|
3363
|
+
}
|
|
3364
|
+
if (response.matched > rows.length) {
|
|
3365
|
+
blocks.push({
|
|
3366
|
+
kind: "text",
|
|
3367
|
+
text: `Matched ${response.matched}, showing the top ${rows.length} under the plan sort. Raise --limit for more.`,
|
|
3368
|
+
tone: "dim"
|
|
3369
|
+
});
|
|
3370
|
+
}
|
|
3371
|
+
return { json: response, doc: doc(...blocks) };
|
|
3372
|
+
}
|
|
3373
|
+
};
|
|
3374
|
+
|
|
3375
|
+
// src/cli/commands/sentiment.ts
|
|
3376
|
+
var DEFAULT_DAYS = 30;
|
|
3377
|
+
function toneForDirection(value) {
|
|
3378
|
+
if (value === "Bullish") return "up";
|
|
3379
|
+
if (value === "Bearish") return "down";
|
|
3380
|
+
return void 0;
|
|
3381
|
+
}
|
|
3382
|
+
function seriesValues(metrics, fallback) {
|
|
3383
|
+
if (metrics && metrics.length > 0) {
|
|
3384
|
+
const points = metrics.map((point) => point.value).filter((value) => typeof value === "number" && Number.isFinite(value));
|
|
3385
|
+
if (points.length > 0) return points;
|
|
3386
|
+
}
|
|
3387
|
+
return fallback ?? [];
|
|
3388
|
+
}
|
|
3389
|
+
var sentimentCommand = {
|
|
3390
|
+
name: "sentiment",
|
|
3391
|
+
summary: "SentiSense Score, tone, attention, and where the conversation is",
|
|
3392
|
+
usage: "sentisense sentiment <ticker> [--days N]",
|
|
3393
|
+
examples: [
|
|
3394
|
+
"sentisense sentiment NVDA",
|
|
3395
|
+
"sentisense sentiment NVDA --days 90",
|
|
3396
|
+
"sentisense sentiment NVDA --json",
|
|
3397
|
+
"sentisense sentiment NVDA --full"
|
|
3398
|
+
],
|
|
3399
|
+
notes: [
|
|
3400
|
+
"The SentiSense Score is a composite of tone and attention centred on zero, not a",
|
|
3401
|
+
"polarity: it is unbounded, and the bands sit at 5, 13, and 23 either side of zero.",
|
|
3402
|
+
"Tone per source is the separate polarity reading, always between -1 and 1, and it is",
|
|
3403
|
+
"reported per source rather than blended into one number.",
|
|
3404
|
+
"--days sets the Score history window used for the sparkline. Default is 30."
|
|
3405
|
+
],
|
|
3406
|
+
flags: {
|
|
3407
|
+
days: { type: "number", placeholder: "N", describe: `Days of Score history (default ${DEFAULT_DAYS})` }
|
|
3408
|
+
},
|
|
3409
|
+
async run({ args, client, full }) {
|
|
3410
|
+
const ticker = oneTicker(args, "sentiment");
|
|
3411
|
+
const days = typeof args.flags.days === "number" ? args.flags.days : DEFAULT_DAYS;
|
|
3412
|
+
if (days < 1) {
|
|
3413
|
+
throw new CliUsageError("--days must be at least 1.", "for example: --days 30");
|
|
3414
|
+
}
|
|
3415
|
+
const api = client();
|
|
3416
|
+
const endTime = Date.now();
|
|
3417
|
+
const startTime = endTime - days * 24 * 60 * 60 * 1e3;
|
|
3418
|
+
const notes = [];
|
|
3419
|
+
const [envelope, metrics] = await Promise.all([
|
|
3420
|
+
api.stocks.getSentiment(ticker),
|
|
3421
|
+
// Supplementary: the headline reading already carries its own sparkline, so a gap in
|
|
3422
|
+
// the time series degrades the display rather than failing the command. It says so on
|
|
3423
|
+
// stderr, because a silent gap looks the same as a quiet week.
|
|
3424
|
+
api.entityMetrics.getMetrics(ticker, {
|
|
3425
|
+
metricType: "sentisense_score",
|
|
3426
|
+
startTime,
|
|
3427
|
+
endTime,
|
|
3428
|
+
maxDataPoints: days
|
|
3429
|
+
}).catch(() => {
|
|
3430
|
+
notes.push("score history unavailable, showing the reading without it");
|
|
3431
|
+
return null;
|
|
3432
|
+
})
|
|
3433
|
+
]);
|
|
3434
|
+
const data = envelope.data ?? {};
|
|
3435
|
+
const series = seriesValues(metrics, data.scoreSparkline);
|
|
3436
|
+
const blocks = [
|
|
3437
|
+
{
|
|
3438
|
+
kind: "head",
|
|
3439
|
+
title: field("ticker", ticker),
|
|
3440
|
+
subtitle: data.companyName ? field("name", data.companyName) : void 0,
|
|
3441
|
+
right: fields(
|
|
3442
|
+
field("score 30d", fixed(data.sentisenseScoreAvg30d, 1), "accent"),
|
|
3443
|
+
data.scoreLabel ? field("band", data.scoreLabel) : void 0,
|
|
3444
|
+
data.direction ? field("direction", data.direction, toneForDirection(data.direction)) : void 0
|
|
3445
|
+
)
|
|
3446
|
+
},
|
|
3447
|
+
{
|
|
3448
|
+
kind: "facts",
|
|
3449
|
+
items: fields(
|
|
3450
|
+
field("Latest", fixed(data.sentisenseScore, 1)),
|
|
3451
|
+
field(
|
|
3452
|
+
"30d change",
|
|
3453
|
+
signed(data.sentisenseScoreDelta30d, 1),
|
|
3454
|
+
typeof data.sentisenseScoreDelta30d === "number" ? data.sentisenseScoreDelta30d > 0 ? "up" : data.sentisenseScoreDelta30d < 0 ? "down" : void 0 : void 0
|
|
3455
|
+
),
|
|
3456
|
+
data.trend ? field("Trend", data.trend) : void 0
|
|
3457
|
+
)
|
|
3458
|
+
},
|
|
3459
|
+
{
|
|
3460
|
+
kind: "facts",
|
|
3461
|
+
items: fields(
|
|
3462
|
+
field("Mentions", humanize(data.mentions, 1)),
|
|
3463
|
+
field("30d avg", humanize(data.mentionsAvg30d, 1)),
|
|
3464
|
+
field(
|
|
3465
|
+
"Dominance",
|
|
3466
|
+
typeof data.socialDominance === "number" ? percent(data.socialDominance * 100, 2) : "n/a"
|
|
3467
|
+
)
|
|
3468
|
+
)
|
|
3469
|
+
}
|
|
3470
|
+
];
|
|
3471
|
+
if (series.length > 0) {
|
|
3472
|
+
blocks.push({
|
|
3473
|
+
kind: "spark",
|
|
3474
|
+
label: `${days}d`,
|
|
3475
|
+
series,
|
|
3476
|
+
note: data.asOf ? `as of ${data.asOf}` : void 0
|
|
3477
|
+
});
|
|
3478
|
+
}
|
|
3479
|
+
if (data.bySource && data.bySource.length > 0) {
|
|
3480
|
+
blocks.push({ kind: "blank" });
|
|
3481
|
+
blocks.push({
|
|
3482
|
+
kind: "table",
|
|
3483
|
+
head: ["SOURCE", "TONE", "SHARE", "VALUE"],
|
|
3484
|
+
align: ["left", "left", "right", "right"],
|
|
3485
|
+
rows: data.bySource.map((source) => [
|
|
3486
|
+
cell(source.source),
|
|
3487
|
+
cell(source.direction, toneForDirection(source.direction)),
|
|
3488
|
+
cell(percent(source.mentionShare, 0)),
|
|
3489
|
+
cell(signed(source.value, 2))
|
|
3490
|
+
])
|
|
3491
|
+
});
|
|
3492
|
+
blocks.push({
|
|
3493
|
+
kind: "text",
|
|
3494
|
+
text: "Shares are rounded per source, so they sum to about 100 rather than exactly 100.",
|
|
3495
|
+
tone: "dim"
|
|
3496
|
+
});
|
|
3497
|
+
}
|
|
3498
|
+
if (full && data.drivers && data.drivers.length > 0) {
|
|
3499
|
+
blocks.push({ kind: "blank" });
|
|
3500
|
+
blocks.push({
|
|
3501
|
+
kind: "table",
|
|
3502
|
+
head: ["TONE", "DRIVER"],
|
|
3503
|
+
align: ["right", "left"],
|
|
3504
|
+
rows: data.drivers.map((driver) => [
|
|
3505
|
+
cell(signed(driver.tone, 2), driver.tone > 0 ? "up" : driver.tone < 0 ? "down" : void 0),
|
|
3506
|
+
cell(driver.title)
|
|
3507
|
+
])
|
|
3508
|
+
});
|
|
3509
|
+
}
|
|
3510
|
+
if (full && data.narrative) {
|
|
3511
|
+
blocks.push({ kind: "blank" });
|
|
3512
|
+
blocks.push({ kind: "text", text: data.narrative });
|
|
3513
|
+
}
|
|
3514
|
+
if (envelope.isPreview) {
|
|
3515
|
+
blocks.push({
|
|
3516
|
+
kind: "text",
|
|
3517
|
+
text: "Preview response: a PRO key returns the full reading.",
|
|
3518
|
+
tone: "dim"
|
|
3519
|
+
});
|
|
3520
|
+
}
|
|
3521
|
+
return {
|
|
3522
|
+
json: { sentiment: envelope, series: metrics },
|
|
3523
|
+
doc: doc(...blocks),
|
|
3524
|
+
notes
|
|
3525
|
+
};
|
|
3526
|
+
}
|
|
3527
|
+
};
|
|
3528
|
+
|
|
3529
|
+
// src/cli/commands/index.ts
|
|
3530
|
+
var COMMANDS = [
|
|
3531
|
+
authCommand,
|
|
3532
|
+
healthCommand,
|
|
3533
|
+
quoteCommand,
|
|
3534
|
+
sentimentCommand,
|
|
3535
|
+
moodCommand,
|
|
3536
|
+
analystsCommand,
|
|
3537
|
+
earningsCommand,
|
|
3538
|
+
insidersCommand,
|
|
3539
|
+
insightsCommand,
|
|
3540
|
+
congressCommand,
|
|
3541
|
+
newsCommand,
|
|
3542
|
+
flowsCommand,
|
|
3543
|
+
optionsCommand,
|
|
3544
|
+
screenCommand
|
|
3545
|
+
];
|
|
3546
|
+
var COMMAND_NAMES = COMMANDS.map((command) => command.name);
|
|
3547
|
+
function findCommand(name) {
|
|
3548
|
+
return COMMANDS.find((command) => command.name === name);
|
|
3549
|
+
}
|
|
3550
|
+
var OFFLINE_COMMANDS = /* @__PURE__ */ new Set(["auth"]);
|
|
3551
|
+
|
|
3552
|
+
// src/cli/help.ts
|
|
3553
|
+
var EXAMPLE = {
|
|
3554
|
+
auth: "sentisense auth $SENTISENSE_API_KEY",
|
|
3555
|
+
health: "sentisense health",
|
|
3556
|
+
quote: "sentisense quote NVDA AAPL",
|
|
3557
|
+
sentiment: "sentisense sentiment NVDA --days 30",
|
|
3558
|
+
mood: "sentisense mood",
|
|
3559
|
+
analysts: "sentisense analysts NVDA",
|
|
3560
|
+
earnings: "sentisense earnings --week next",
|
|
3561
|
+
insiders: "sentisense insiders NVDA",
|
|
3562
|
+
insights: "sentisense insights NVDA",
|
|
3563
|
+
congress: "sentisense congress NVDA",
|
|
3564
|
+
news: "sentisense news NVDA",
|
|
3565
|
+
flows: "sentisense flows NVDA",
|
|
3566
|
+
options: "sentisense options NVDA",
|
|
3567
|
+
screen: "sentisense screen --filter SENTI_SCORE_7D:GTE:13"
|
|
3568
|
+
};
|
|
3569
|
+
function pad(text, width) {
|
|
3570
|
+
return text.length >= width ? text : text + " ".repeat(width - text.length);
|
|
3571
|
+
}
|
|
3572
|
+
function flagLines(specs) {
|
|
3573
|
+
const entries = Object.entries(specs);
|
|
3574
|
+
if (entries.length === 0) return [];
|
|
3575
|
+
const rendered = entries.map(([name, spec]) => ({
|
|
3576
|
+
left: spec.type === "boolean" ? `--${name}` : `--${name} <${spec.placeholder ?? "value"}>`,
|
|
3577
|
+
describe: spec.describe
|
|
3578
|
+
}));
|
|
3579
|
+
const width = Math.max(...rendered.map((entry) => entry.left.length));
|
|
3580
|
+
return rendered.map((entry) => ` ${pad(entry.left, width)} ${entry.describe}`);
|
|
3581
|
+
}
|
|
3582
|
+
function mainHelp() {
|
|
3583
|
+
const width = Math.max(...COMMANDS.map((command) => command.name.length));
|
|
3584
|
+
const lines = [
|
|
3585
|
+
`sentisense ${VERSION}`,
|
|
3586
|
+
"Market data, sentiment, and filings from the command line.",
|
|
3587
|
+
"",
|
|
3588
|
+
"Usage: sentisense <command> [arguments] [flags]",
|
|
3589
|
+
"",
|
|
3590
|
+
"Commands:",
|
|
3591
|
+
...COMMANDS.map(
|
|
3592
|
+
(command) => ` ${pad(command.name, width)} ${command.summary}
|
|
3593
|
+
${" ".repeat(width)} ${EXAMPLE[command.name] ?? ""}`
|
|
3594
|
+
),
|
|
3595
|
+
"",
|
|
3596
|
+
"Output:",
|
|
3597
|
+
" Pretty in a terminal, plain text when piped, exact API JSON with --json.",
|
|
3598
|
+
" --full widens a command, --plain and --no-color force text, --debug shows stack traces.",
|
|
3599
|
+
"",
|
|
3600
|
+
"Setup:",
|
|
3601
|
+
` sentisense auth <key> store a key at ~/.config/sentisense/config.json (0600)`,
|
|
3602
|
+
" SENTISENSE_API_KEY=<key> or pass it in the environment",
|
|
3603
|
+
` Get a key at ${KEY_URL}`,
|
|
3604
|
+
"",
|
|
3605
|
+
'Run "sentisense help <command>" for flags, examples, and exit codes.',
|
|
3606
|
+
"",
|
|
3607
|
+
"Research data, not investment advice."
|
|
3608
|
+
];
|
|
3609
|
+
return `${lines.join("\n")}
|
|
3610
|
+
`;
|
|
3611
|
+
}
|
|
3612
|
+
function commandHelp(command) {
|
|
3613
|
+
const lines = [
|
|
3614
|
+
`sentisense ${command.name} ${command.summary}`,
|
|
3615
|
+
"",
|
|
3616
|
+
`Usage: ${command.usage}`
|
|
3617
|
+
];
|
|
3618
|
+
if (command.notes && command.notes.length > 0) {
|
|
3619
|
+
lines.push("", ...command.notes);
|
|
3620
|
+
}
|
|
3621
|
+
const own = flagLines(command.flags);
|
|
3622
|
+
if (own.length > 0) lines.push("", "Flags:", ...own);
|
|
3623
|
+
lines.push("", "Common flags:", ...flagLines(GLOBAL_FLAGS));
|
|
3624
|
+
lines.push("", "Examples:", ...command.examples.map((example) => ` ${example}`));
|
|
3625
|
+
lines.push(
|
|
3626
|
+
"",
|
|
3627
|
+
"Exit codes:",
|
|
3628
|
+
...EXIT_TABLE.map(([code, meaning]) => ` ${code} ${meaning}`)
|
|
3629
|
+
);
|
|
3630
|
+
return `${lines.join("\n")}
|
|
3631
|
+
`;
|
|
3632
|
+
}
|
|
3633
|
+
function versionLine() {
|
|
3634
|
+
return `${VERSION}
|
|
3635
|
+
`;
|
|
3636
|
+
}
|
|
3637
|
+
|
|
3638
|
+
// src/cli/render/ansi.ts
|
|
3639
|
+
var CODES = {
|
|
3640
|
+
up: "32",
|
|
3641
|
+
down: "31",
|
|
3642
|
+
accent: "33",
|
|
3643
|
+
dim: "2",
|
|
3644
|
+
bold: "1"
|
|
3645
|
+
};
|
|
3646
|
+
var ESC = "\x1B";
|
|
3647
|
+
function wrap(text, code) {
|
|
3648
|
+
return `${ESC}[${code}m${text}${ESC}[0m`;
|
|
3649
|
+
}
|
|
3650
|
+
function createStyler(enabled) {
|
|
3651
|
+
if (!enabled) {
|
|
3652
|
+
const passthrough = (text) => text;
|
|
3653
|
+
return { enabled: false, tone: passthrough, bold: passthrough, dim: passthrough };
|
|
3654
|
+
}
|
|
3655
|
+
return {
|
|
3656
|
+
enabled: true,
|
|
3657
|
+
tone: (text, tone) => tone ? wrap(text, CODES[tone]) : text,
|
|
3658
|
+
bold: (text) => wrap(text, CODES.bold),
|
|
3659
|
+
dim: (text) => wrap(text, CODES.dim)
|
|
3660
|
+
};
|
|
3661
|
+
}
|
|
3662
|
+
function shouldColor({ pretty, noColorFlag, env, isTTY }) {
|
|
3663
|
+
if (!pretty || noColorFlag) return false;
|
|
3664
|
+
if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
|
|
3665
|
+
return isTTY === true;
|
|
3666
|
+
}
|
|
3667
|
+
|
|
3668
|
+
// src/cli/render/json.ts
|
|
3669
|
+
function renderJson(payload) {
|
|
3670
|
+
return `${JSON.stringify(payload, null, 2)}
|
|
3671
|
+
`;
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
// src/cli/render/plain.ts
|
|
3675
|
+
function padEnd(text, width) {
|
|
3676
|
+
return text.length >= width ? text : text + " ".repeat(width - text.length);
|
|
3677
|
+
}
|
|
3678
|
+
function padStart(text, width) {
|
|
3679
|
+
return text.length >= width ? text : " ".repeat(width - text.length) + text;
|
|
3680
|
+
}
|
|
3681
|
+
function renderFields(items) {
|
|
3682
|
+
if (items.length === 0) return [];
|
|
3683
|
+
const width = Math.max(...items.map((f) => f.label.length + 1));
|
|
3684
|
+
return items.map((f) => `${padEnd(`${f.label}:`, width)} ${f.value.text}`);
|
|
3685
|
+
}
|
|
3686
|
+
function renderBlock(block) {
|
|
3687
|
+
switch (block.kind) {
|
|
3688
|
+
case "head":
|
|
3689
|
+
return renderFields([block.title, ...block.subtitle ? [block.subtitle] : [], ...block.right ?? []]);
|
|
3690
|
+
case "facts":
|
|
3691
|
+
case "kv":
|
|
3692
|
+
return renderFields(block.items);
|
|
3693
|
+
case "spark": {
|
|
3694
|
+
const line = `${block.label}: ${sparkline(block.series)}`;
|
|
3695
|
+
return block.note ? [line, `${block.label} note: ${block.note}`] : [line];
|
|
3696
|
+
}
|
|
3697
|
+
case "table": {
|
|
3698
|
+
if (block.rows.length === 0) return [];
|
|
3699
|
+
const widths = block.head.map(
|
|
3700
|
+
(header, i) => Math.max(header.length, ...block.rows.map((row) => row[i]?.text.length ?? 0))
|
|
3701
|
+
);
|
|
3702
|
+
const align = (i) => block.align?.[i] ?? "left";
|
|
3703
|
+
const pad2 = (text, i) => align(i) === "right" ? padStart(text, widths[i]) : padEnd(text, widths[i]);
|
|
3704
|
+
const line = (cells) => cells.map((text, i) => pad2(text, i)).join(" ").trimEnd();
|
|
3705
|
+
return [
|
|
3706
|
+
line(block.head),
|
|
3707
|
+
...block.rows.map((row) => line(block.head.map((_, i) => row[i]?.text ?? "")))
|
|
3708
|
+
];
|
|
3709
|
+
}
|
|
3710
|
+
case "text":
|
|
3711
|
+
return [block.text];
|
|
3712
|
+
case "blank":
|
|
3713
|
+
return [""];
|
|
3714
|
+
}
|
|
3715
|
+
}
|
|
3716
|
+
function renderPlain(document) {
|
|
3717
|
+
const lines = [];
|
|
3718
|
+
for (const block of document.blocks) lines.push(...renderBlock(block));
|
|
3719
|
+
return lines.length === 0 ? "" : `${lines.join("\n")}
|
|
3720
|
+
`;
|
|
3721
|
+
}
|
|
3722
|
+
|
|
3723
|
+
// src/cli/render/pretty.ts
|
|
3724
|
+
var WIDTH = 78;
|
|
3725
|
+
function padEnd2(text, width) {
|
|
3726
|
+
return text.length >= width ? text : text + " ".repeat(width - text.length);
|
|
3727
|
+
}
|
|
3728
|
+
function padStart2(text, width) {
|
|
3729
|
+
return text.length >= width ? text : " ".repeat(width - text.length) + text;
|
|
3730
|
+
}
|
|
3731
|
+
function renderBlock2(block, s) {
|
|
3732
|
+
switch (block.kind) {
|
|
3733
|
+
case "head": {
|
|
3734
|
+
const left = block.subtitle ? `${s.bold(block.title.value.text)} ${s.dim(block.subtitle.value.text)}` : s.bold(block.title.value.text);
|
|
3735
|
+
const plainLeftWidth = block.title.value.text.length + (block.subtitle ? block.subtitle.value.text.length + 2 : 0);
|
|
3736
|
+
if (!block.right || block.right.length === 0) return [left];
|
|
3737
|
+
const rightPlain = block.right.map((f) => f.value.text).join(" ");
|
|
3738
|
+
const right = block.right.map((f) => s.tone(f.value.text, f.value.tone)).join(" ");
|
|
3739
|
+
const gap = Math.max(2, WIDTH - plainLeftWidth - rightPlain.length);
|
|
3740
|
+
return [`${left}${" ".repeat(gap)}${right}`];
|
|
3741
|
+
}
|
|
3742
|
+
case "facts":
|
|
3743
|
+
if (block.items.length === 0) return [];
|
|
3744
|
+
return [
|
|
3745
|
+
block.items.map((f) => `${s.dim(f.label)} ${s.tone(f.value.text, f.value.tone)}`).join(" ")
|
|
3746
|
+
];
|
|
3747
|
+
case "kv": {
|
|
3748
|
+
if (block.items.length === 0) return [];
|
|
3749
|
+
const width = Math.max(...block.items.map((f) => f.label.length));
|
|
3750
|
+
return block.items.map(
|
|
3751
|
+
(f) => `${s.dim(padEnd2(f.label, width))} ${s.tone(f.value.text, f.value.tone)}`
|
|
3752
|
+
);
|
|
3753
|
+
}
|
|
3754
|
+
case "spark": {
|
|
3755
|
+
const line = `${s.dim(block.label)} ${sparkline(block.series)}`;
|
|
3756
|
+
return [block.note ? `${line} ${s.dim(block.note)}` : line];
|
|
3757
|
+
}
|
|
3758
|
+
case "table": {
|
|
3759
|
+
if (block.rows.length === 0) return [];
|
|
3760
|
+
const columns = block.head.length;
|
|
3761
|
+
const widths = block.head.map(
|
|
3762
|
+
(header, i) => Math.max(header.length, ...block.rows.map((row) => row[i]?.text.length ?? 0))
|
|
3763
|
+
);
|
|
3764
|
+
const align = (i) => block.align?.[i] ?? "left";
|
|
3765
|
+
const pad2 = (text, i) => align(i) === "right" ? padStart2(text, widths[i]) : padEnd2(text, widths[i]);
|
|
3766
|
+
const lines = [
|
|
3767
|
+
s.dim(
|
|
3768
|
+
block.head.map((header, i) => pad2(header, i)).join(" ").trimEnd()
|
|
3769
|
+
)
|
|
3770
|
+
];
|
|
3771
|
+
for (const row of block.rows) {
|
|
3772
|
+
const cells = [];
|
|
3773
|
+
for (let i = 0; i < columns; i++) {
|
|
3774
|
+
const value = row[i] ?? { text: "" };
|
|
3775
|
+
const padded = pad2(value.text, i);
|
|
3776
|
+
cells.push(value.tone ? s.tone(padded, value.tone) : padded);
|
|
3777
|
+
}
|
|
3778
|
+
lines.push(cells.join(" ").trimEnd());
|
|
3779
|
+
}
|
|
3780
|
+
return lines;
|
|
3781
|
+
}
|
|
3782
|
+
case "text":
|
|
3783
|
+
return [s.tone(block.text, block.tone)];
|
|
3784
|
+
case "blank":
|
|
3785
|
+
return [""];
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
function renderPretty(document, color) {
|
|
3789
|
+
const s = createStyler(color);
|
|
3790
|
+
const lines = [];
|
|
3791
|
+
for (const block of document.blocks) lines.push(...renderBlock2(block, s));
|
|
3792
|
+
return lines.length === 0 ? "" : `${lines.join("\n")}
|
|
3793
|
+
`;
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3796
|
+
// src/cli/run.ts
|
|
3797
|
+
function outputMode(flags, isTTY) {
|
|
3798
|
+
if (flags.json === true) return "json";
|
|
3799
|
+
if (flags.plain === true) return "plain";
|
|
3800
|
+
if (flags.pretty === true) return "pretty";
|
|
3801
|
+
return isTTY === true ? "pretty" : "plain";
|
|
3802
|
+
}
|
|
3803
|
+
async function runCli(argv, io) {
|
|
3804
|
+
const { command: name, rest } = splitCommand(argv);
|
|
3805
|
+
if (!name) {
|
|
3806
|
+
const bare = parseArgs(rest, GLOBAL_FLAGS);
|
|
3807
|
+
if (bare.flags.version === true) {
|
|
3808
|
+
io.stdout(versionLine());
|
|
3809
|
+
return EXIT.OK;
|
|
3810
|
+
}
|
|
3811
|
+
io.stdout(mainHelp());
|
|
3812
|
+
return EXIT.OK;
|
|
3813
|
+
}
|
|
3814
|
+
try {
|
|
3815
|
+
if (name === "help") {
|
|
3816
|
+
const target = rest.find((token) => !token.startsWith("-"));
|
|
3817
|
+
if (!target) {
|
|
3818
|
+
io.stdout(mainHelp());
|
|
3819
|
+
return EXIT.OK;
|
|
3820
|
+
}
|
|
3821
|
+
const command2 = findCommand(target);
|
|
3822
|
+
if (!command2) throw unknownCommand(target);
|
|
3823
|
+
io.stdout(commandHelp(command2));
|
|
3824
|
+
return EXIT.OK;
|
|
3825
|
+
}
|
|
3826
|
+
const command = findCommand(name);
|
|
3827
|
+
if (!command) throw unknownCommand(name);
|
|
3828
|
+
const specs = { ...GLOBAL_FLAGS, ...command.flags };
|
|
3829
|
+
const args = parseArgs(rest, specs);
|
|
3830
|
+
if (args.flags.help === true) {
|
|
3831
|
+
io.stdout(commandHelp(command));
|
|
3832
|
+
return EXIT.OK;
|
|
3833
|
+
}
|
|
3834
|
+
if (args.flags.version === true) {
|
|
3835
|
+
io.stdout(versionLine());
|
|
3836
|
+
return EXIT.OK;
|
|
3837
|
+
}
|
|
3838
|
+
const context = resolveContext({ flags: args.flags, env: io.env, configDir: io.configDir });
|
|
3839
|
+
let cached;
|
|
3840
|
+
const client = () => {
|
|
3841
|
+
if (!cached) cached = createClient(context);
|
|
3842
|
+
return cached;
|
|
3843
|
+
};
|
|
3844
|
+
const mode = outputMode(args.flags, io.isTTY);
|
|
3845
|
+
const result = await command.run({
|
|
3846
|
+
args,
|
|
3847
|
+
context,
|
|
3848
|
+
io: { env: io.env, isTTY: io.isTTY, configDir: io.configDir },
|
|
3849
|
+
client: OFFLINE_COMMANDS.has(command.name) ? () => {
|
|
3850
|
+
throw new Error(`${command.name} does not call the API`);
|
|
3851
|
+
} : client,
|
|
3852
|
+
full: args.flags.full === true,
|
|
3853
|
+
mode
|
|
3854
|
+
});
|
|
3855
|
+
const color = shouldColor({
|
|
3856
|
+
pretty: mode === "pretty",
|
|
3857
|
+
noColorFlag: args.flags["no-color"] === true,
|
|
3858
|
+
env: io.env,
|
|
3859
|
+
isTTY: io.isTTY
|
|
3860
|
+
});
|
|
3861
|
+
for (const note of result.notes ?? []) {
|
|
3862
|
+
io.stderr(`${createStyler(color).dim(`note: ${note}`)}
|
|
3863
|
+
`);
|
|
3864
|
+
}
|
|
3865
|
+
if (mode === "json") {
|
|
3866
|
+
io.stdout(renderJson(result.json));
|
|
3867
|
+
} else if (mode === "pretty") {
|
|
3868
|
+
io.stdout(renderPretty(result.doc, color));
|
|
3869
|
+
} else {
|
|
3870
|
+
io.stdout(renderPlain(result.doc));
|
|
3871
|
+
}
|
|
3872
|
+
return result.exitCode ?? EXIT.OK;
|
|
3873
|
+
} catch (error) {
|
|
3874
|
+
const debug = argv.includes("--debug");
|
|
3875
|
+
const report = describeError(error, debug);
|
|
3876
|
+
for (const line of report.lines) io.stderr(`${line}
|
|
3877
|
+
`);
|
|
3878
|
+
return report.exitCode;
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
3881
|
+
function unknownCommand(name) {
|
|
3882
|
+
const suggestion = nearest(name, COMMAND_NAMES);
|
|
3883
|
+
return new CliUsageError(
|
|
3884
|
+
`unknown command "${name}".`,
|
|
3885
|
+
suggestion ? `did you mean "${suggestion}"? Run "sentisense --help" for the full list.` : 'run "sentisense --help" for the command list.'
|
|
3886
|
+
);
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
// src/cli/main.ts
|
|
3890
|
+
async function main() {
|
|
3891
|
+
process.exitCode = await runCli(process.argv.slice(2), {
|
|
3892
|
+
stdout: (chunk) => process.stdout.write(chunk),
|
|
3893
|
+
stderr: (chunk) => process.stderr.write(chunk),
|
|
3894
|
+
env: process.env,
|
|
3895
|
+
isTTY: Boolean(process.stdout.isTTY)
|
|
3896
|
+
});
|
|
3897
|
+
}
|
|
3898
|
+
main().catch((error) => {
|
|
3899
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3900
|
+
process.stderr.write(`error: ${message}
|
|
3901
|
+
`);
|
|
3902
|
+
process.stderr.write("next: run the same command with --debug for the stack trace.\n");
|
|
3903
|
+
process.exitCode = 1;
|
|
3904
|
+
});
|