Skip to content

Commit c72cd47

Browse files
committed
fix(stock): switch bars source from Twelve Data to Polygon.io
Twelve Data moved hourly intraday for individual symbols behind the Grow/Venture plan. Polygon.io free tier covers the same window (5 req/min, 2y history) and the aggregates endpoint maps cleanly to the existing `{ meta, bars }` shape. - Add `polygon` integration to thirdPartyServiceIntegration (enabled + apiKey) - Rewrite stock_bars.runtime.mjs against `/v2/aggs/ticker/.../range/...` - Pull display meta from `/v3/reference/tickers/{sym}` with 24h cache - Keep cache key, TTLs and response shape so api-client/admin need no change - Twelve Data field retained for stock_quote pending its migration
1 parent fb1a506 commit c72cd47

3 files changed

Lines changed: 104 additions & 47 deletions

File tree

apps/core/src/modules/configs/configs.default.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export const generateDefaultConfig: () => IConfig = () => ({
132132
},
133133
},
134134
twelveData: { enabled: false, apiKey: '' },
135+
polygon: { enabled: false, apiKey: '' },
135136
},
136137

137138
authSecurity: {

apps/core/src/modules/configs/configs.schema.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,14 @@ const ArxivIntegrationSchema = section('Arxiv', {
511511
const TwelveDataIntegrationSchema = section('Twelve Data', {
512512
enabled: field.toggle(z.boolean().optional().default(false), 'Enabled'),
513513
apiKey: field.password(z.string().optional(), 'API Key', {
514-
description: 'Stock quote + bars data source, https://twelvedata.com',
514+
description: 'Stock quote data source, https://twelvedata.com',
515+
}),
516+
})
517+
518+
const PolygonIntegrationSchema = section('Polygon.io', {
519+
enabled: field.toggle(z.boolean().optional().default(false), 'Enabled'),
520+
apiKey: field.password(z.string().optional(), 'API Key', {
521+
description: 'Stock bars data source, https://polygon.io',
515522
}),
516523
})
517524

@@ -639,6 +646,7 @@ export const ThirdPartyServiceIntegrationSchema = section(
639646
qqMusic: QQMusicIntegrationSchema.optional(),
640647
openGraph: OpenGraphIntegrationSchema.optional(),
641648
twelveData: TwelveDataIntegrationSchema.optional(),
649+
polygon: PolygonIntegrationSchema.optional(),
642650
},
643651
)
644652
export class ThirdPartyServiceIntegrationDto extends createZodDto(

apps/core/src/modules/serverless/pack/built-in/stock_bars.runtime.mjs

Lines changed: 94 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,150 @@
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+
}
39
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 }
511
const TTL_LIVE_SEC = 600
612
const TTL_FROZEN_SEC = 60 * 60 * 24 * 365
713
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+
}
823

924
const num = (v) => {
1025
const n = Number(v)
1126
return Number.isFinite(n) ? n : undefined
1227
}
1328

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+
1436
export default async function handler(ctx) {
1537
const { symbol, interval, from, to } = ctx.query
1638
if (!symbol) ctx.throws(422, 'symbol is required')
1739
if (!ALLOWED_INTERVALS.includes(interval)) ctx.throws(422, 'invalid interval')
1840

1941
const sym = String(symbol).toUpperCase()
20-
const tdInterval = INTERVAL_MAP[interval]
42+
const { mult, span } = INTERVAL_MAP[interval]
2143

2244
const cacheKey = `stock:bars:${sym}:${interval}:${from || ''}:${to || ''}`
2345
const cached = await ctx.storage.cache.get(cacheKey)
2446
if (cached) return typeof cached === 'string' ? JSON.parse(cached) : cached
2547

2648
const config = await ctx.getService('config')
2749
const thirdParty = await config.get('thirdPartyServiceIntegration')
28-
const apiKey = String(thirdParty?.twelveData?.apiKey ?? '').trim()
50+
const apiKey = String(thirdParty?.polygon?.apiKey ?? '').trim()
2951
if (!apiKey) {
3052
ctx.throws(
3153
500,
32-
'Twelve Data API key not configured (Settings → Third-party integrations)',
54+
'Polygon.io API key not configured (Settings → Third-party integrations)',
3355
)
3456
}
3557

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
4770
}
4871

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+
4977
let payload
5078
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' } })
5480
payload = await res.json()
5581
} catch (e) {
56-
ctx.throws(502, `Twelve Data: ${e.message || 'unknown'}`)
82+
ctx.throws(502, `Polygon.io: ${e.message || 'unknown'}`)
5783
}
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+
)
6690
}
6791

92+
const results = Array.isArray(payload?.results) ? payload.results : []
6893
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)
74100
if (open == null || high == null || low == null || close == null) continue
75101
bars.push({
76-
timestamp: new Date(`${b.datetime.replace(' ', 'T')}Z`).getTime(),
102+
timestamp: r.t,
77103
open,
78104
high,
79105
low,
80106
close,
81-
volume: num(b.volume),
107+
volume: num(r.v),
82108
})
83109
}
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+
}
84132

85-
const metaSym = payload.meta?.symbol || sym
133+
const exchangeCode = tickerInfo?.primary_exchange
86134
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),
94143
}
95144

96145
const result = { meta, bars }
97146

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
100148
const ttl = isFrozen ? TTL_FROZEN_SEC : TTL_LIVE_SEC
101149
await ctx.storage.cache.set(cacheKey, JSON.stringify(result), ttl)
102150
return result

0 commit comments

Comments
 (0)