@astronomer-app/signals 0.0.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 +55 -0
- package/dist/client.d.ts +12 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +217 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/types.d.ts +31 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +34 -0
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# @astronomer-app/signals
|
|
2
|
+
|
|
3
|
+
TypeScript client for Astronomer Signal Stream.
|
|
4
|
+
|
|
5
|
+
Signal Stream delivers live signal events while a listener is running. The SDK
|
|
6
|
+
polls a short-lived JSON endpoint with a cursor, so it works reliably on
|
|
7
|
+
serverless hosting while still exposing a simple `listen()` API. A new process
|
|
8
|
+
starts from the latest signal cursor and does not replay historical events.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @astronomer-app/signals
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Listen For Signals
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { AstronomerSignals } from '@astronomer-app/signals';
|
|
20
|
+
|
|
21
|
+
const client = new AstronomerSignals({
|
|
22
|
+
apiKey: process.env.ASTRONOMER_SIGNAL_STREAM_KEY!,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const listener = client.signals.listen({
|
|
26
|
+
onSignal(signal) {
|
|
27
|
+
console.log('New signal:', signal);
|
|
28
|
+
},
|
|
29
|
+
onError(error) {
|
|
30
|
+
console.error(error);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// later
|
|
35
|
+
await listener.close();
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Signal Stream is active during the live signal window from 9:30 AM to 3:00 PM
|
|
39
|
+
Eastern Time, Monday through Friday.
|
|
40
|
+
|
|
41
|
+
## Local Example
|
|
42
|
+
|
|
43
|
+
After building the package, run the listener example with a Signal Stream key:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
ASTRONOMER_SIGNAL_STREAM_KEY=ast_live_... npm --workspace @astronomer-app/signals run example:listen
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
For local app testing, pass a custom base URL:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
ASTRONOMER_SIGNAL_STREAM_KEY=ast_live_... \
|
|
53
|
+
ASTRONOMER_SIGNAL_STREAM_BASE_URL=http://localhost:4200 \
|
|
54
|
+
npm --workspace @astronomer-app/signals run example:listen
|
|
55
|
+
```
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { AstronomerSignalsOptions, ListenOptions, SignalListener } from './types.js';
|
|
2
|
+
export declare class AstronomerSignals {
|
|
3
|
+
private readonly apiKey;
|
|
4
|
+
private readonly baseUrl;
|
|
5
|
+
private readonly fetchImpl;
|
|
6
|
+
constructor(options: AstronomerSignalsOptions);
|
|
7
|
+
signals: {
|
|
8
|
+
listen: (options: ListenOptions) => SignalListener;
|
|
9
|
+
};
|
|
10
|
+
private listenToSignals;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,wBAAwB,EACxB,aAAa,EAEb,cAAc,EACf,MAAM,YAAY,CAAC;AA4BpB,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;gBAE7B,OAAO,EAAE,wBAAwB;IAc7C,OAAO;0BACa,aAAa,KAAG,cAAc;MAEhD;IAEF,OAAO,CAAC,eAAe;CAkCxB"}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
const DEFAULT_BASE_URL = 'https://www.astronomerapp.com';
|
|
2
|
+
const DEFAULT_POLL_INTERVAL_MS = 15000;
|
|
3
|
+
const MAX_RECONNECT_DELAY_MS = 30000;
|
|
4
|
+
const MIN_RECONNECT_DELAY_MS = 1000;
|
|
5
|
+
const STREAM_PATH = '/api/v1/signals/stream';
|
|
6
|
+
class SignalStreamRequestError extends Error {
|
|
7
|
+
constructor(message, retryAfterMs) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'SignalStreamRequestError';
|
|
10
|
+
this.retryAfterMs = retryAfterMs;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class AstronomerSignals {
|
|
14
|
+
constructor(options) {
|
|
15
|
+
this.signals = {
|
|
16
|
+
listen: (options) => this.listenToSignals(options),
|
|
17
|
+
};
|
|
18
|
+
if (!options.apiKey) {
|
|
19
|
+
throw new Error('AstronomerSignals requires an apiKey');
|
|
20
|
+
}
|
|
21
|
+
this.apiKey = options.apiKey;
|
|
22
|
+
this.baseUrl = normalizeBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
|
|
23
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
24
|
+
if (!this.fetchImpl) {
|
|
25
|
+
throw new Error('AstronomerSignals requires a fetch implementation');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
listenToSignals(options) {
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const abortSignals = [controller.signal, options.signal].filter(Boolean);
|
|
31
|
+
let closed = false;
|
|
32
|
+
let closePromise = null;
|
|
33
|
+
const close = async () => {
|
|
34
|
+
if (closed)
|
|
35
|
+
return;
|
|
36
|
+
closed = true;
|
|
37
|
+
controller.abort();
|
|
38
|
+
};
|
|
39
|
+
closePromise = runSignalPoller({
|
|
40
|
+
apiKey: this.apiKey,
|
|
41
|
+
baseUrl: this.baseUrl,
|
|
42
|
+
fetchImpl: this.fetchImpl,
|
|
43
|
+
isClosed: () => closed,
|
|
44
|
+
options,
|
|
45
|
+
signal: mergeAbortSignals(abortSignals),
|
|
46
|
+
}).finally(() => {
|
|
47
|
+
closed = true;
|
|
48
|
+
options.onClose?.();
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
async close() {
|
|
52
|
+
await close();
|
|
53
|
+
await closePromise;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function runSignalPoller({ apiKey, baseUrl, fetchImpl, isClosed, options, signal, }) {
|
|
59
|
+
// The first request asks the API for the latest queue cursor without emitting
|
|
60
|
+
// historical rows. After that, each request asks for events after the cursor.
|
|
61
|
+
let cursor = 'latest';
|
|
62
|
+
let hasOpened = false;
|
|
63
|
+
let reconnectAttempt = 0;
|
|
64
|
+
while (!isClosed() && !signal.aborted) {
|
|
65
|
+
try {
|
|
66
|
+
const response = await pollOnce({
|
|
67
|
+
apiKey,
|
|
68
|
+
baseUrl,
|
|
69
|
+
cursor,
|
|
70
|
+
fetchImpl,
|
|
71
|
+
signal,
|
|
72
|
+
});
|
|
73
|
+
if (!hasOpened) {
|
|
74
|
+
hasOpened = true;
|
|
75
|
+
options.onOpen?.();
|
|
76
|
+
}
|
|
77
|
+
for (const event of response.events) {
|
|
78
|
+
options.onSignal(event);
|
|
79
|
+
}
|
|
80
|
+
cursor = response.nextCursor ?? cursor;
|
|
81
|
+
reconnectAttempt = 0;
|
|
82
|
+
await delay(getNextPollDelay(response, options), signal);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (isClosed() || signal.aborted) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
options.onError?.(error);
|
|
89
|
+
await delay(getErrorRetryDelay(error, reconnectAttempt), signal);
|
|
90
|
+
reconnectAttempt += 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function pollOnce({ apiKey, baseUrl, cursor, fetchImpl, signal, }) {
|
|
95
|
+
const url = new URL(STREAM_PATH, baseUrl);
|
|
96
|
+
if (cursor) {
|
|
97
|
+
url.searchParams.set('cursor', cursor);
|
|
98
|
+
}
|
|
99
|
+
const response = await fetchImpl(url, {
|
|
100
|
+
headers: {
|
|
101
|
+
Accept: 'application/json',
|
|
102
|
+
Authorization: `Bearer ${apiKey}`,
|
|
103
|
+
},
|
|
104
|
+
signal,
|
|
105
|
+
});
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new SignalStreamRequestError(`Signal Stream request failed with status ${response.status}`, getRetryAfterMs(response));
|
|
108
|
+
}
|
|
109
|
+
return parsePollResponse(await response.json());
|
|
110
|
+
}
|
|
111
|
+
function parsePollResponse(data) {
|
|
112
|
+
if (!isRecord(data) || !Array.isArray(data.events)) {
|
|
113
|
+
throw new Error('Received malformed Signal Stream response');
|
|
114
|
+
}
|
|
115
|
+
const events = data.events.map(parseSignalEvent);
|
|
116
|
+
const nextCursor = typeof data.nextCursor === 'string' || data.nextCursor === null
|
|
117
|
+
? data.nextCursor
|
|
118
|
+
: null;
|
|
119
|
+
const retryAfterMs = typeof data.retryAfterMs === 'number' && data.retryAfterMs > 0
|
|
120
|
+
? data.retryAfterMs
|
|
121
|
+
: undefined;
|
|
122
|
+
const window = isRecord(data.window)
|
|
123
|
+
? {
|
|
124
|
+
open: data.window.open === true,
|
|
125
|
+
message: typeof data.window.message === 'string'
|
|
126
|
+
? data.window.message
|
|
127
|
+
: undefined,
|
|
128
|
+
}
|
|
129
|
+
: undefined;
|
|
130
|
+
return {
|
|
131
|
+
events,
|
|
132
|
+
nextCursor,
|
|
133
|
+
retryAfterMs,
|
|
134
|
+
window,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function getNextPollDelay(response, options) {
|
|
138
|
+
if (response.window?.open === false && response.retryAfterMs) {
|
|
139
|
+
return response.retryAfterMs;
|
|
140
|
+
}
|
|
141
|
+
return (options.pollIntervalMs ?? response.retryAfterMs ?? DEFAULT_POLL_INTERVAL_MS);
|
|
142
|
+
}
|
|
143
|
+
function getErrorRetryDelay(error, attempt) {
|
|
144
|
+
if (error instanceof SignalStreamRequestError &&
|
|
145
|
+
typeof error.retryAfterMs === 'number') {
|
|
146
|
+
return error.retryAfterMs;
|
|
147
|
+
}
|
|
148
|
+
return getReconnectDelay(attempt);
|
|
149
|
+
}
|
|
150
|
+
function getRetryAfterMs(response) {
|
|
151
|
+
const retryAfter = response.headers.get('retry-after');
|
|
152
|
+
if (!retryAfter) {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
const retryAfterSeconds = Number.parseInt(retryAfter, 10);
|
|
156
|
+
return Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
|
|
157
|
+
? retryAfterSeconds * 1000
|
|
158
|
+
: undefined;
|
|
159
|
+
}
|
|
160
|
+
function parseSignalEvent(data) {
|
|
161
|
+
if (!isRecord(data)) {
|
|
162
|
+
throw new Error('Received malformed Signal Stream event');
|
|
163
|
+
}
|
|
164
|
+
const signal = data.signal;
|
|
165
|
+
if (data.type !== 'signal.created' ||
|
|
166
|
+
typeof data.id !== 'string' ||
|
|
167
|
+
typeof data.createdAt !== 'string' ||
|
|
168
|
+
!isRecord(signal) ||
|
|
169
|
+
typeof signal.id !== 'string' ||
|
|
170
|
+
typeof signal.symbol !== 'string' ||
|
|
171
|
+
(signal.optionType !== 'C' && signal.optionType !== 'P') ||
|
|
172
|
+
typeof signal.strikePrice !== 'number') {
|
|
173
|
+
throw new Error('Received malformed Signal Stream event');
|
|
174
|
+
}
|
|
175
|
+
return data;
|
|
176
|
+
}
|
|
177
|
+
function isRecord(value) {
|
|
178
|
+
return typeof value === 'object' && value !== null;
|
|
179
|
+
}
|
|
180
|
+
function getReconnectDelay(attempt) {
|
|
181
|
+
return Math.min(MAX_RECONNECT_DELAY_MS, MIN_RECONNECT_DELAY_MS * 2 ** attempt);
|
|
182
|
+
}
|
|
183
|
+
function delay(durationMs, signal) {
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
if (signal.aborted) {
|
|
186
|
+
reject(signal.reason);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const timeout = setTimeout(resolve, durationMs);
|
|
190
|
+
signal.addEventListener('abort', () => {
|
|
191
|
+
clearTimeout(timeout);
|
|
192
|
+
reject(signal.reason);
|
|
193
|
+
}, { once: true });
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
function mergeAbortSignals(signals) {
|
|
197
|
+
if (signals.length === 1) {
|
|
198
|
+
return signals[0];
|
|
199
|
+
}
|
|
200
|
+
const controller = new AbortController();
|
|
201
|
+
const abort = (signal) => {
|
|
202
|
+
if (!controller.signal.aborted) {
|
|
203
|
+
controller.abort(signal.reason);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
for (const signal of signals) {
|
|
207
|
+
if (signal.aborted) {
|
|
208
|
+
abort(signal);
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
signal.addEventListener('abort', () => abort(signal), { once: true });
|
|
212
|
+
}
|
|
213
|
+
return controller.signal;
|
|
214
|
+
}
|
|
215
|
+
function normalizeBaseUrl(baseUrl) {
|
|
216
|
+
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
|
217
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAChD,YAAY,EACV,wBAAwB,EACxB,aAAa,EACb,WAAW,EACX,eAAe,EACf,cAAc,GACf,MAAM,YAAY,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { AstronomerSignals } from './client.js';
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type SignalEventType = 'signal.created';
|
|
2
|
+
export interface SignalEvent {
|
|
3
|
+
id: string;
|
|
4
|
+
type: SignalEventType;
|
|
5
|
+
createdAt: string;
|
|
6
|
+
signal: {
|
|
7
|
+
id: string;
|
|
8
|
+
symbol: string;
|
|
9
|
+
optionType: 'C' | 'P';
|
|
10
|
+
strikePrice: number;
|
|
11
|
+
openedAt?: string;
|
|
12
|
+
priceAtOpen?: number;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface AstronomerSignalsOptions {
|
|
16
|
+
apiKey: string;
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
fetch?: typeof fetch;
|
|
19
|
+
}
|
|
20
|
+
export interface ListenOptions {
|
|
21
|
+
pollIntervalMs?: number;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
onSignal(event: SignalEvent): void;
|
|
24
|
+
onError?(error: unknown): void;
|
|
25
|
+
onOpen?(): void;
|
|
26
|
+
onClose?(): void;
|
|
27
|
+
}
|
|
28
|
+
export interface SignalListener {
|
|
29
|
+
close(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GAAG,gBAAgB,CAAC;AAE/C,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,eAAe,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE;QACN,EAAE,EAAE,MAAM,CAAC;QACX,MAAM,EAAE,MAAM,CAAC;QACf,UAAU,EAAE,GAAG,GAAG,GAAG,CAAC;QACtB,WAAW,EAAE,MAAM,CAAC;QACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC;CACH;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI,CAAC;IACnC,OAAO,CAAC,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IAC/B,MAAM,CAAC,IAAI,IAAI,CAAC;IAChB,OAAO,CAAC,IAAI,IAAI,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@astronomer-app/signals",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "TypeScript client for Astronomer Signal Stream.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"example:listen": "npm run build && node examples/listen.mjs",
|
|
21
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"astronomer",
|
|
25
|
+
"signals",
|
|
26
|
+
"polling",
|
|
27
|
+
"trading"
|
|
28
|
+
],
|
|
29
|
+
"license": "UNLICENSED",
|
|
30
|
+
"sideEffects": false,
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
}
|
|
34
|
+
}
|