@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.
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +7 -0
- package/dist/commands/auth.d.ts +2 -0
- package/dist/commands/auth.js +265 -0
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +182 -0
- package/dist/commands/gtt.d.ts +12 -0
- package/dist/commands/gtt.js +276 -0
- package/dist/commands/market.d.ts +2 -0
- package/dist/commands/market.js +389 -0
- package/dist/commands/orders.d.ts +2 -0
- package/dist/commands/orders.js +679 -0
- package/dist/commands/portfolio.d.ts +2 -0
- package/dist/commands/portfolio.js +286 -0
- package/dist/commands/types.d.ts +16 -0
- package/dist/commands/types.js +1 -0
- package/dist/commands/watch.d.ts +18 -0
- package/dist/commands/watch.js +285 -0
- package/dist/context.d.ts +40 -0
- package/dist/context.js +126 -0
- package/dist/core/api.d.ts +843 -0
- package/dist/core/api.js +472 -0
- package/dist/core/auth.d.ts +68 -0
- package/dist/core/auth.js +220 -0
- package/dist/core/client.d.ts +76 -0
- package/dist/core/client.js +285 -0
- package/dist/core/config.d.ts +114 -0
- package/dist/core/config.js +163 -0
- package/dist/core/credentials.d.ts +27 -0
- package/dist/core/credentials.js +182 -0
- package/dist/core/errors.d.ts +83 -0
- package/dist/core/errors.js +152 -0
- package/dist/core/instruments.d.ts +87 -0
- package/dist/core/instruments.js +288 -0
- package/dist/core/paths.d.ts +11 -0
- package/dist/core/paths.js +56 -0
- package/dist/core/ratelimit.d.ts +57 -0
- package/dist/core/ratelimit.js +196 -0
- package/dist/core/redact.d.ts +48 -0
- package/dist/core/redact.js +181 -0
- package/dist/core/schemas.d.ts +662 -0
- package/dist/core/schemas.js +433 -0
- package/dist/core/secretstore.d.ts +11 -0
- package/dist/core/secretstore.js +138 -0
- package/dist/core/session.d.ts +37 -0
- package/dist/core/session.js +102 -0
- package/dist/core/ticker.d.ts +131 -0
- package/dist/core/ticker.js +367 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +17 -0
- package/dist/output/format.d.ts +31 -0
- package/dist/output/format.js +162 -0
- package/dist/output/io.d.ts +70 -0
- package/dist/output/io.js +143 -0
- package/dist/output/table.d.ts +28 -0
- package/dist/output/table.js +73 -0
- package/dist/run.d.ts +15 -0
- package/dist/run.js +161 -0
- package/dist/safety.d.ts +93 -0
- package/dist/safety.js +154 -0
- 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>;
|