@pungoyal/kite-cli 0.1.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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +292 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +7 -0
  5. package/dist/commands/auth.d.ts +2 -0
  6. package/dist/commands/auth.js +265 -0
  7. package/dist/commands/config.d.ts +2 -0
  8. package/dist/commands/config.js +182 -0
  9. package/dist/commands/gtt.d.ts +12 -0
  10. package/dist/commands/gtt.js +276 -0
  11. package/dist/commands/market.d.ts +2 -0
  12. package/dist/commands/market.js +389 -0
  13. package/dist/commands/orders.d.ts +2 -0
  14. package/dist/commands/orders.js +679 -0
  15. package/dist/commands/portfolio.d.ts +2 -0
  16. package/dist/commands/portfolio.js +286 -0
  17. package/dist/commands/types.d.ts +16 -0
  18. package/dist/commands/types.js +1 -0
  19. package/dist/commands/watch.d.ts +18 -0
  20. package/dist/commands/watch.js +285 -0
  21. package/dist/context.d.ts +40 -0
  22. package/dist/context.js +126 -0
  23. package/dist/core/api.d.ts +843 -0
  24. package/dist/core/api.js +472 -0
  25. package/dist/core/auth.d.ts +68 -0
  26. package/dist/core/auth.js +220 -0
  27. package/dist/core/client.d.ts +76 -0
  28. package/dist/core/client.js +285 -0
  29. package/dist/core/config.d.ts +114 -0
  30. package/dist/core/config.js +163 -0
  31. package/dist/core/credentials.d.ts +27 -0
  32. package/dist/core/credentials.js +182 -0
  33. package/dist/core/errors.d.ts +83 -0
  34. package/dist/core/errors.js +152 -0
  35. package/dist/core/instruments.d.ts +87 -0
  36. package/dist/core/instruments.js +288 -0
  37. package/dist/core/paths.d.ts +11 -0
  38. package/dist/core/paths.js +56 -0
  39. package/dist/core/ratelimit.d.ts +57 -0
  40. package/dist/core/ratelimit.js +196 -0
  41. package/dist/core/redact.d.ts +48 -0
  42. package/dist/core/redact.js +181 -0
  43. package/dist/core/schemas.d.ts +662 -0
  44. package/dist/core/schemas.js +433 -0
  45. package/dist/core/secretstore.d.ts +11 -0
  46. package/dist/core/secretstore.js +138 -0
  47. package/dist/core/session.d.ts +37 -0
  48. package/dist/core/session.js +102 -0
  49. package/dist/core/ticker.d.ts +131 -0
  50. package/dist/core/ticker.js +367 -0
  51. package/dist/index.d.ts +17 -0
  52. package/dist/index.js +17 -0
  53. package/dist/output/format.d.ts +31 -0
  54. package/dist/output/format.js +162 -0
  55. package/dist/output/io.d.ts +70 -0
  56. package/dist/output/io.js +143 -0
  57. package/dist/output/table.d.ts +28 -0
  58. package/dist/output/table.js +73 -0
  59. package/dist/run.d.ts +15 -0
  60. package/dist/run.js +161 -0
  61. package/dist/safety.d.ts +93 -0
  62. package/dist/safety.js +154 -0
  63. package/package.json +85 -0
@@ -0,0 +1,286 @@
1
+ import { PRODUCTS } from '../core/api.js';
2
+ import { UsageError } from '../core/errors.js';
3
+ import { money, percent, quantity, rupees, signedRupees } from '../output/format.js';
4
+ import { heading, printTable, renderKeyValue } from '../output/table.js';
5
+ import { confirmAction } from '../safety.js';
6
+ export const portfolioCommands = (program, run) => {
7
+ program
8
+ .command('holdings')
9
+ .description('Show your long-term holdings')
10
+ .option('--sort <field>', 'Sort by: symbol, value, pnl, day', 'value')
11
+ .action(run(holdings));
12
+ program
13
+ .command('positions')
14
+ .description('Show your open positions')
15
+ .option('--day', 'Show intraday positions instead of net')
16
+ .action(run(positions));
17
+ program
18
+ .command('funds')
19
+ .description('Show available margin and funds')
20
+ .option('--segment <segment>', 'equity or commodity')
21
+ .action(run(funds));
22
+ program
23
+ .command('authorise')
24
+ .alias('authorize')
25
+ .description('Authorise holdings at the depository so they can be sold (recovers from HTTP 428)')
26
+ .argument('[isins...]', 'Specific ISINs to authorise. Omit to authorise the whole demat account.')
27
+ .action(run(authoriseHoldings));
28
+ const convert = program
29
+ .command('convert')
30
+ .description('Convert a position between products (e.g. MIS to CNC)')
31
+ .argument('<instrument>', 'Instrument as EXCHANGE:SYMBOL, e.g. NSE:INFY')
32
+ .requiredOption('--quantity <n>', 'Quantity to convert')
33
+ .requiredOption('--from <product>', `Current product (${PRODUCTS.join(', ')})`)
34
+ .requiredOption('--to <product>', `Target product (${PRODUCTS.join(', ')})`)
35
+ .option('--transaction-type <type>', 'BUY or SELL', 'BUY')
36
+ .option('--position-type <type>', 'overnight or day', 'day')
37
+ .action(run(convertPosition));
38
+ void convert;
39
+ };
40
+ async function holdings(ctx, opts) {
41
+ ctx.requireSession();
42
+ const rows = await ctx.api.getHoldings(ctx.signal);
43
+ const sorted = [...rows].sort(sorter(opts.sort ?? 'value'));
44
+ const totalInvested = sorted.reduce((sum, h) => sum + h.average_price * h.quantity, 0);
45
+ const totalCurrent = sorted.reduce((sum, h) => sum + h.last_price * h.quantity, 0);
46
+ const totalPnl = totalCurrent - totalInvested;
47
+ const totalDayChange = sorted.reduce((sum, h) => sum + h.day_change * h.quantity, 0);
48
+ const columns = [
49
+ { header: 'Symbol', value: (h, io) => io.bold(h.tradingsymbol) },
50
+ { header: 'Exch', value: (h) => h.exchange },
51
+ {
52
+ header: 'Qty',
53
+ value: (h) => quantity(h.quantity + h.t1_quantity),
54
+ align: 'right',
55
+ },
56
+ { header: 'Avg', value: (h) => money(h.average_price), align: 'right' },
57
+ { header: 'LTP', value: (h) => money(h.last_price), align: 'right' },
58
+ {
59
+ header: 'Value',
60
+ value: (h) => money(h.last_price * h.quantity),
61
+ align: 'right',
62
+ },
63
+ {
64
+ header: 'P&L',
65
+ value: (h, io) => io.signed(h.pnl, signedRupees(h.pnl)),
66
+ align: 'right',
67
+ },
68
+ {
69
+ header: 'P&L %',
70
+ value: (h, io) => {
71
+ const invested = h.average_price * h.quantity;
72
+ const pct = invested === 0 ? 0 : (h.pnl / invested) * 100;
73
+ return io.signed(pct, percent(pct));
74
+ },
75
+ align: 'right',
76
+ },
77
+ {
78
+ header: 'Day',
79
+ value: (h, io) => io.signed(h.day_change_percentage, percent(h.day_change_percentage)),
80
+ align: 'right',
81
+ },
82
+ ];
83
+ printTable(ctx.io, sorted, columns, rows, {
84
+ compact: ctx.config.output.compact,
85
+ empty: 'No holdings.',
86
+ });
87
+ if (ctx.io.json)
88
+ return;
89
+ const { io } = ctx;
90
+ io.line(renderKeyValue(io, [
91
+ ['Invested', rupees(totalInvested)],
92
+ ['Current', rupees(totalCurrent)],
93
+ [
94
+ 'P&L',
95
+ io.signed(totalPnl, `${signedRupees(totalPnl)} ${percent(totalInvested === 0 ? 0 : (totalPnl / totalInvested) * 100)}`),
96
+ ],
97
+ ["Day's change", io.signed(totalDayChange, signedRupees(totalDayChange))],
98
+ ]));
99
+ }
100
+ function sorter(field) {
101
+ switch (field) {
102
+ case 'symbol':
103
+ return (a, b) => a.tradingsymbol.localeCompare(b.tradingsymbol);
104
+ case 'pnl':
105
+ return (a, b) => b.pnl - a.pnl;
106
+ case 'day':
107
+ return (a, b) => b.day_change_percentage - a.day_change_percentage;
108
+ case 'value':
109
+ return (a, b) => b.last_price * b.quantity - a.last_price * a.quantity;
110
+ default:
111
+ throw new UsageError(`Unknown sort field "${field}".`, 'Valid fields: symbol, value, pnl, day.');
112
+ }
113
+ }
114
+ async function positions(ctx, opts) {
115
+ ctx.requireSession();
116
+ const result = await ctx.api.getPositions(ctx.signal);
117
+ const rows = opts.day ? result.day : result.net;
118
+ const columns = [
119
+ { header: 'Symbol', value: (p, io) => io.bold(p.tradingsymbol) },
120
+ { header: 'Exch', value: (p) => p.exchange },
121
+ { header: 'Product', value: (p) => p.product ?? '—' },
122
+ {
123
+ header: 'Qty',
124
+ value: (p, io) => io.signed(p.quantity, quantity(p.quantity)),
125
+ align: 'right',
126
+ },
127
+ { header: 'Avg', value: (p) => money(p.average_price), align: 'right' },
128
+ { header: 'LTP', value: (p) => money(p.last_price), align: 'right' },
129
+ {
130
+ header: 'P&L',
131
+ value: (p, io) => io.signed(p.pnl, signedRupees(p.pnl)),
132
+ align: 'right',
133
+ },
134
+ {
135
+ header: 'M2M',
136
+ value: (p, io) => io.signed(p.m2m, signedRupees(p.m2m)),
137
+ align: 'right',
138
+ },
139
+ ];
140
+ // The JSON payload is always the array of positions being displayed. Emitting
141
+ // the whole {net, day} envelope for one flag and a bare array for the other
142
+ // would force every consumer to branch on the flag they passed.
143
+ printTable(ctx.io, rows, columns, rows, {
144
+ compact: ctx.config.output.compact,
145
+ empty: opts.day ? 'No intraday positions.' : 'No open positions.',
146
+ });
147
+ if (ctx.io.json)
148
+ return;
149
+ const totalPnl = rows.reduce((sum, p) => sum + p.pnl, 0);
150
+ const { io } = ctx;
151
+ io.line(renderKeyValue(io, [['Total P&L', io.signed(totalPnl, signedRupees(totalPnl))]]));
152
+ const open = rows.filter((p) => p.quantity !== 0);
153
+ if (open.length > 0 && !opts.day) {
154
+ io.note('');
155
+ io.info('To exit a position, place an opposite order with the SAME product — otherwise it opens a new position.');
156
+ }
157
+ }
158
+ async function funds(ctx, opts) {
159
+ ctx.requireSession();
160
+ const margins = await ctx.api.getMargins(ctx.signal);
161
+ if (ctx.io.json) {
162
+ ctx.io.writeJson(margins);
163
+ return;
164
+ }
165
+ const { io } = ctx;
166
+ const segments = [
167
+ ['Equity', margins.equity],
168
+ ['Commodity', margins.commodity],
169
+ ];
170
+ for (const [name, segment] of segments) {
171
+ if (!segment)
172
+ continue;
173
+ if (opts.segment && opts.segment.toLowerCase() !== name.toLowerCase())
174
+ continue;
175
+ io.line(heading(io, name));
176
+ io.line(renderKeyValue(io, [
177
+ ['Available', rupees(segment.net)],
178
+ ['Cash', rupees(segment.available?.cash)],
179
+ ['Opening balance', rupees(segment.available?.opening_balance)],
180
+ ['Collateral', rupees(segment.available?.collateral)],
181
+ ['Used', rupees(segment.utilised?.debits)],
182
+ ['SPAN', rupees(segment.utilised?.span)],
183
+ ['Exposure', rupees(segment.utilised?.exposure)],
184
+ ['Option premium', rupees(segment.utilised?.option_premium)],
185
+ ['Realised P&L', io.signed(segment.utilised?.m2m_realised ?? 0, signedRupees(segment.utilised?.m2m_realised))],
186
+ [
187
+ 'Unrealised P&L',
188
+ io.signed(segment.utilised?.m2m_unrealised ?? 0, signedRupees(segment.utilised?.m2m_unrealised)),
189
+ ],
190
+ ]));
191
+ }
192
+ }
193
+ /**
194
+ * Recover from HTTP 428 — "N quantity needs authorisation at depository".
195
+ *
196
+ * Selling shares held in demat requires a CDSL authorisation the user must
197
+ * complete in a browser. We request an id, then hand them the URL. The
198
+ * authorisation is valid until 5:30 PM the same day.
199
+ */
200
+ async function authoriseHoldings(ctx, _opts, command) {
201
+ ctx.requireSession();
202
+ const isins = command.args.map((isin) => isin.trim().toUpperCase()).filter(Boolean);
203
+ const result = await ctx.api.authoriseHoldings(isins, ctx.signal);
204
+ const url = ctx.api.authorisationUrl(result.request_id);
205
+ if (ctx.io.json) {
206
+ ctx.io.writeJson({ request_id: result.request_id, url, isins });
207
+ return;
208
+ }
209
+ const { io } = ctx;
210
+ io.note('');
211
+ io.info(isins.length > 0
212
+ ? `Authorising ${isins.length} instrument(s): ${isins.join(', ')}`
213
+ : 'Authorising your entire demat account.');
214
+ io.note('');
215
+ io.note(` ${io.bold(url)}`);
216
+ io.note('');
217
+ io.info('Open that URL to complete the authorisation with CDSL.');
218
+ io.info('It stays valid until 5:30 PM IST today.');
219
+ const { openBrowser } = await import('../core/auth.js');
220
+ if (await openBrowser(url)) {
221
+ io.success('Opened your browser.');
222
+ }
223
+ }
224
+ async function convertPosition(ctx, opts, command) {
225
+ ctx.requireSession();
226
+ const instrument = command.args[0] ?? '';
227
+ const { parseInstrumentKey } = await import('../core/instruments.js');
228
+ const parsed = parseInstrumentKey(instrument);
229
+ const qty = Number(opts.quantity);
230
+ if (!Number.isInteger(qty) || qty <= 0) {
231
+ throw new UsageError('--quantity must be a positive whole number.');
232
+ }
233
+ const from = normaliseProduct(opts.from);
234
+ const to = normaliseProduct(opts.to);
235
+ if (from === to) {
236
+ throw new UsageError('--from and --to are the same product; nothing to convert.');
237
+ }
238
+ const transactionType = (opts.transactionType ?? 'BUY').toUpperCase();
239
+ if (transactionType !== 'BUY' && transactionType !== 'SELL') {
240
+ throw new UsageError('--transaction-type must be BUY or SELL.');
241
+ }
242
+ const positionType = (opts.positionType ?? 'day').toLowerCase();
243
+ if (positionType !== 'day' && positionType !== 'overnight') {
244
+ throw new UsageError('--position-type must be day or overnight.');
245
+ }
246
+ await confirmAction(ctx, {
247
+ action: `Convert ${qty} ${parsed.tradingsymbol} from ${from} to ${to}`,
248
+ mutatesOrders: true,
249
+ details: [
250
+ ['Instrument', `${parsed.exchange}:${parsed.tradingsymbol}`],
251
+ ['Quantity', quantity(qty)],
252
+ ['From product', from],
253
+ ['To product', to],
254
+ ['Transaction', transactionType],
255
+ ['Position type', positionType],
256
+ ].map(([label, value]) => ({ label: label, value: value })),
257
+ });
258
+ if (ctx.options.dryRun)
259
+ return;
260
+ await ctx.api.convertPosition({
261
+ tradingsymbol: parsed.tradingsymbol,
262
+ exchange: parsed.exchange,
263
+ transaction_type: transactionType,
264
+ position_type: positionType,
265
+ quantity: qty,
266
+ old_product: from,
267
+ new_product: to,
268
+ }, ctx.signal);
269
+ if (ctx.io.json) {
270
+ ctx.io.writeJson({
271
+ converted: true,
272
+ tradingsymbol: parsed.tradingsymbol,
273
+ from,
274
+ to,
275
+ quantity: qty,
276
+ });
277
+ return;
278
+ }
279
+ ctx.io.success(`Converted ${qty} ${parsed.tradingsymbol} from ${from} to ${to}.`);
280
+ }
281
+ function normaliseProduct(value) {
282
+ const upper = value.toUpperCase();
283
+ if (PRODUCTS.includes(upper))
284
+ return upper;
285
+ throw new UsageError(`Unknown product "${value}".`, `Valid products: ${PRODUCTS.join(', ')}.`);
286
+ }
@@ -0,0 +1,16 @@
1
+ import type { Command } from 'commander';
2
+ import type { Context } from '../context.js';
3
+ /**
4
+ * A command handler receives a fully-built context plus its own parsed options.
5
+ *
6
+ * Commander types `.opts<T>()` as a caller-supplied cast rather than inferring
7
+ * from the `.option()` calls, so nothing verifies that `Options` matches what
8
+ * was declared. Commands that take non-trivial input therefore re-validate
9
+ * through a zod schema at the top of the handler, which converts a silent type
10
+ * lie into a runtime guarantee.
11
+ */
12
+ export type Handler<Options = Record<string, unknown>> = (ctx: Context, options: Options, command: Command) => Promise<void> | void;
13
+ /** Wraps a handler so it is given a context and has its errors reported. */
14
+ export type Runner = <Options>(handler: Handler<Options>) => (...args: unknown[]) => Promise<void>;
15
+ /** Registers a group of related commands on the root program. */
16
+ export type CommandFactory = (program: Command, run: Runner) => void;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import type { CommandFactory } from './types.js';
2
+ /**
3
+ * Live streaming quotes.
4
+ *
5
+ * Two design choices worth stating, both about not melting the terminal:
6
+ *
7
+ * 1. **Tick rate is decoupled from render rate.** Kite pushes far faster than
8
+ * a human can read. Ticks accumulate into a map and we repaint on a fixed
9
+ * interval (default 4/sec). Rendering per tick would burn CPU and produce
10
+ * an unreadable blur.
11
+ *
12
+ * 2. **The visible row count is capped below the terminal height.** When a
13
+ * live-updating region grows taller than the terminal, the renderer is
14
+ * forced into full-screen repaints and flicker becomes very hard to
15
+ * eliminate. Staying under the fold avoids the problem entirely rather
16
+ * than fighting it.
17
+ */
18
+ export declare const watchCommands: CommandFactory;
@@ -0,0 +1,285 @@
1
+ import logUpdate from 'log-update';
2
+ import { getSecret } from '../core/credentials.js';
3
+ import { ExitCode, KiteCliError, UsageError } from '../core/errors.js';
4
+ import { formatInstrumentKey, parseInstrumentKey } from '../core/instruments.js';
5
+ import { MAX_INSTRUMENTS_PER_CONNECTION, Ticker } from '../core/ticker.js';
6
+ import { compactNumber, money, percent, timeOnly } from '../output/format.js';
7
+ import { renderTable } from '../output/table.js';
8
+ /**
9
+ * Live streaming quotes.
10
+ *
11
+ * Two design choices worth stating, both about not melting the terminal:
12
+ *
13
+ * 1. **Tick rate is decoupled from render rate.** Kite pushes far faster than
14
+ * a human can read. Ticks accumulate into a map and we repaint on a fixed
15
+ * interval (default 4/sec). Rendering per tick would burn CPU and produce
16
+ * an unreadable blur.
17
+ *
18
+ * 2. **The visible row count is capped below the terminal height.** When a
19
+ * live-updating region grows taller than the terminal, the renderer is
20
+ * forced into full-screen repaints and flicker becomes very hard to
21
+ * eliminate. Staying under the fold avoids the problem entirely rather
22
+ * than fighting it.
23
+ */
24
+ export const watchCommands = (program, run) => {
25
+ program
26
+ .command('watch')
27
+ .description('Stream live quotes in a self-updating table')
28
+ .argument('[instruments...]', 'Instruments to watch, e.g. NSE:INFY NSE:TCS')
29
+ .option('--holdings', 'Watch everything in your holdings')
30
+ .option('--positions', 'Watch your open positions')
31
+ .option('-m, --mode <mode>', 'Streaming mode: ltp, quote, or full', 'quote')
32
+ .option('--fps <n>', 'Screen repaints per second', '4')
33
+ .option('--orders', 'Also print live order updates')
34
+ .action(run(watch));
35
+ };
36
+ async function watch(ctx, opts, command) {
37
+ ctx.requireSession();
38
+ const mode = (opts.mode ?? 'quote').toLowerCase();
39
+ if (mode !== 'ltp' && mode !== 'quote' && mode !== 'full') {
40
+ throw new UsageError('--mode must be ltp, quote, or full.');
41
+ }
42
+ // --- resolve the watchlist ---------------------------------------------
43
+ const keys = new Set();
44
+ for (const arg of command.args) {
45
+ const parsed = parseInstrumentKey(arg);
46
+ keys.add(formatInstrumentKey(parsed.exchange, parsed.tradingsymbol));
47
+ }
48
+ if (opts.holdings) {
49
+ for (const holding of await ctx.api.getHoldings(ctx.signal)) {
50
+ keys.add(formatInstrumentKey(holding.exchange, holding.tradingsymbol));
51
+ }
52
+ }
53
+ if (opts.positions) {
54
+ const positions = await ctx.api.getPositions(ctx.signal);
55
+ for (const position of positions.net) {
56
+ if (position.quantity !== 0) {
57
+ keys.add(formatInstrumentKey(position.exchange, position.tradingsymbol));
58
+ }
59
+ }
60
+ }
61
+ if (keys.size === 0) {
62
+ throw new UsageError('Nothing to watch.', 'Pass instruments (e.g. `kite watch NSE:INFY`), or use --holdings / --positions.');
63
+ }
64
+ if (keys.size > MAX_INSTRUMENTS_PER_CONNECTION) {
65
+ throw new UsageError(`Kite allows at most ${MAX_INSTRUMENTS_PER_CONNECTION} instruments per connection; you asked for ${keys.size}.`);
66
+ }
67
+ // The WebSocket accepts ONLY numeric instrument tokens, never symbols.
68
+ await ctx.instruments.load({ signal: ctx.signal });
69
+ const watchlist = [];
70
+ for (const key of keys) {
71
+ watchlist.push({ key, token: ctx.instruments.requireToken(key) });
72
+ }
73
+ const tokenToKey = new Map(watchlist.map((entry) => [entry.token, entry.key]));
74
+ // --- credentials for the socket ----------------------------------------
75
+ const stored = await getSecret('access_token', { env: ctx.env });
76
+ if (!stored) {
77
+ throw new KiteCliError('No access token available for streaming.', ExitCode.Auth, 'Run `kite login`.');
78
+ }
79
+ const ticker = new Ticker({
80
+ apiKey: ctx.client.apiKey,
81
+ accessToken: stored.value,
82
+ endpoints: ctx.endpoints,
83
+ // The sandbox socket will not authenticate without a user_id, which the
84
+ // official SDKs do not send.
85
+ userId: ctx.env === 'sandbox' ? ctx.session?.userId : undefined,
86
+ });
87
+ // --- JSON mode: stream NDJSON, no dashboard ----------------------------
88
+ if (ctx.io.json || !ctx.io.interactive) {
89
+ await streamJson(ctx, ticker, watchlist, tokenToKey, mode, opts.orders ?? false);
90
+ return;
91
+ }
92
+ await streamDashboard(ctx, ticker, watchlist, tokenToKey, mode, opts);
93
+ }
94
+ /** Non-TTY / --json: one JSON object per line, suitable for piping. */
95
+ async function streamJson(ctx, ticker, watchlist, tokenToKey, mode, showOrders) {
96
+ ticker.on('ticks', (ticks) => {
97
+ for (const tick of ticks) {
98
+ ctx.io.writeJson({
99
+ ...tick,
100
+ instrument: tokenToKey.get(tick.instrumentToken),
101
+ });
102
+ }
103
+ });
104
+ if (showOrders) {
105
+ ticker.on('orderUpdate', (order) => ctx.io.writeJson({ type: 'order', data: order }));
106
+ }
107
+ ticker.on('connect', () => ticker.subscribe(watchlist.map((w) => w.token), mode));
108
+ // An EventEmitter with no 'error' listener RETHROWS the error, which here
109
+ // would escape the socket callback and take the process down mid-stream.
110
+ // Errors are recoverable — the ticker reconnects — so report and continue.
111
+ ticker.on('error', (err) => ctx.io.warn(err.message));
112
+ ticker.on('reconnect', ({ attempt, delayMs }) => ctx.io.warn(`Disconnected; reconnecting (attempt ${attempt}, ${Math.round(delayMs / 1000)}s)…`));
113
+ // Without this the reconnect budget can be exhausted while the command sits
114
+ // waiting on a signal that will never arrive, hanging a pipeline forever.
115
+ const exhausted = new Promise((resolve) => {
116
+ ticker.once('noreconnect', () => {
117
+ ctx.io.error('Gave up reconnecting to the Kite ticker.');
118
+ process.exitCode = ExitCode.Upstream;
119
+ resolve();
120
+ });
121
+ });
122
+ ticker.connect();
123
+ await Promise.race([waitForSignal(ctx.signal), exhausted]);
124
+ ticker.close();
125
+ }
126
+ async function streamDashboard(ctx, ticker, watchlist, _tokenToKey, mode, opts) {
127
+ const { io } = ctx;
128
+ const fps = clamp(Number(opts.fps ?? 4) || 4, 1, 20);
129
+ const latest = new Map();
130
+ const previousPrice = new Map();
131
+ const flashUntil = new Map();
132
+ const orderLog = [];
133
+ let status = 'connecting…';
134
+ let lastUpdate;
135
+ ticker.on('connect', () => {
136
+ status = 'live';
137
+ ticker.subscribe(watchlist.map((w) => w.token), mode);
138
+ });
139
+ ticker.on('reconnect', ({ attempt, delayMs }) => {
140
+ status = `reconnecting (attempt ${attempt}, ${Math.round(delayMs / 1000)}s)`;
141
+ });
142
+ ticker.on('close', () => {
143
+ if (status === 'live')
144
+ status = 'disconnected';
145
+ });
146
+ ticker.on('noreconnect', () => {
147
+ status = 'gave up reconnecting';
148
+ });
149
+ ticker.on('error', (err) => {
150
+ status = `error: ${err.message}`;
151
+ });
152
+ ticker.on('ticks', (ticks) => {
153
+ // Buffer only — the render loop decides when to paint.
154
+ for (const tick of ticks) {
155
+ const previous = latest.get(tick.instrumentToken);
156
+ if (previous && previous.lastPrice !== tick.lastPrice) {
157
+ previousPrice.set(tick.instrumentToken, previous.lastPrice);
158
+ flashUntil.set(tick.instrumentToken, {
159
+ direction: tick.lastPrice > previous.lastPrice ? 1 : -1,
160
+ at: Date.now(),
161
+ });
162
+ }
163
+ latest.set(tick.instrumentToken, tick);
164
+ }
165
+ lastUpdate = new Date();
166
+ });
167
+ if (opts.orders) {
168
+ ticker.on('orderUpdate', (payload) => {
169
+ const order = payload;
170
+ orderLog.unshift(`${timeOnly(new Date())} ${order.tradingsymbol ?? '?'} ${order.status ?? '?'} ${order.filled_quantity ?? 0} filled`);
171
+ orderLog.length = Math.min(orderLog.length, 5);
172
+ });
173
+ }
174
+ // Cap rows so the live region never exceeds the terminal height — this is
175
+ // what keeps the repaint incremental instead of full-screen.
176
+ const chromeRows = 6 + (opts.orders ? 7 : 0);
177
+ const maxRows = Math.max(3, io.rows - chromeRows);
178
+ const visible = watchlist.slice(0, maxRows);
179
+ const hidden = watchlist.length - visible.length;
180
+ const render = () => {
181
+ const now = Date.now();
182
+ const rows = visible.map((entry) => ({
183
+ key: entry.key,
184
+ tick: latest.get(entry.token),
185
+ flash: flashUntil.get(entry.token),
186
+ }));
187
+ const columns = [
188
+ { header: 'Instrument', value: (r, io) => io.bold(r.key) },
189
+ {
190
+ header: 'LTP',
191
+ value: (r, io) => {
192
+ if (!r.tick)
193
+ return io.dim('—');
194
+ const text = money(r.tick.lastPrice);
195
+ // Flash the cell briefly on a price change so movement is visible
196
+ // even when the number barely shifts.
197
+ if (r.flash && now - r.flash.at < 400) {
198
+ return r.flash.direction === 1 ? io.green(text) : io.red(text);
199
+ }
200
+ return text;
201
+ },
202
+ align: 'right',
203
+ },
204
+ {
205
+ header: 'Change',
206
+ value: (r, io) => r.tick?.change === undefined ? io.dim('—') : io.signed(r.tick.change, percent(r.tick.change)),
207
+ align: 'right',
208
+ },
209
+ {
210
+ header: 'Open',
211
+ value: (r) => money(r.tick?.ohlc?.open),
212
+ align: 'right',
213
+ },
214
+ {
215
+ header: 'High',
216
+ value: (r) => money(r.tick?.ohlc?.high),
217
+ align: 'right',
218
+ },
219
+ { header: 'Low', value: (r) => money(r.tick?.ohlc?.low), align: 'right' },
220
+ {
221
+ header: 'Close',
222
+ value: (r) => money(r.tick?.ohlc?.close),
223
+ align: 'right',
224
+ },
225
+ ...(mode !== 'ltp'
226
+ ? [
227
+ {
228
+ header: 'Volume',
229
+ value: (r) => compactNumber(r.tick?.volume),
230
+ align: 'right',
231
+ },
232
+ ]
233
+ : []),
234
+ ];
235
+ const statusColour = status === 'live'
236
+ ? io.green('● live')
237
+ : status.startsWith('error')
238
+ ? io.red(`● ${status}`)
239
+ : io.yellow(`● ${status}`);
240
+ const parts = [
241
+ `${statusColour} ${io.dim(`${watchlist.length} instruments · ${mode} mode · ${lastUpdate ? timeOnly(lastUpdate) : 'waiting for data'}`)}`,
242
+ renderTable(io, rows, columns, { compact: ctx.config.output.compact }),
243
+ ];
244
+ if (hidden > 0) {
245
+ parts.push(io.dim(` …and ${hidden} more (terminal too short to show them all)`));
246
+ }
247
+ if (opts.orders && orderLog.length > 0) {
248
+ parts.push(`\n${io.bold('Order updates')}\n${orderLog.map((line) => ` ${io.dim(line)}`).join('\n')}`);
249
+ }
250
+ parts.push(io.dim('\n Ctrl-C to stop'));
251
+ logUpdate(parts.join('\n'));
252
+ };
253
+ // The dashboard already listens for 'error', so the emitter never rethrows.
254
+ // It still needs to stop waiting once reconnection is abandoned.
255
+ const exhausted = new Promise((resolve) => {
256
+ ticker.once('noreconnect', () => {
257
+ process.exitCode = ExitCode.Upstream;
258
+ resolve();
259
+ });
260
+ });
261
+ ticker.connect();
262
+ const timer = setInterval(render, Math.round(1000 / fps));
263
+ render();
264
+ try {
265
+ await Promise.race([waitForSignal(ctx.signal), exhausted]);
266
+ }
267
+ finally {
268
+ clearInterval(timer);
269
+ ticker.close();
270
+ // Leave the final frame on screen rather than erasing it.
271
+ logUpdate.done();
272
+ }
273
+ }
274
+ function waitForSignal(signal) {
275
+ return new Promise((resolve) => {
276
+ if (signal.aborted) {
277
+ resolve();
278
+ return;
279
+ }
280
+ signal.addEventListener('abort', () => resolve(), { once: true });
281
+ });
282
+ }
283
+ function clamp(value, min, max) {
284
+ return Math.min(max, Math.max(min, value));
285
+ }
@@ -0,0 +1,40 @@
1
+ import { KiteApi } from './core/api.js';
2
+ import { KiteClient } from './core/client.js';
3
+ import { type Config, type Endpoints, type Environment } from './core/config.js';
4
+ import { InstrumentStore } from './core/instruments.js';
5
+ import { type SessionMeta } from './core/session.js';
6
+ import { Io, type IoStreams } from './output/io.js';
7
+ /**
8
+ * Everything a command needs, assembled once per invocation.
9
+ *
10
+ * Construction is deliberately lazy about authentication: `kite login`,
11
+ * `kite config` and `kite --help` must work with no session at all, so the
12
+ * client is built without a token and commands call `requireSession()` when
13
+ * they actually need one.
14
+ */
15
+ export interface GlobalOptions {
16
+ json?: boolean;
17
+ color?: 'auto' | 'always' | 'never';
18
+ quiet?: boolean;
19
+ debug?: boolean;
20
+ env?: string;
21
+ yes?: boolean;
22
+ dryRun?: boolean;
23
+ }
24
+ export interface Context {
25
+ io: Io;
26
+ config: Config;
27
+ env: Environment;
28
+ endpoints: Endpoints;
29
+ client: KiteClient;
30
+ api: KiteApi;
31
+ instruments: InstrumentStore;
32
+ session: SessionMeta | null;
33
+ options: GlobalOptions;
34
+ signal: AbortSignal;
35
+ /** Throw unless there is a live session. */
36
+ requireSession: () => SessionMeta;
37
+ /** Resolve the API secret, for flows that need to sign (login only). */
38
+ requireApiSecret: () => Promise<string>;
39
+ }
40
+ export declare function createContext(options: GlobalOptions, signal: AbortSignal, streams?: IoStreams): Promise<Context>;