market-feed 0.1.0 → 0.4.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 (61) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +393 -5
  3. package/dist/calendar.cjs +508 -0
  4. package/dist/calendar.cjs.map +1 -0
  5. package/dist/calendar.d.cts +42 -0
  6. package/dist/calendar.d.ts +42 -0
  7. package/dist/calendar.js +498 -0
  8. package/dist/calendar.js.map +1 -0
  9. package/dist/cli.js +1582 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/client-CfZS7mw6.d.ts +107 -0
  12. package/dist/client-DMChcXq5.d.cts +107 -0
  13. package/dist/consensus.cjs +181 -0
  14. package/dist/consensus.cjs.map +1 -0
  15. package/dist/consensus.d.cts +112 -0
  16. package/dist/consensus.d.ts +112 -0
  17. package/dist/consensus.js +174 -0
  18. package/dist/consensus.js.map +1 -0
  19. package/dist/errors-CbrhDR4X.d.cts +39 -0
  20. package/dist/errors-CbrhDR4X.d.ts +39 -0
  21. package/dist/historical-BbCuwqyZ.d.cts +30 -0
  22. package/dist/historical-BbCuwqyZ.d.ts +30 -0
  23. package/dist/index.cjs +334 -60
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +70 -347
  26. package/dist/index.d.ts +70 -347
  27. package/dist/index.js +331 -61
  28. package/dist/index.js.map +1 -1
  29. package/dist/indicators.cjs +178 -0
  30. package/dist/indicators.cjs.map +1 -0
  31. package/dist/indicators.d.cts +98 -0
  32. package/dist/indicators.d.ts +98 -0
  33. package/dist/indicators.js +169 -0
  34. package/dist/indicators.js.map +1 -0
  35. package/dist/market-B9DVrpuM.d.cts +22 -0
  36. package/dist/market-B9DVrpuM.d.ts +22 -0
  37. package/dist/portfolio.cjs +119 -0
  38. package/dist/portfolio.cjs.map +1 -0
  39. package/dist/portfolio.d.cts +112 -0
  40. package/dist/portfolio.d.ts +112 -0
  41. package/dist/portfolio.js +117 -0
  42. package/dist/portfolio.js.map +1 -0
  43. package/dist/provider-0hpSUPR2.d.cts +118 -0
  44. package/dist/provider-DK6G4Nmp.d.ts +118 -0
  45. package/dist/quote-Cfh_7Cgg.d.cts +48 -0
  46. package/dist/quote-Cfh_7Cgg.d.ts +48 -0
  47. package/dist/stream.cjs +552 -0
  48. package/dist/stream.cjs.map +1 -0
  49. package/dist/stream.d.cts +113 -0
  50. package/dist/stream.d.ts +113 -0
  51. package/dist/stream.js +550 -0
  52. package/dist/stream.js.map +1 -0
  53. package/dist/types-DVfrmc4W.d.cts +27 -0
  54. package/dist/types-DVfrmc4W.d.ts +27 -0
  55. package/dist/ws.cjs +364 -0
  56. package/dist/ws.cjs.map +1 -0
  57. package/dist/ws.d.cts +111 -0
  58. package/dist/ws.d.ts +111 -0
  59. package/dist/ws.js +362 -0
  60. package/dist/ws.js.map +1 -0
  61. package/package.json +60 -20
package/CHANGELOG.md CHANGED
@@ -1,5 +1,218 @@
1
1
  # market-feed Changelog
2
2
 
3
+ ## 0.4.0 — 2026-03-11
4
+
5
+ ### New module
6
+
7
+ **`market-feed/ws`** — True WebSocket streaming for tick-by-tick trade data.
8
+
9
+ Unlike `market-feed/stream` (which is HTTP polling), `market-feed/ws` opens a
10
+ persistent WebSocket connection and yields individual trade executions in real time.
11
+
12
+ ```ts
13
+ import { connect } from "market-feed/ws";
14
+ import { FinnhubProvider } from "market-feed";
15
+
16
+ const provider = new FinnhubProvider({ apiKey: process.env.FINNHUB_KEY! });
17
+ const controller = new AbortController();
18
+
19
+ for await (const event of connect(provider, ["AAPL", "MSFT"], { signal: controller.signal })) {
20
+ switch (event.type) {
21
+ case "trade":
22
+ console.log(`${event.trade.symbol}: $${event.trade.price} × ${event.trade.size}`);
23
+ break;
24
+ case "connected":
25
+ console.log(`Connected to ${event.provider}`);
26
+ break;
27
+ case "disconnected":
28
+ console.log(`Disconnected (reconnecting: ${event.reconnecting})`);
29
+ break;
30
+ case "error":
31
+ if (!event.recoverable) throw event.error;
32
+ break;
33
+ }
34
+ }
35
+ ```
36
+
37
+ #### Provider support
38
+
39
+ | Provider | WebSocket | Notes |
40
+ |----------|-----------|-------|
41
+ | **PolygonProvider** | Native WS | `wss://socket.polygon.io/stocks` — auth via JSON handshake, subscribes to `T.*` trade channel |
42
+ | **FinnhubProvider** | Native WS | `wss://ws.finnhub.io?token=KEY` — per-symbol subscribe, batched trade messages |
43
+ | **YahooProvider** | Polling fallback | Polls `provider.quote()` every 5 s; emits `WsTrade` from quote data |
44
+ | **AlphaVantageProvider** | Polling fallback | Same as Yahoo |
45
+
46
+ The `connect()` function detects provider capability automatically — no configuration required.
47
+
48
+ #### `WsEvent` union
49
+
50
+ | `type` | Payload | When |
51
+ |--------|---------|------|
52
+ | `"connected"` | `provider: string` | WS opened (and after each reconnect) |
53
+ | `"trade"` | `trade: WsTrade` | Each trade tick |
54
+ | `"disconnected"` | `provider`, `reconnecting`, `attempt` | WS closed unexpectedly |
55
+ | `"error"` | `error`, `recoverable` | Protocol or network error |
56
+
57
+ #### `WsTrade`
58
+
59
+ ```ts
60
+ interface WsTrade {
61
+ symbol: string;
62
+ price: number;
63
+ size: number; // shares / units
64
+ timestamp: Date;
65
+ conditions?: number[]; // provider-specific condition codes
66
+ }
67
+ ```
68
+
69
+ #### `WsOptions`
70
+
71
+ | Option | Type | Default | Description |
72
+ |--------|------|---------|-------------|
73
+ | `wsImpl` | `typeof globalThis.WebSocket` | `globalThis.WebSocket` | Custom WS constructor for Node 18–20 |
74
+ | `maxReconnectAttempts` | `number` | `10` | Reconnect attempts before closing |
75
+ | `reconnectDelayMs` | `number` | `1000` | Base delay (doubles per attempt, max 30 s) |
76
+ | `signal` | `AbortSignal` | — | Stop the stream |
77
+
78
+ #### Node 18–20 compatibility
79
+
80
+ Node 21+ exposes `WebSocket` globally. For Node 18/20, install the `ws` package and inject it:
81
+
82
+ ```ts
83
+ import WebSocket from "ws";
84
+ connect(provider, ["AAPL"], { wsImpl: WebSocket as unknown as typeof globalThis.WebSocket })
85
+ ```
86
+
87
+ ### Other changes
88
+
89
+ - `PolygonProvider` now exposes `get wsApiKey(): string` (used internally by `market-feed/ws`)
90
+ - `FinnhubProvider` now exposes `get wsApiKey(): string` (used internally by `market-feed/ws`)
91
+ - 27 new unit tests (341 total across 18 test files)
92
+ - 7 tsup library entry points + 1 CLI binary: `index`, `calendar`, `stream`, `consensus`, `indicators`, `portfolio`, `ws`, `cli`
93
+
94
+ ### Breaking changes
95
+
96
+ None. All v0.3.0 imports continue to work unchanged.
97
+
98
+ ---
99
+
100
+ ## 0.3.0 — 2026-03-11
101
+
102
+ ### New provider
103
+
104
+ **`FinnhubProvider`** — Finnhub.io (free tier: real-time US stock data, 60 calls/minute).
105
+ - `quote`, `historical` (candles), `search`, `company`, `news`
106
+ - API key required — get one free at https://finnhub.io
107
+ - Uses `X-Finnhub-Token` header; rate-limited client-side at 60 req/min
108
+ - `historical` maps standard intervals to Finnhub resolutions (1m→"1", 1d→"D", 1wk→"W", 1mo→"M")
109
+ - `news` fetches articles from the last 30 days by default
110
+
111
+ ### New modules
112
+
113
+ **`market-feed/indicators`** — Technical indicators as pure functions over `HistoricalBar[]`.
114
+ - `sma(bars, period)` — Simple Moving Average (O(1) sliding window)
115
+ - `ema(bars, period)` — Exponential Moving Average (k = 2/(period+1), SMA-seeded)
116
+ - `rsi(bars, period?)` — Relative Strength Index via Wilder's smoothing (default period: 14)
117
+ - `macd(bars, fast?, slow?, signal?)` — MACD line, signal line, histogram (default 12/26/9)
118
+ - `bollingerBands(bars, period?, stdDevMult?)` — upper/middle/lower bands (default 20/2)
119
+ - `atr(bars, period?)` — Average True Range via Wilder's smoothing (default period: 14)
120
+ - `vwap(bars)` — Volume-Weighted Average Price (cumulative from first bar)
121
+ - `stochastic(bars, kPeriod?, dPeriod?)` — %K and %D oscillator (default 14/3)
122
+ - Zero dependencies, no network, tree-shakeable per indicator
123
+ - All functions return typed result arrays (`IndicatorPoint[]`, `MACDPoint[]`, `BollingerPoint[]`, `StochasticPoint[]`)
124
+
125
+ **`market-feed/portfolio`** — Track positions and compute live P&L.
126
+ - `new Portfolio(positions?)` — construct with an array of `Position` objects
127
+ - `portfolio.add(position)` / `portfolio.remove(symbol)` — mutable, chainable
128
+ - `portfolio.snapshot(feed)` — fetches live quotes and returns `PortfolioSnapshot` with per-position and aggregate P&L
129
+ - `PositionSnapshot` includes: `marketValue`, `costBasis`, `unrealizedPnl`, `unrealizedPnlPct`, `dayChange`, `dayChangePct`, `quote`
130
+ - `PortfolioSnapshot` includes aggregate totals: `totalMarketValue`, `totalCostBasis`, `totalUnrealizedPnl`, `totalDayChange`
131
+ - Supports long and short positions (negative `quantity`)
132
+ - Uses `QuoteFetcher` duck-type interface — accepts any object with a `quote(symbols)` method, including `MarketFeed`
133
+
134
+ ### CLI (`npx market-feed`)
135
+
136
+ A zero-install CLI that uses Yahoo Finance by default (no API key needed).
137
+
138
+ ```
139
+ market-feed quote AAPL MSFT GOOGL
140
+ market-feed historical AAPL --interval 1wk --period1 2024-01-01
141
+ market-feed search "apple inc"
142
+ market-feed company AAPL
143
+ market-feed news AAPL --limit 5 --json
144
+ ```
145
+
146
+ Flags: `--av-key`, `--polygon-key`, `--finnhub-key`, `--json`, `--limit`, `--interval`, `--period1`, `--period2`
147
+
148
+ ### Crypto / Forex support
149
+
150
+ - `isCrypto(symbol)` — detects `"BTC-USD"`, `"BTC/USD"`, `"X:BTCUSD"` using a known-crypto-bases set
151
+ - `isForex(symbol)` — detects `"EURUSD=X"`, `"C:EURUSD"`, `"OANDA:EUR_USD"`, `"EUR/USD"`
152
+ - `toFinnhubSymbol(symbol)` — normalises symbol for Finnhub
153
+ - `CRYPTO` exchange added to the calendar — `alwaysOpen: true`, no holidays, no session boundaries
154
+ - `isMarketOpen("CRYPTO")` → always `true`
155
+ - `getSession("CRYPTO")` → always `"regular"`
156
+ - `isHoliday("CRYPTO")` → always `false`
157
+ - `nextSessionOpen("CRYPTO")` → returns `from` (already open)
158
+ - `nextSessionClose("CRYPTO")` → returns `from + 24h` (rolling window)
159
+
160
+ ### Breaking changes
161
+
162
+ None. All v0.2.0 imports continue to work unchanged.
163
+
164
+ ### Other changes
165
+
166
+ - `isCrypto`, `isForex`, `toFinnhubSymbol` exported from main `market-feed` entry point
167
+ - `FinnhubProvider` and `FinnhubProviderOptions` exported from main `market-feed` entry point
168
+ - 94 new unit tests (314 total across 17 test files)
169
+ - 6 library tsup entry points + 1 CLI binary: `index`, `calendar`, `stream`, `consensus`, `indicators`, `portfolio`, `cli`
170
+
171
+ ---
172
+
173
+ ## 0.2.0 — 2026-03-11
174
+
175
+ ### New modules
176
+
177
+ **`market-feed/calendar`** — Synchronous exchange calendar. No network required.
178
+ - `isMarketOpen(exchange, at?)` — boolean, DST-correct via `Intl`
179
+ - `getSession(exchange, at?)` — `"pre" | "regular" | "post" | "closed"`
180
+ - `nextSessionOpen(exchange, from?)` / `nextSessionClose(exchange, from?)` — next UTC Date
181
+ - `isHoliday(exchange, date?)` / `isEarlyClose(exchange, date?)` — boolean
182
+ - `getHolidayDates(exchange, year)` — all holidays for a given year
183
+ - `getExchangeInfo(exchange)` — name, MIC, timezone, open/close times, currency
184
+ - Supports: NYSE, NASDAQ, LSE, TSX, ASX, XETRA, NSE, BSE
185
+ - Holiday rules computed from first principles (Easter via Meeus/Jones/Butcher algorithm, all US federal/NYSE-specific rules, UK bank holidays, Canadian/Australian/German/Indian holidays)
186
+ - Early-close days (NYSE: day before Thanksgiving, Independence Day, Christmas Eve)
187
+
188
+ **`market-feed/stream`** — Market-hours-aware observable quote stream.
189
+ - `watch(feed, symbols, options)` — async generator yielding typed `StreamEvent` union
190
+ - Polls at `interval.open` (default 5s) during regular hours, `interval.prepost` (default 30s) pre/post, pauses at `interval.closed` (default 60s) when closed — saves API quota overnight and on weekends
191
+ - Emits `market-open` / `market-close` events at session transitions
192
+ - Emits `divergence` events when multiple configured providers disagree beyond `divergenceThreshold`
193
+ - Graceful `AbortSignal` cancellation
194
+ - Configurable `maxErrors` before the generator throws
195
+
196
+ **`market-feed/consensus`** — Multi-provider parallel price consensus.
197
+ - `consensus(providers, symbol, options)` — queries all providers simultaneously via `Promise.allSettled`
198
+ - Median-based outlier detection (avoids the all-outlier edge case of mean-based approaches)
199
+ - Staleness detection: providers with quotes older than `stalenessThreshold` receive half weight
200
+ - Returns `ConsensusResult` with `price`, `confidence` (0–1), `spread`, `spreadPct`, per-provider breakdown, and `flags`
201
+ - Flags: `HIGH_DIVERGENCE`, `STALE_DATA`, `SINGLE_SOURCE`, `OUTLIER_EXCLUDED`
202
+ - Algorithm helpers exported: `normalizeWeights`, `applyStalenessPenalty`, `weightedMean`, `detectOutliers`, `computeConfidence`
203
+
204
+ ### Breaking changes
205
+
206
+ None. All v0.1.0 imports continue to work unchanged.
207
+
208
+ ### Other changes
209
+
210
+ - `MarketFeed` now exposes `get providers(): readonly MarketProvider[]` — read-only view of configured providers, used by `watch()` for divergence detection and `consensus()` for parallel querying
211
+ - 78 new unit tests (220 total across 13 test files)
212
+ - 4 tsup entry points: `index`, `calendar`, `stream`, `consensus` — each tree-shaken independently
213
+
214
+ ---
215
+
3
216
  ## 0.1.0 — 2026-03-10
4
217
 
5
218
  ### Initial release
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # market-feed
2
2
 
3
3
  > Unified TypeScript client for financial market data.
4
- > Wraps Yahoo Finance, Alpha Vantage, and Polygon.io under one consistent interface — with caching and automatic fallback built in.
4
+ > Wraps Yahoo Finance, Alpha Vantage, Polygon.io, and Finnhub under one consistent interface — with caching and automatic fallback built in.
5
5
 
6
6
  [![CI](https://github.com/piyushgupta344/market-feed/actions/workflows/ci.yml/badge.svg)](https://github.com/piyushgupta344/market-feed/actions/workflows/ci.yml)
7
7
  [![npm version](https://badge.fury.io/js/market-feed.svg)](https://www.npmjs.com/package/market-feed)
@@ -39,7 +39,7 @@ const quote = await feed.quote("AAPL");
39
39
  console.log(quote.price); // always a number, always the same key
40
40
  ```
41
41
 
42
- One interface. Three providers. Zero API key required for Yahoo Finance.
42
+ One interface. Four providers. Zero API key required for Yahoo Finance.
43
43
 
44
44
  ---
45
45
 
@@ -53,6 +53,391 @@ One interface. Three providers. Zero API key required for Yahoo Finance.
53
53
  - **Strict TypeScript** — no `any`, full autocomplete, compile-time safety
54
54
  - **Multi-runtime** — Node 18+, Bun 1+, Deno 2+, Cloudflare Workers
55
55
  - **Escape hatch** — pass `{ raw: true }` to get the original provider response
56
+ - **Exchange calendar** — synchronous, offline-capable holiday and session detection for 8 exchanges + crypto (24/7)
57
+ - **WebSocket streaming** — `market-feed/ws` opens a persistent WS connection to Polygon or Finnhub; polling fallback for Yahoo/AV
58
+ - **Observable stream** — market-hours-aware HTTP polling that pauses overnight and on weekends
59
+ - **Price consensus** — query all providers in parallel, get a weighted mean with confidence score
60
+ - **Technical indicators** — SMA, EMA, RSI, MACD, Bollinger Bands, ATR, VWAP, Stochastic — pure functions, zero deps
61
+ - **Portfolio tracking** — live P&L, unrealised gains, day change across all positions
62
+ - **CLI** — `npx market-feed quote AAPL` — no install required
63
+ - **Crypto & Forex** — `isCrypto()` / `isForex()` helpers, CRYPTO calendar exchange (always open)
64
+
65
+ ---
66
+
67
+ ## Subpath modules
68
+
69
+ `market-feed` ships six optional subpath modules alongside the core client.
70
+
71
+ ### `market-feed/ws`
72
+
73
+ True WebSocket streaming — tick-by-tick trade data from Polygon and Finnhub, with automatic polling fallback for other providers.
74
+
75
+ ```ts
76
+ import { connect } from "market-feed/ws";
77
+ import { MarketFeed, FinnhubProvider, PolygonProvider } from "market-feed";
78
+
79
+ // Finnhub — real-time WebSocket
80
+ const provider = new FinnhubProvider({ apiKey: process.env.FINNHUB_KEY! });
81
+
82
+ // Polygon — real-time WebSocket (requires paid plan for true real-time)
83
+ // const provider = new PolygonProvider({ apiKey: process.env.POLYGON_KEY! });
84
+
85
+ const controller = new AbortController();
86
+
87
+ for await (const event of connect(provider, ["AAPL", "MSFT", "TSLA"], {
88
+ signal: controller.signal,
89
+ })) {
90
+ switch (event.type) {
91
+ case "trade":
92
+ console.log(`${event.trade.symbol}: $${event.trade.price} × ${event.trade.size} shares`);
93
+ break;
94
+ case "connected":
95
+ console.log(`Connected to ${event.provider}`);
96
+ break;
97
+ case "disconnected":
98
+ console.log(`Disconnected (attempt ${event.attempt}, reconnecting: ${event.reconnecting})`);
99
+ break;
100
+ case "error":
101
+ if (!event.recoverable) throw event.error;
102
+ break;
103
+ }
104
+ }
105
+
106
+ controller.abort(); // stop the stream
107
+ ```
108
+
109
+ Unlike `market-feed/stream` (which is HTTP polling), `market-feed/ws` opens a persistent WebSocket connection and yields individual trade executions in real time.
110
+
111
+ #### Provider support
112
+
113
+ | Provider | WebSocket | Notes |
114
+ |----------|-----------|-------|
115
+ | `PolygonProvider` | Native WS | Auth via JSON handshake, subscribes to `T.*` trades |
116
+ | `FinnhubProvider` | Native WS | Token in URL, per-symbol subscribe |
117
+ | `YahooProvider` | Polling fallback | Polls `quote()` every 5 s |
118
+ | `AlphaVantageProvider` | Polling fallback | Same as Yahoo |
119
+
120
+ #### `WsOptions`
121
+
122
+ | Option | Type | Default | Description |
123
+ |--------|------|---------|-------------|
124
+ | `wsImpl` | `typeof WebSocket` | `globalThis.WebSocket` | Custom WS constructor for Node 18–20 |
125
+ | `maxReconnectAttempts` | `number` | `10` | Reconnects before closing |
126
+ | `reconnectDelayMs` | `number` | `1000` | Base delay (doubles per attempt, max 30 s) |
127
+ | `signal` | `AbortSignal` | — | Stop the stream |
128
+
129
+ #### Node 18–20
130
+
131
+ Node 21+, Bun, Deno, and Cloudflare Workers expose `WebSocket` globally. For Node 18–20, install the `ws` package and inject it:
132
+
133
+ ```ts
134
+ import WebSocket from "ws";
135
+ connect(provider, ["AAPL"], { wsImpl: WebSocket as unknown as typeof globalThis.WebSocket })
136
+ ```
137
+
138
+ ---
139
+
140
+ ### `market-feed/calendar`
141
+
142
+ Synchronous exchange calendar — no network, no async, works offline.
143
+
144
+ ```ts
145
+ import {
146
+ isMarketOpen,
147
+ getSession,
148
+ nextSessionOpen,
149
+ nextSessionClose,
150
+ isHoliday,
151
+ isEarlyClose,
152
+ getHolidayDates,
153
+ getExchangeInfo,
154
+ } from "market-feed/calendar";
155
+
156
+ // Is NYSE open right now?
157
+ isMarketOpen("NYSE"); // true | false
158
+
159
+ // What session is it?
160
+ getSession("NYSE"); // "pre" | "regular" | "post" | "closed"
161
+
162
+ // When does it next open?
163
+ nextSessionOpen("NYSE"); // Date (UTC)
164
+ nextSessionClose("NYSE"); // Date (UTC)
165
+
166
+ // Holiday checks
167
+ isHoliday("LSE"); // false
168
+ isHoliday("NYSE", new Date("2025-04-18")); // true — Good Friday
169
+ isEarlyClose("NYSE"); // true on day before Thanksgiving
170
+
171
+ // All holidays for a year
172
+ getHolidayDates("NYSE", 2026); // Date[]
173
+
174
+ // Exchange metadata
175
+ getExchangeInfo("LSE");
176
+ // { id: "LSE", name: "London Stock Exchange", mic: "XLON",
177
+ // timezone: "Europe/London", openTime: "08:00", closeTime: "16:30", ... }
178
+ ```
179
+
180
+ Supports **NYSE, NASDAQ, LSE, TSX, ASX, XETRA, NSE, BSE**, and **CRYPTO** (always open — no sessions, no holidays). Holiday rules are computed from first principles — Easter via the Meeus/Jones/Butcher algorithm, all NYSE-specific rules (MLK Day, Presidents' Day, Juneteenth, Memorial Day, Labor Day, Thanksgiving, Good Friday), UK bank holidays, Canadian, Australian, German, and Indian exchanges. DST is handled via `Intl.DateTimeFormat` — no manual offset arithmetic.
181
+
182
+ ---
183
+
184
+ ### `market-feed/stream`
185
+
186
+ Market-hours-aware async generator. Polls during open hours, pauses when the market is closed.
187
+
188
+ ```ts
189
+ import { watch } from "market-feed/stream";
190
+ import { MarketFeed, YahooProvider, PolygonProvider } from "market-feed";
191
+
192
+ const feed = new MarketFeed({
193
+ providers: [
194
+ new YahooProvider(),
195
+ new PolygonProvider({ apiKey: process.env.POLYGON_KEY }),
196
+ ],
197
+ });
198
+
199
+ const controller = new AbortController();
200
+
201
+ for await (const event of watch(feed, ["AAPL", "MSFT"], {
202
+ exchange: "NYSE",
203
+ interval: {
204
+ open: 5_000, // poll every 5s during regular hours
205
+ prepost: 30_000, // every 30s pre/post market
206
+ closed: 60_000, // check every 60s for session open
207
+ },
208
+ divergenceThreshold: 0.5, // % spread between providers
209
+ signal: controller.signal,
210
+ })) {
211
+ switch (event.type) {
212
+ case "quote":
213
+ console.log(`${event.symbol}: $${event.quote.price}`);
214
+ break;
215
+ case "market-open":
216
+ console.log(`${event.exchange} session started: ${event.session}`);
217
+ break;
218
+ case "market-close":
219
+ console.log(`${event.exchange} session ended`);
220
+ break;
221
+ case "divergence":
222
+ console.log(`${event.symbol} providers disagree: ${event.spreadPct.toFixed(2)}%`);
223
+ break;
224
+ case "error":
225
+ if (!event.recoverable) throw event.error;
226
+ break;
227
+ }
228
+ }
229
+
230
+ // Stop the stream
231
+ controller.abort();
232
+ ```
233
+
234
+ When `marketHoursAware: true` (default), the stream pauses completely during closed sessions — no wasted API calls overnight or on weekends. It emits `market-open` / `market-close` events at session boundaries. When the feed has multiple providers, it detects price divergence across them and emits `divergence` events.
235
+
236
+ #### `WatchOptions`
237
+
238
+ | Option | Type | Default | Description |
239
+ |--------|------|---------|-------------|
240
+ | `exchange` | `ExchangeId` | `"NYSE"` | Calendar to use for session detection |
241
+ | `interval.open` | `number` | `5000` | Poll interval (ms) during regular hours |
242
+ | `interval.prepost` | `number` | `30000` | Poll interval (ms) during pre/post market |
243
+ | `interval.closed` | `number` | `60000` | Check interval (ms) when market is closed |
244
+ | `marketHoursAware` | `boolean` | `true` | Pause during closed sessions |
245
+ | `divergenceThreshold` | `number` | `0.5` | % spread that triggers a divergence event |
246
+ | `maxErrors` | `number` | `5` | Consecutive errors before the generator throws |
247
+ | `signal` | `AbortSignal` | — | Cancel the stream |
248
+
249
+ ---
250
+
251
+ ### `market-feed/consensus`
252
+
253
+ Queries all configured providers simultaneously and returns a statistically-weighted price consensus.
254
+
255
+ Unlike `feed.quote()` which stops at the first successful provider, `consensus()` fires all providers in parallel and combines their results.
256
+
257
+ ```ts
258
+ import { consensus } from "market-feed/consensus";
259
+ import { MarketFeed, YahooProvider, AlphaVantageProvider, PolygonProvider } from "market-feed";
260
+
261
+ const feed = new MarketFeed({
262
+ providers: [
263
+ new YahooProvider(),
264
+ new AlphaVantageProvider({ apiKey: process.env.AV_KEY }),
265
+ new PolygonProvider({ apiKey: process.env.POLYGON_KEY }),
266
+ ],
267
+ });
268
+
269
+ const result = await consensus(feed.providers, "AAPL");
270
+
271
+ console.log(result.price); // 189.82 — weighted mean
272
+ console.log(result.confidence); // 0.97 — 0=no agreement, 1=perfect
273
+ console.log(result.spread); // 0.08 — max - min across providers
274
+ console.log(result.spreadPct); // 0.042 — spread as % of price
275
+ console.log(result.flags); // [] or ["HIGH_DIVERGENCE", "STALE_DATA", ...]
276
+ console.log(result.providers);
277
+ // {
278
+ // yahoo: { price: 189.84, weight: 0.33, stale: false, included: true },
279
+ // polygon: { price: 189.80, weight: 0.33, stale: false, included: true },
280
+ // "alpha-vantage": { price: 189.82, weight: 0.33, stale: false, included: true },
281
+ // }
282
+ ```
283
+
284
+ #### `ConsensusOptions`
285
+
286
+ | Option | Type | Default | Description |
287
+ |--------|------|---------|-------------|
288
+ | `stalenessThreshold` | `number` | `60` | Seconds before a quote is stale. Stale providers get half weight. |
289
+ | `divergenceThreshold` | `number` | `2.0` | % deviation from median that marks a provider as an outlier |
290
+ | `weights` | `Record<string, number>` | equal | Custom weights per provider name (normalized automatically) |
291
+
292
+ #### Flags
293
+
294
+ | Flag | Meaning |
295
+ |------|---------|
296
+ | `HIGH_DIVERGENCE` | `spreadPct` exceeds `divergenceThreshold` |
297
+ | `STALE_DATA` | At least one provider returned a quote older than `stalenessThreshold` |
298
+ | `SINGLE_SOURCE` | Only one provider responded successfully |
299
+ | `OUTLIER_EXCLUDED` | At least one provider was excluded as a price outlier |
300
+
301
+ ---
302
+
303
+ ### `market-feed/indicators`
304
+
305
+ Technical indicators as pure functions over `HistoricalBar[]`. No network, no async, tree-shakeable.
306
+
307
+ ```ts
308
+ import {
309
+ sma, ema, rsi, macd, bollingerBands, atr, vwap, stochastic,
310
+ } from "market-feed/indicators";
311
+ import { MarketFeed } from "market-feed";
312
+
313
+ const feed = new MarketFeed();
314
+ const bars = await feed.historical("AAPL", { period1: "2024-01-01", interval: "1d" });
315
+
316
+ // Simple / Exponential Moving Average
317
+ const sma20 = sma(bars, 20); // IndicatorPoint[] — { date, value }[]
318
+ const ema12 = ema(bars, 12);
319
+
320
+ // Relative Strength Index
321
+ const rsi14 = rsi(bars, 14); // values in [0, 100]
322
+
323
+ // MACD
324
+ const macdResult = macd(bars); // MACDPoint[] — { date, macd, signal, histogram }[]
325
+
326
+ // Bollinger Bands
327
+ const bb = bollingerBands(bars, 20, 2); // BollingerPoint[] — { date, upper, middle, lower }[]
328
+
329
+ // Average True Range
330
+ const atr14 = atr(bars, 14);
331
+
332
+ // VWAP (cumulative from first bar)
333
+ const vwapPoints = vwap(bars);
334
+
335
+ // Stochastic Oscillator
336
+ const stoch = stochastic(bars, 14, 3); // StochasticPoint[] — { date, k, d }[]
337
+ ```
338
+
339
+ All functions return typed arrays starting from the first bar where enough data exists. They never throw for insufficient data — they return an empty array instead.
340
+
341
+ | Function | Output type | Default params |
342
+ |----------|------------|----------------|
343
+ | `sma(bars, period)` | `IndicatorPoint[]` | — |
344
+ | `ema(bars, period)` | `IndicatorPoint[]` | — |
345
+ | `rsi(bars, period?)` | `IndicatorPoint[]` | period: 14 |
346
+ | `macd(bars, fast?, slow?, signal?)` | `MACDPoint[]` | 12, 26, 9 |
347
+ | `bollingerBands(bars, period?, stdDevMult?)` | `BollingerPoint[]` | 20, 2 |
348
+ | `atr(bars, period?)` | `IndicatorPoint[]` | period: 14 |
349
+ | `vwap(bars)` | `IndicatorPoint[]` | — |
350
+ | `stochastic(bars, kPeriod?, dPeriod?)` | `StochasticPoint[]` | 14, 3 |
351
+
352
+ ---
353
+
354
+ ### `market-feed/portfolio`
355
+
356
+ Track a collection of positions and compute live P&L against current market prices.
357
+
358
+ ```ts
359
+ import { Portfolio } from "market-feed/portfolio";
360
+ import { MarketFeed } from "market-feed";
361
+
362
+ const feed = new MarketFeed();
363
+
364
+ const portfolio = new Portfolio([
365
+ { symbol: "AAPL", quantity: 10, avgCost: 150.00 },
366
+ { symbol: "MSFT", quantity: 5, avgCost: 280.00 },
367
+ { symbol: "TSLA", quantity: -3, avgCost: 250.00 }, // short position
368
+ ]);
369
+
370
+ // Fetch live quotes and compute P&L in one call
371
+ const snap = await portfolio.snapshot(feed);
372
+
373
+ console.log(`Total value: $${snap.totalMarketValue.toFixed(2)}`);
374
+ console.log(`Unrealised P&L: $${snap.totalUnrealizedPnl.toFixed(2)}`);
375
+ console.log(`Today's change: $${snap.totalDayChange.toFixed(2)}`);
376
+
377
+ for (const pos of snap.positions) {
378
+ const pct = (pos.unrealizedPnlPct * 100).toFixed(2);
379
+ console.log(`${pos.symbol}: $${pos.marketValue.toFixed(2)} (${pct}%)`);
380
+ }
381
+ ```
382
+
383
+ #### `Position`
384
+
385
+ | Field | Type | Description |
386
+ |-------|------|-------------|
387
+ | `symbol` | `string` | Ticker symbol |
388
+ | `quantity` | `number` | Units held. Negative = short. |
389
+ | `avgCost` | `number` | Average cost per unit |
390
+ | `currency` | `string?` | Defaults to "USD" |
391
+ | `openedAt` | `Date?` | When the position was opened |
392
+ | `notes` | `string?` | Free-form notes |
393
+
394
+ #### `Portfolio` API
395
+
396
+ ```ts
397
+ portfolio.add(position) // add or replace a position (chainable)
398
+ portfolio.remove(symbol) // remove a position (chainable)
399
+ portfolio.get(symbol) // Position | undefined
400
+ portfolio.list() // readonly Position[]
401
+ portfolio.size // number
402
+ portfolio.snapshot(feed) // Promise<PortfolioSnapshot>
403
+ ```
404
+
405
+ ---
406
+
407
+ ### CLI (`npx market-feed`)
408
+
409
+ A zero-config CLI powered by Yahoo Finance (no API key needed). Add keys to unlock more providers.
410
+
411
+ ```bash
412
+ # Quotes
413
+ npx market-feed quote AAPL MSFT GOOGL
414
+
415
+ # Historical data
416
+ npx market-feed historical AAPL --interval 1wk --period1 2024-01-01
417
+
418
+ # Symbol search
419
+ npx market-feed search "apple inc"
420
+
421
+ # Company profile
422
+ npx market-feed company AAPL
423
+
424
+ # News (JSON output)
425
+ npx market-feed news AAPL --limit 5 --json
426
+ ```
427
+
428
+ #### Options
429
+
430
+ | Flag | Description |
431
+ |------|-------------|
432
+ | `--av-key <key>` | Alpha Vantage API key |
433
+ | `--polygon-key <key>` | Polygon.io API key |
434
+ | `--finnhub-key <key>` | Finnhub API key |
435
+ | `--json` | Output raw JSON instead of formatted tables |
436
+ | `--limit <n>` | Limit results (default: 10) |
437
+ | `--interval <i>` | Historical interval: `1m` `5m` `15m` `30m` `1h` `1d` `1wk` `1mo` (default: `1d`) |
438
+ | `--period1 <date>` | Historical start date (ISO 8601) |
439
+ | `--period2 <date>` | Historical end date (ISO 8601) |
440
+ | `-h`, `--help` | Show help |
56
441
 
57
442
  ---
58
443
 
@@ -107,8 +492,9 @@ console.log(profile.sector); // "Technology"
107
492
  | **Yahoo Finance** | Not required | ✓ | ✓ | ✓ | ✓ | — | — |
108
493
  | **Alpha Vantage** | Free (25/day) | ✓ | ✓ | ✓ | ✓ | — | — |
109
494
  | **Polygon.io** | Free (delayed) | ✓ | ✓ | ✓ | ✓ | ✓ | — |
495
+ | **Finnhub** | Free (60/min) | ✓ | ✓ | ✓ | ✓ | ✓ | — |
110
496
 
111
- Get free keys: [Alpha Vantage](https://www.alphavantage.co/support/#api-key) · [Polygon.io](https://polygon.io/)
497
+ Get free keys: [Alpha Vantage](https://www.alphavantage.co/support/#api-key) · [Polygon.io](https://polygon.io/) · [Finnhub](https://finnhub.io/)
112
498
 
113
499
  ### Using multiple providers
114
500
 
@@ -118,6 +504,7 @@ import {
118
504
  YahooProvider,
119
505
  AlphaVantageProvider,
120
506
  PolygonProvider,
507
+ FinnhubProvider,
121
508
  } from "market-feed";
122
509
 
123
510
  const feed = new MarketFeed({
@@ -125,6 +512,7 @@ const feed = new MarketFeed({
125
512
  new YahooProvider(),
126
513
  new AlphaVantageProvider({ apiKey: process.env.AV_KEY }),
127
514
  new PolygonProvider({ apiKey: process.env.POLYGON_KEY }),
515
+ new FinnhubProvider({ apiKey: process.env.FINNHUB_KEY }),
128
516
  ],
129
517
  fallback: true, // auto-try next provider on failure
130
518
  });
@@ -300,7 +688,7 @@ const feed = new MarketFeed({ providers: [new MyProvider()] });
300
688
 
301
689
  | Runtime | Version | Notes |
302
690
  |---------|---------|-------|
303
- | Node.js | 18+ | Requires native `fetch` (available since Node 18) |
691
+ | Node.js | 18+ | `fetch` available since Node 18; `WebSocket` global available since Node 21. For Node 18–20, inject the `ws` package via `wsImpl` for `market-feed/ws`. |
304
692
  | Bun | 1+ | Fully supported |
305
693
  | Deno | 2+ | Fully supported |
306
694
  | Cloudflare Workers | Latest | Fully supported |
@@ -323,7 +711,7 @@ pnpm test
323
711
 
324
712
  ## Disclaimer
325
713
 
326
- This library is not affiliated with or endorsed by Yahoo Finance, Alpha Vantage, or Polygon.io. Data is provided for informational purposes only and should not be used as the sole basis for investment decisions.
714
+ This library is not affiliated with or endorsed by Yahoo Finance, Alpha Vantage, Polygon.io, or Finnhub. Data is provided for informational purposes only and should not be used as the sole basis for investment decisions.
327
715
 
328
716
  ---
329
717