|
1 | | -const SERIES_URL = 'https://api.twelvedata.com/time_series' |
2 | | -const INTERVAL_MAP = { '5m': '5min', '15m': '15min', '1h': '1h', '1d': '1day' } |
| 1 | +const AGGS_URL = 'https://api.polygon.io/v2/aggs/ticker' |
| 2 | +const TICKER_URL = 'https://api.polygon.io/v3/reference/tickers' |
| 3 | +const INTERVAL_MAP = { |
| 4 | + '5m': { mult: 5, span: 'minute' }, |
| 5 | + '15m': { mult: 15, span: 'minute' }, |
| 6 | + '1h': { mult: 1, span: 'hour' }, |
| 7 | + '1d': { mult: 1, span: 'day' }, |
| 8 | +} |
3 | 9 | const ALLOWED_INTERVALS = Object.keys(INTERVAL_MAP) |
4 | | -const DEFAULT_OUTPUTSIZE = '120' |
| 10 | +const DEFAULT_LOOKBACK_DAYS = { '5m': 5, '15m': 5, '1h': 30, '1d': 180 } |
5 | 11 | const TTL_LIVE_SEC = 600 |
6 | 12 | const TTL_FROZEN_SEC = 60 * 60 * 24 * 365 |
7 | 13 | const FREEZE_THRESHOLD_MS = 60_000 |
| 14 | +const TICKER_CACHE_TTL_SEC = 60 * 60 * 24 |
| 15 | +const EXCHANGE_NAME = { |
| 16 | + XNAS: 'NASDAQ', |
| 17 | + XNYS: 'NYSE', |
| 18 | + ARCX: 'NYSE Arca', |
| 19 | + BATS: 'CBOE BZX', |
| 20 | + XASE: 'NYSE American', |
| 21 | + IEXG: 'IEX', |
| 22 | +} |
8 | 23 |
|
9 | 24 | const num = (v) => { |
10 | 25 | const n = Number(v) |
11 | 26 | return Number.isFinite(n) ? n : undefined |
12 | 27 | } |
13 | 28 |
|
| 29 | +const ymd = (ms) => { |
| 30 | + const d = new Date(ms) |
| 31 | + const m = String(d.getUTCMonth() + 1).padStart(2, '0') |
| 32 | + const day = String(d.getUTCDate()).padStart(2, '0') |
| 33 | + return `${d.getUTCFullYear()}-${m}-${day}` |
| 34 | +} |
| 35 | + |
14 | 36 | export default async function handler(ctx) { |
15 | 37 | const { symbol, interval, from, to } = ctx.query |
16 | 38 | if (!symbol) ctx.throws(422, 'symbol is required') |
17 | 39 | if (!ALLOWED_INTERVALS.includes(interval)) ctx.throws(422, 'invalid interval') |
18 | 40 |
|
19 | 41 | const sym = String(symbol).toUpperCase() |
20 | | - const tdInterval = INTERVAL_MAP[interval] |
| 42 | + const { mult, span } = INTERVAL_MAP[interval] |
21 | 43 |
|
22 | 44 | const cacheKey = `stock:bars:${sym}:${interval}:${from || ''}:${to || ''}` |
23 | 45 | const cached = await ctx.storage.cache.get(cacheKey) |
24 | 46 | if (cached) return typeof cached === 'string' ? JSON.parse(cached) : cached |
25 | 47 |
|
26 | 48 | const config = await ctx.getService('config') |
27 | 49 | const thirdParty = await config.get('thirdPartyServiceIntegration') |
28 | | - const apiKey = String(thirdParty?.twelveData?.apiKey ?? '').trim() |
| 50 | + const apiKey = String(thirdParty?.polygon?.apiKey ?? '').trim() |
29 | 51 | if (!apiKey) { |
30 | 52 | ctx.throws( |
31 | 53 | 500, |
32 | | - 'Twelve Data API key not configured (Settings → Third-party integrations)', |
| 54 | + 'Polygon.io API key not configured (Settings → Third-party integrations)', |
33 | 55 | ) |
34 | 56 | } |
35 | 57 |
|
36 | | - const params = { |
37 | | - symbol: sym, |
38 | | - interval: tdInterval, |
39 | | - apikey: apiKey, |
40 | | - order: 'ASC', |
41 | | - ...(from && to |
42 | | - ? { |
43 | | - start_date: String(from).slice(0, 10), |
44 | | - end_date: String(to).slice(0, 10), |
45 | | - } |
46 | | - : { outputsize: DEFAULT_OUTPUTSIZE }), |
| 58 | + const now = Date.now() |
| 59 | + let fromMs |
| 60 | + let toMs |
| 61 | + if (from && to) { |
| 62 | + fromMs = new Date(from).getTime() |
| 63 | + toMs = new Date(to).getTime() |
| 64 | + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) { |
| 65 | + ctx.throws(422, 'invalid from/to') |
| 66 | + } |
| 67 | + } else { |
| 68 | + toMs = now |
| 69 | + fromMs = now - DEFAULT_LOOKBACK_DAYS[interval] * 86_400_000 |
47 | 70 | } |
48 | 71 |
|
| 72 | + const aggsUrl = |
| 73 | + `${AGGS_URL}/${encodeURIComponent(sym)}/range/${mult}/${span}/` + |
| 74 | + `${ymd(fromMs)}/${ymd(toMs)}` + |
| 75 | + `?adjusted=true&sort=asc&limit=5000&apiKey=${encodeURIComponent(apiKey)}` |
| 76 | + |
49 | 77 | let payload |
50 | 78 | try { |
51 | | - const res = await fetch(`${SERIES_URL}?${new URLSearchParams(params)}`, { |
52 | | - headers: { Accept: 'application/json' }, |
53 | | - }) |
| 79 | + const res = await fetch(aggsUrl, { headers: { Accept: 'application/json' } }) |
54 | 80 | payload = await res.json() |
55 | 81 | } catch (e) { |
56 | | - ctx.throws(502, `Twelve Data: ${e.message || 'unknown'}`) |
| 82 | + ctx.throws(502, `Polygon.io: ${e.message || 'unknown'}`) |
57 | 83 | } |
58 | | - if ( |
59 | | - payload?.status === 'error' || |
60 | | - (typeof payload?.code === 'number' && payload.code >= 400) |
61 | | - ) { |
62 | | - ctx.throws(502, `Twelve Data: ${payload.message || 'unknown'}`) |
63 | | - } |
64 | | - if (!Array.isArray(payload?.values) || payload.values.length === 0) { |
65 | | - ctx.throws(404, `no bars for ${sym}`) |
| 84 | + |
| 85 | + if (payload?.status && payload.status !== 'OK' && payload.status !== 'DELAYED') { |
| 86 | + ctx.throws( |
| 87 | + 502, |
| 88 | + `Polygon.io: ${payload.error || payload.message || payload.status}`, |
| 89 | + ) |
66 | 90 | } |
67 | 91 |
|
| 92 | + const results = Array.isArray(payload?.results) ? payload.results : [] |
68 | 93 | const bars = [] |
69 | | - for (const b of payload.values) { |
70 | | - const open = num(b.open) |
71 | | - const high = num(b.high) |
72 | | - const low = num(b.low) |
73 | | - const close = num(b.close) |
| 94 | + for (const r of results) { |
| 95 | + if (r.t < fromMs || r.t > toMs) continue |
| 96 | + const open = num(r.o) |
| 97 | + const high = num(r.h) |
| 98 | + const low = num(r.l) |
| 99 | + const close = num(r.c) |
74 | 100 | if (open == null || high == null || low == null || close == null) continue |
75 | 101 | bars.push({ |
76 | | - timestamp: new Date(`${b.datetime.replace(' ', 'T')}Z`).getTime(), |
| 102 | + timestamp: r.t, |
77 | 103 | open, |
78 | 104 | high, |
79 | 105 | low, |
80 | 106 | close, |
81 | | - volume: num(b.volume), |
| 107 | + volume: num(r.v), |
82 | 108 | }) |
83 | 109 | } |
| 110 | + if (bars.length === 0) ctx.throws(404, `no bars for ${sym}`) |
| 111 | + |
| 112 | + const tickerCacheKey = `stock:ticker:${sym}` |
| 113 | + let tickerInfo = await ctx.storage.cache.get(tickerCacheKey) |
| 114 | + if (tickerInfo && typeof tickerInfo === 'string') tickerInfo = JSON.parse(tickerInfo) |
| 115 | + if (!tickerInfo) { |
| 116 | + try { |
| 117 | + const tRes = await fetch( |
| 118 | + `${TICKER_URL}/${encodeURIComponent(sym)}?apiKey=${encodeURIComponent(apiKey)}`, |
| 119 | + { headers: { Accept: 'application/json' } }, |
| 120 | + ) |
| 121 | + const tJson = await tRes.json() |
| 122 | + tickerInfo = tJson?.results || {} |
| 123 | + await ctx.storage.cache.set( |
| 124 | + tickerCacheKey, |
| 125 | + JSON.stringify(tickerInfo), |
| 126 | + TICKER_CACHE_TTL_SEC, |
| 127 | + ) |
| 128 | + } catch { |
| 129 | + tickerInfo = {} |
| 130 | + } |
| 131 | + } |
84 | 132 |
|
85 | | - const metaSym = payload.meta?.symbol || sym |
| 133 | + const exchangeCode = tickerInfo?.primary_exchange |
86 | 134 | const meta = { |
87 | | - symbol: metaSym, |
88 | | - exchange: payload.meta?.exchange, |
89 | | - currency: payload.meta?.currency, |
90 | | - timezone: payload.meta?.exchange_timezone, |
91 | | - longName: metaSym, |
92 | | - shortName: metaSym, |
93 | | - asOf: bars.length ? Math.floor(bars.at(-1).timestamp / 1000) : undefined, |
| 135 | + symbol: tickerInfo?.ticker || sym, |
| 136 | + exchange: |
| 137 | + (exchangeCode && (EXCHANGE_NAME[exchangeCode] || exchangeCode)) || undefined, |
| 138 | + currency: (tickerInfo?.currency_name || 'USD').toUpperCase(), |
| 139 | + timezone: 'America/New_York', |
| 140 | + longName: tickerInfo?.name || sym, |
| 141 | + shortName: tickerInfo?.name || sym, |
| 142 | + asOf: Math.floor(bars.at(-1).timestamp / 1000), |
94 | 143 | } |
95 | 144 |
|
96 | 145 | const result = { meta, bars } |
97 | 146 |
|
98 | | - const toMs = to ? new Date(to).getTime() : Date.now() |
99 | | - const isFrozen = Number.isFinite(toMs) && toMs < Date.now() - FREEZE_THRESHOLD_MS |
| 147 | + const isFrozen = Number.isFinite(toMs) && toMs < now - FREEZE_THRESHOLD_MS |
100 | 148 | const ttl = isFrozen ? TTL_FROZEN_SEC : TTL_LIVE_SEC |
101 | 149 | await ctx.storage.cache.set(cacheKey, JSON.stringify(result), ttl) |
102 | 150 | return result |
|
0 commit comments