@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,152 @@
1
+ /**
2
+ * Error taxonomy for the Kite Connect API and this CLI.
3
+ *
4
+ * Kite returns errors as:
5
+ * { "status": "error", "message": "...", "error_type": "GeneralException" }
6
+ *
7
+ * See https://kite.trade/docs/connect/v3/exceptions/
8
+ */
9
+ /** Process exit codes. Distinct per failure mode so scripts can branch on them. */
10
+ export const ExitCode = {
11
+ Ok: 0,
12
+ /** Generic/unclassified failure. */
13
+ Failure: 1,
14
+ /** Bad CLI usage: unknown flag, missing argument, invalid value. */
15
+ Usage: 2,
16
+ /** No session, or the session expired. Run `kite login`. */
17
+ Auth: 3,
18
+ /** Kite rejected the input (InputException). */
19
+ Input: 4,
20
+ /** Order was rejected by the OMS (OrderException). */
21
+ Order: 5,
22
+ /** Insufficient funds (MarginException). */
23
+ Margin: 6,
24
+ /** Insufficient holdings to sell (HoldingException). */
25
+ Holding: 7,
26
+ /** Rate limited (HTTP 429). */
27
+ RateLimit: 8,
28
+ /** Kite or its upstream OMS is unreachable or erroring (5xx, network). */
29
+ Upstream: 9,
30
+ /** The user declined a confirmation prompt. */
31
+ Aborted: 10,
32
+ /** Confirmation required but stdin is not a TTY and --yes was not passed. */
33
+ ConfirmationRequired: 11,
34
+ /** Holdings need depository authorisation (HTTP 428). */
35
+ AuthorisationRequired: 12,
36
+ /** Trading is disabled by the local kill switch. */
37
+ TradingDisabled: 13,
38
+ };
39
+ /**
40
+ * Base class for every error this CLI raises deliberately. Carries an exit code
41
+ * and an optional remediation hint shown to the user.
42
+ */
43
+ export class KiteCliError extends Error {
44
+ exitCode;
45
+ /** A short actionable next step, e.g. "Run `kite login`." */
46
+ hint;
47
+ constructor(message, exitCode = ExitCode.Failure, hint) {
48
+ super(message);
49
+ this.name = new.target.name;
50
+ this.exitCode = exitCode;
51
+ this.hint = hint;
52
+ }
53
+ }
54
+ /** An error returned by the Kite API with a structured envelope. */
55
+ export class KiteApiError extends KiteCliError {
56
+ status;
57
+ errorType;
58
+ constructor(opts) {
59
+ super(opts.message, exitCodeForApiError(opts.status, opts.errorType), opts.hint);
60
+ this.status = opts.status;
61
+ this.errorType = opts.errorType;
62
+ }
63
+ }
64
+ /** The local session is missing or expired. */
65
+ export class AuthRequiredError extends KiteCliError {
66
+ constructor(message = 'Not logged in.') {
67
+ super(message, ExitCode.Auth, 'Run `kite login` to start a session.');
68
+ }
69
+ }
70
+ /** Bad CLI usage — distinct from Kite rejecting valid-looking input. */
71
+ export class UsageError extends KiteCliError {
72
+ constructor(message, hint) {
73
+ super(message, ExitCode.Usage, hint);
74
+ }
75
+ }
76
+ /** The user answered "no" at a confirmation prompt. */
77
+ export class AbortedError extends KiteCliError {
78
+ constructor(message = 'Aborted.') {
79
+ super(message, ExitCode.Aborted);
80
+ }
81
+ }
82
+ /** Network-level failure: DNS, TCP, TLS, or timeout. */
83
+ export class NetworkError extends KiteCliError {
84
+ constructor(message, hint) {
85
+ super(message, ExitCode.Upstream, hint);
86
+ }
87
+ }
88
+ function exitCodeForApiError(status, errorType) {
89
+ // Status codes that are more specific than the error_type Kite pairs them
90
+ // with are checked FIRST. A 428 always means "holdings need depository
91
+ // authorisation", but Kite sends it as a generic OrderException — matching on
92
+ // error_type first would collapse it to ExitCode.Order and make the
93
+ // documented ExitCode.AuthorisationRequired unreachable, so a script could
94
+ // never branch on the one condition that has a specific recovery flow.
95
+ if (status === 428)
96
+ return ExitCode.AuthorisationRequired;
97
+ if (status === 429)
98
+ return ExitCode.RateLimit;
99
+ switch (errorType) {
100
+ case 'TokenException':
101
+ return ExitCode.Auth;
102
+ case 'InputException':
103
+ return ExitCode.Input;
104
+ case 'OrderException':
105
+ return ExitCode.Order;
106
+ case 'MarginException':
107
+ return ExitCode.Margin;
108
+ case 'HoldingException':
109
+ return ExitCode.Holding;
110
+ case 'NetworkException':
111
+ case 'DataException':
112
+ return ExitCode.Upstream;
113
+ default:
114
+ break;
115
+ }
116
+ if (status === 403)
117
+ return ExitCode.Auth;
118
+ if (status >= 500)
119
+ return ExitCode.Upstream;
120
+ if (status >= 400)
121
+ return ExitCode.Input;
122
+ return ExitCode.Failure;
123
+ }
124
+ /**
125
+ * Remediation hints for the error types where the right next step is not
126
+ * obvious from Kite's own message.
127
+ */
128
+ export function hintForApiError(status, errorType) {
129
+ if (errorType === 'TokenException') {
130
+ return 'Your session expired or was invalidated (logging into Kite web ends API sessions). Run `kite login`.';
131
+ }
132
+ // A 403 that is NOT a TokenException is a permission problem, not an expired
133
+ // session — most often an endpoint this app is not subscribed to (the
134
+ // Historical Data API, for instance, is a paid Kite add-on). Telling the user
135
+ // to re-login would send them round a loop that cannot fix it.
136
+ if (status === 403) {
137
+ return 'Kite denied this request. Your app may not have permission for this endpoint — historical data, for example, needs the paid Historical Data subscription. Only re-run `kite login` if your session was the problem.';
138
+ }
139
+ if (status === 428) {
140
+ return 'Selling these holdings needs depository authorisation. Run `kite authorise`.';
141
+ }
142
+ if (status === 429) {
143
+ return 'Rate limited by Kite. Quote calls are capped at 1/sec and historical at 3/sec.';
144
+ }
145
+ if (errorType === 'MarginException') {
146
+ return 'Check available margin with `kite funds`.';
147
+ }
148
+ if (status === 502 || status === 503 || status === 504) {
149
+ return "Zerodha's OMS backend is unavailable. This is usually transient; retry shortly.";
150
+ }
151
+ return undefined;
152
+ }
@@ -0,0 +1,87 @@
1
+ import type { KiteApi } from './api.js';
2
+ import type { Instrument } from './schemas.js';
3
+ /**
4
+ * The instrument master.
5
+ *
6
+ * Kite publishes the full instrument list as a gzipped CSV, regenerated once a
7
+ * day at around 08:30 IST. We cache it locally and refresh on that cadence.
8
+ *
9
+ * The critical correctness rule, quoted from Kite's docs:
10
+ *
11
+ * "For storage, it is recommended to use a combination of exchange and
12
+ * tradingsymbol as the unique key, not the numeric instrument token.
13
+ * Exchanges may reuse instrument tokens for different derivative
14
+ * instruments after each expiry."
15
+ *
16
+ * So the cache is keyed on `EXCHANGE:TRADINGSYMBOL` throughout. A token-keyed
17
+ * cache would silently resolve to the wrong contract after an expiry roll —
18
+ * a bug that costs money and is nearly invisible in testing.
19
+ *
20
+ * Three identifiers, three uses, easily confused:
21
+ * instrument_token — the ONLY id accepted by WebSocket and historical data
22
+ * tradingsymbol — the ONLY id accepted by order placement and /quote
23
+ * exchange_token — the exchange's own id; rarely needed
24
+ */
25
+ export interface InstrumentCache {
26
+ fetchedAt: string;
27
+ instruments: Instrument[];
28
+ }
29
+ /**
30
+ * Minimal RFC-4180 CSV parser.
31
+ *
32
+ * Hand-rolled rather than pulled from npm: instrument names legitimately
33
+ * contain commas inside quoted fields (e.g. "NIFTY 50"), so naive splitting
34
+ * corrupts rows — but this is ~40 lines and adding a dependency to a tool that
35
+ * holds trading credentials needs a better reason than that.
36
+ */
37
+ export declare function parseCsv(text: string): string[][];
38
+ export declare function parseInstrumentsCsv(csv: string): Instrument[];
39
+ export declare class InstrumentStore {
40
+ private instruments;
41
+ private bySymbol;
42
+ private loaded;
43
+ private readonly api;
44
+ private readonly env;
45
+ constructor(api: KiteApi, env: string);
46
+ /** Load from cache, fetching from Kite if absent or stale. */
47
+ load(opts?: {
48
+ force?: boolean;
49
+ signal?: AbortSignal;
50
+ }): Promise<void>;
51
+ /** Load from cache only; returns false when there is no usable cache. */
52
+ loadCachedOnly(): Promise<boolean>;
53
+ private hydrate;
54
+ private readCache;
55
+ private writeCache;
56
+ get size(): number;
57
+ get all(): readonly Instrument[];
58
+ /** Look up by "EXCHANGE:TRADINGSYMBOL" or by exchange + symbol. */
59
+ lookup(exchange: string, tradingsymbol: string): Instrument | undefined;
60
+ lookupKey(instrumentKey: string): Instrument | undefined;
61
+ /**
62
+ * Resolve a key to an instrument token, for the WebSocket and historical
63
+ * endpoints (which accept nothing else).
64
+ */
65
+ requireToken(instrumentKey: string): number;
66
+ /**
67
+ * Fuzzy search over tradingsymbol and name.
68
+ *
69
+ * Ranked so that exact and prefix matches on the trading symbol come first —
70
+ * searching "INFY" should not bury the equity under fifty option contracts.
71
+ */
72
+ search(query: string, opts?: {
73
+ exchange?: string | undefined;
74
+ type?: string | undefined;
75
+ limit?: number;
76
+ }): Instrument[];
77
+ }
78
+ export interface ParsedInstrumentKey {
79
+ exchange: string;
80
+ tradingsymbol: string;
81
+ }
82
+ /**
83
+ * Parse "NSE:INFY". Defaults to NSE when no exchange is given, since that is
84
+ * overwhelmingly what a bare symbol means.
85
+ */
86
+ export declare function parseInstrumentKey(value: string): ParsedInstrumentKey;
87
+ export declare function formatInstrumentKey(exchange: string, tradingsymbol: string): string;
@@ -0,0 +1,288 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { UsageError } from './errors.js';
3
+ import { cacheDir, ensurePrivateDir, instrumentsCacheFile } from './paths.js';
4
+ /**
5
+ * Minimal RFC-4180 CSV parser.
6
+ *
7
+ * Hand-rolled rather than pulled from npm: instrument names legitimately
8
+ * contain commas inside quoted fields (e.g. "NIFTY 50"), so naive splitting
9
+ * corrupts rows — but this is ~40 lines and adding a dependency to a tool that
10
+ * holds trading credentials needs a better reason than that.
11
+ */
12
+ export function parseCsv(text) {
13
+ const rows = [];
14
+ let row = [];
15
+ let field = '';
16
+ let inQuotes = false;
17
+ let i = 0;
18
+ while (i < text.length) {
19
+ const char = text[i];
20
+ if (inQuotes) {
21
+ if (char === '"') {
22
+ if (text[i + 1] === '"') {
23
+ field += '"';
24
+ i += 2;
25
+ continue;
26
+ }
27
+ inQuotes = false;
28
+ i += 1;
29
+ continue;
30
+ }
31
+ field += char;
32
+ i += 1;
33
+ continue;
34
+ }
35
+ if (char === '"') {
36
+ inQuotes = true;
37
+ i += 1;
38
+ continue;
39
+ }
40
+ if (char === ',') {
41
+ row.push(field);
42
+ field = '';
43
+ i += 1;
44
+ continue;
45
+ }
46
+ if (char === '\r') {
47
+ i += 1;
48
+ continue;
49
+ }
50
+ if (char === '\n') {
51
+ row.push(field);
52
+ rows.push(row);
53
+ row = [];
54
+ field = '';
55
+ i += 1;
56
+ continue;
57
+ }
58
+ field += char;
59
+ i += 1;
60
+ }
61
+ if (field !== '' || row.length > 0) {
62
+ row.push(field);
63
+ rows.push(row);
64
+ }
65
+ return rows;
66
+ }
67
+ export function parseInstrumentsCsv(csv) {
68
+ const rows = parseCsv(csv);
69
+ if (rows.length < 2)
70
+ return [];
71
+ const header = rows[0].map((h) => h.trim());
72
+ const index = (name) => header.indexOf(name);
73
+ const idx = {
74
+ instrument_token: index('instrument_token'),
75
+ exchange_token: index('exchange_token'),
76
+ tradingsymbol: index('tradingsymbol'),
77
+ name: index('name'),
78
+ last_price: index('last_price'),
79
+ expiry: index('expiry'),
80
+ strike: index('strike'),
81
+ tick_size: index('tick_size'),
82
+ lot_size: index('lot_size'),
83
+ instrument_type: index('instrument_type'),
84
+ segment: index('segment'),
85
+ exchange: index('exchange'),
86
+ };
87
+ const out = [];
88
+ for (let r = 1; r < rows.length; r += 1) {
89
+ const row = rows[r];
90
+ // Skip blank trailing lines and any truncated row.
91
+ if (row.length < header.length)
92
+ continue;
93
+ const token = Number(row[idx.instrument_token]);
94
+ const tradingsymbol = row[idx.tradingsymbol] ?? '';
95
+ const exchange = row[idx.exchange] ?? '';
96
+ if (!Number.isFinite(token) || tradingsymbol === '' || exchange === '')
97
+ continue;
98
+ out.push({
99
+ instrument_token: token,
100
+ exchange_token: numberOrUndefined(row[idx.exchange_token]),
101
+ tradingsymbol,
102
+ name: row[idx.name] || undefined,
103
+ // last_price in the dump is a day-old snapshot. Kept for completeness,
104
+ // but never surfaced as a quote.
105
+ last_price: numberOrUndefined(row[idx.last_price]),
106
+ expiry: row[idx.expiry] || undefined,
107
+ strike: numberOrUndefined(row[idx.strike]),
108
+ tick_size: numberOrUndefined(row[idx.tick_size]),
109
+ lot_size: numberOrUndefined(row[idx.lot_size]),
110
+ instrument_type: row[idx.instrument_type] || undefined,
111
+ segment: row[idx.segment] || undefined,
112
+ exchange,
113
+ });
114
+ }
115
+ return out;
116
+ }
117
+ function numberOrUndefined(value) {
118
+ if (value === undefined || value.trim() === '')
119
+ return undefined;
120
+ const n = Number(value);
121
+ return Number.isFinite(n) ? n : undefined;
122
+ }
123
+ /** The instrument dump regenerates daily around 08:30 IST. */
124
+ function isStale(fetchedAt, now = new Date()) {
125
+ const fetched = Date.parse(fetchedAt);
126
+ if (Number.isNaN(fetched))
127
+ return true;
128
+ const refreshHourIst = 8.5;
129
+ const istOffsetMs = 5.5 * 3600 * 1000;
130
+ const istNow = new Date(now.getTime() + istOffsetMs);
131
+ const istFetched = new Date(fetched + istOffsetMs);
132
+ // Most recent 08:30 IST boundary, as a UTC instant.
133
+ let boundary = Date.UTC(istNow.getUTCFullYear(), istNow.getUTCMonth(), istNow.getUTCDate(), 8, 30, 0);
134
+ const istHour = istNow.getUTCHours() + istNow.getUTCMinutes() / 60;
135
+ if (istHour < refreshHourIst) {
136
+ boundary -= 24 * 3600 * 1000;
137
+ }
138
+ return istFetched.getTime() < boundary;
139
+ }
140
+ export class InstrumentStore {
141
+ instruments = [];
142
+ bySymbol = new Map();
143
+ loaded = false;
144
+ api;
145
+ env;
146
+ constructor(api, env) {
147
+ this.api = api;
148
+ this.env = env;
149
+ }
150
+ /** Load from cache, fetching from Kite if absent or stale. */
151
+ async load(opts = {}) {
152
+ if (this.loaded && !opts.force)
153
+ return;
154
+ if (!opts.force) {
155
+ const cached = await this.readCache();
156
+ if (cached && !isStale(cached.fetchedAt)) {
157
+ this.hydrate(cached.instruments);
158
+ return;
159
+ }
160
+ }
161
+ const csv = await this.api.getInstrumentsCsv(undefined, opts.signal);
162
+ const instruments = parseInstrumentsCsv(csv);
163
+ this.hydrate(instruments);
164
+ await this.writeCache({ fetchedAt: new Date().toISOString(), instruments });
165
+ }
166
+ /** Load from cache only; returns false when there is no usable cache. */
167
+ async loadCachedOnly() {
168
+ const cached = await this.readCache();
169
+ if (!cached)
170
+ return false;
171
+ this.hydrate(cached.instruments);
172
+ return true;
173
+ }
174
+ hydrate(instruments) {
175
+ this.instruments = instruments;
176
+ this.bySymbol = new Map();
177
+ for (const instrument of instruments) {
178
+ this.bySymbol.set(key(instrument.exchange, instrument.tradingsymbol), instrument);
179
+ }
180
+ this.loaded = true;
181
+ }
182
+ async readCache() {
183
+ try {
184
+ const raw = await readFile(instrumentsCacheFile(this.env), 'utf8');
185
+ const parsed = JSON.parse(raw);
186
+ if (!Array.isArray(parsed.instruments))
187
+ return null;
188
+ return parsed;
189
+ }
190
+ catch {
191
+ return null;
192
+ }
193
+ }
194
+ async writeCache(cache) {
195
+ await ensurePrivateDir(cacheDir());
196
+ await writeFile(instrumentsCacheFile(this.env), JSON.stringify(cache), 'utf8');
197
+ }
198
+ get size() {
199
+ return this.instruments.length;
200
+ }
201
+ get all() {
202
+ return this.instruments;
203
+ }
204
+ /** Look up by "EXCHANGE:TRADINGSYMBOL" or by exchange + symbol. */
205
+ lookup(exchange, tradingsymbol) {
206
+ return this.bySymbol.get(key(exchange, tradingsymbol));
207
+ }
208
+ lookupKey(instrumentKey) {
209
+ const parsed = parseInstrumentKey(instrumentKey);
210
+ return this.lookup(parsed.exchange, parsed.tradingsymbol);
211
+ }
212
+ /**
213
+ * Resolve a key to an instrument token, for the WebSocket and historical
214
+ * endpoints (which accept nothing else).
215
+ */
216
+ requireToken(instrumentKey) {
217
+ const instrument = this.lookupKey(instrumentKey);
218
+ if (!instrument) {
219
+ throw new UsageError(`Unknown instrument "${instrumentKey}".`, 'Search for it with `kite instruments search <query>`, or refresh the list with `kite instruments refresh`.');
220
+ }
221
+ return instrument.instrument_token;
222
+ }
223
+ /**
224
+ * Fuzzy search over tradingsymbol and name.
225
+ *
226
+ * Ranked so that exact and prefix matches on the trading symbol come first —
227
+ * searching "INFY" should not bury the equity under fifty option contracts.
228
+ */
229
+ search(query, opts = {}) {
230
+ const needle = query.trim().toUpperCase();
231
+ if (needle === '')
232
+ return [];
233
+ const limit = opts.limit ?? 25;
234
+ const scored = [];
235
+ for (const instrument of this.instruments) {
236
+ if (opts.exchange && instrument.exchange !== opts.exchange)
237
+ continue;
238
+ if (opts.type && instrument.instrument_type !== opts.type)
239
+ continue;
240
+ const symbol = instrument.tradingsymbol.toUpperCase();
241
+ const name = (instrument.name ?? '').toUpperCase();
242
+ let score = 0;
243
+ if (symbol === needle)
244
+ score = 1000;
245
+ else if (name === needle)
246
+ score = 900;
247
+ else if (symbol.startsWith(needle))
248
+ score = 800 - symbol.length;
249
+ else if (name.startsWith(needle))
250
+ score = 700 - name.length;
251
+ else if (symbol.includes(needle))
252
+ score = 500 - symbol.length;
253
+ else if (name.includes(needle))
254
+ score = 400 - name.length;
255
+ else
256
+ continue;
257
+ // Prefer plain equity over derivatives for an otherwise equal match.
258
+ if (instrument.segment === 'NSE' || instrument.segment === 'BSE')
259
+ score += 50;
260
+ scored.push({ instrument, score });
261
+ }
262
+ scored.sort((a, b) => b.score - a.score || a.instrument.tradingsymbol.localeCompare(b.instrument.tradingsymbol));
263
+ return scored.slice(0, limit).map((entry) => entry.instrument);
264
+ }
265
+ }
266
+ function key(exchange, tradingsymbol) {
267
+ return `${exchange.toUpperCase()}:${tradingsymbol.toUpperCase()}`;
268
+ }
269
+ /**
270
+ * Parse "NSE:INFY". Defaults to NSE when no exchange is given, since that is
271
+ * overwhelmingly what a bare symbol means.
272
+ */
273
+ export function parseInstrumentKey(value) {
274
+ const trimmed = value.trim();
275
+ const colon = trimmed.indexOf(':');
276
+ if (colon === -1) {
277
+ return { exchange: 'NSE', tradingsymbol: trimmed.toUpperCase() };
278
+ }
279
+ const exchange = trimmed.slice(0, colon).toUpperCase();
280
+ const tradingsymbol = trimmed.slice(colon + 1).toUpperCase();
281
+ if (exchange === '' || tradingsymbol === '') {
282
+ throw new UsageError(`Malformed instrument "${value}". Expected EXCHANGE:SYMBOL, e.g. NSE:INFY.`);
283
+ }
284
+ return { exchange, tradingsymbol };
285
+ }
286
+ export function formatInstrumentKey(exchange, tradingsymbol) {
287
+ return `${exchange.toUpperCase()}:${tradingsymbol.toUpperCase()}`;
288
+ }
@@ -0,0 +1,11 @@
1
+ export declare function configDir(): string;
2
+ export declare function cacheDir(): string;
3
+ export declare function configFile(): string;
4
+ /** Non-secret session metadata (expiry, user id). The token itself is not here. */
5
+ export declare function sessionFile(): string;
6
+ /** Encrypted credential store, used only when the OS keyring is unavailable. */
7
+ export declare function credentialsFile(): string;
8
+ /** Instrument master cache, refreshed daily. */
9
+ export declare function instrumentsCacheFile(env: string): string;
10
+ /** Create a directory with 0700 so secrets are not world-readable. */
11
+ export declare function ensurePrivateDir(dir: string): Promise<void>;
@@ -0,0 +1,56 @@
1
+ import { chmod, mkdir } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ /**
5
+ * XDG-compliant paths, with the usual macOS pragmatism: we use ~/.config rather
6
+ * than ~/Library/Application Support, because CLI users expect their dotfiles
7
+ * in one place and every comparable tool (gh, aws, stripe) does the same.
8
+ */
9
+ function xdg(envVar, fallback) {
10
+ const value = process.env[envVar];
11
+ if (value && value.trim() !== '')
12
+ return value;
13
+ return fallback;
14
+ }
15
+ export function configDir() {
16
+ const override = process.env['KITE_CONFIG_DIR'];
17
+ if (override && override.trim() !== '')
18
+ return override;
19
+ return join(xdg('XDG_CONFIG_HOME', join(homedir(), '.config')), 'kite');
20
+ }
21
+ export function cacheDir() {
22
+ const override = process.env['KITE_CACHE_DIR'];
23
+ if (override && override.trim() !== '')
24
+ return override;
25
+ return join(xdg('XDG_CACHE_HOME', join(homedir(), '.cache')), 'kite');
26
+ }
27
+ export function configFile() {
28
+ return join(configDir(), 'config.json');
29
+ }
30
+ /** Non-secret session metadata (expiry, user id). The token itself is not here. */
31
+ export function sessionFile() {
32
+ return join(configDir(), 'session.json');
33
+ }
34
+ /** Encrypted credential store, used only when the OS keyring is unavailable. */
35
+ export function credentialsFile() {
36
+ return join(configDir(), 'credentials.enc');
37
+ }
38
+ /** Instrument master cache, refreshed daily. */
39
+ export function instrumentsCacheFile(env) {
40
+ return join(cacheDir(), `instruments.${env}.json`);
41
+ }
42
+ /** Create a directory with 0700 so secrets are not world-readable. */
43
+ export async function ensurePrivateDir(dir) {
44
+ await mkdir(dir, { recursive: true, mode: 0o700 });
45
+ // mkdir's mode is masked by umask, so set it explicitly. Not supported on
46
+ // Windows, where ACLs already restrict the user profile directory.
47
+ if (process.platform !== 'win32') {
48
+ try {
49
+ await chmod(dir, 0o700);
50
+ }
51
+ catch {
52
+ // Directory may be owned by another user in a shared setup; the write
53
+ // itself will fail later with a clearer error.
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Client-side rate limiting, per endpoint category.
3
+ *
4
+ * Kite's published limits (https://kite.trade/docs/connect/v3/exceptions/):
5
+ *
6
+ * Quote 1 req/sec <- the binding constraint in practice
7
+ * Historical 3 req/sec
8
+ * Order placement 10 req/sec, 400/min, 5000/day
9
+ * Everything else 10 req/sec
10
+ *
11
+ * The quote limit is what bites first: a naive per-symbol loop over a watchlist
12
+ * is ~1000x slower than one batched /quote/ltp call, which accepts 1000
13
+ * instruments. Batching is handled at the client layer; this module only
14
+ * enforces pacing.
15
+ */
16
+ export type RateCategory = 'quote' | 'historical' | 'order' | 'default';
17
+ export declare const ORDER_LIMITS: {
18
+ readonly perMinute: 400;
19
+ readonly perDay: 5000;
20
+ /** Kite rejects further modifications after this many on one order. */
21
+ readonly modificationsPerOrder: 25;
22
+ };
23
+ export declare class RateLimiter {
24
+ private readonly buckets;
25
+ private readonly orderCounters;
26
+ private readonly now;
27
+ constructor(now?: () => number);
28
+ acquire(category: RateCategory, signal?: AbortSignal): Promise<void>;
29
+ /**
30
+ * Roll the per-minute and per-IST-day order windows forward if they have
31
+ * elapsed. Shared by the pre-flight cap check and the post-acquire counter so
32
+ * the two never disagree about which window an order falls in.
33
+ */
34
+ private rollOrderWindows;
35
+ /**
36
+ * Refuse to place another order once a documented cap is reached.
37
+ *
38
+ * These counters reset each process run, so they cannot be authoritative
39
+ * across invocations of a short-lived CLI — this is a backstop against a
40
+ * runaway loop inside ONE long-running process (`kite watch`, a scripted
41
+ * batch, a library embedder), not a mirror of Zerodha's server-side
42
+ * accounting.
43
+ */
44
+ private assertOrderCapacity;
45
+ /**
46
+ * Track the 400/min and 5000/day order caps (see {@link assertOrderCapacity},
47
+ * which enforces them before the order is sent).
48
+ */
49
+ private countOrder;
50
+ /** Orders placed by this process in the current minute / IST day. */
51
+ orderUsage(): {
52
+ minute: number;
53
+ day: number;
54
+ };
55
+ /** True if this process is approaching a documented order cap. */
56
+ nearOrderLimit(): boolean;
57
+ }