1- import type { Quote , Candle } from "./yahoo.js" ;
1+ import type { Quote , Candle , ExtendedQuote } from "./yahoo.js" ;
22import { httpFetch } from "../http.js" ;
33
44const UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36" ;
@@ -78,12 +78,16 @@ export async function quote(symbol: string): Promise<Quote> {
7878 nfetch ( `https://api.nasdaq.com/api/quote/${ encodeURIComponent ( symbol ) } /summary?assetclass=${ assetclass } ` ) . catch ( ( ) => null ) ,
7979 ] ) ;
8080
81- const price = money ( info . primaryData ?. lastSalePrice ) ;
82- const prevClose = money ( info . primaryData ?. previousClose ) ?? money ( summary ?. summaryData ?. PreviousClose ?. value ) ;
83- const change = money ( info . primaryData ?. netChange ) ;
84- const pctChange = typeof info . primaryData ?. percentageChange === "string"
85- ? Number ( info . primaryData . percentageChange . replace ( / [ % + ] / g, "" ) )
86- : null ;
81+ const { regular, extended } = splitSessions ( info ) ;
82+ const price = regular . price ;
83+ const change = regular . change ;
84+ const pctChange = regular . changePercent ;
85+ // price - change is the close the change was measured against; Nasdaq's summary
86+ // "PreviousClose" sometimes disagrees with it (seen off by $2.65 on SPY after hours).
87+ const prevClose =
88+ price !== null && change !== null
89+ ? Math . round ( ( price - change ) * 10_000 ) / 10_000
90+ : money ( info . primaryData ?. previousClose ) ?? money ( summary ?. summaryData ?. PreviousClose ?. value ) ;
8791
8892 const [ dayLow , dayHigh ] = ( info . keyStats ?. dayrange ?. value ?? "" ) . split ( " - " ) . map ( ( s : string ) => money ( s ) ) ;
8993 const [ wLow , wHigh ] = ( info . keyStats ?. fiftyTwoWeekHighLow ?. value ?? "" ) . split ( " - " ) . map ( ( s : string ) => money ( s ) ) ;
@@ -97,7 +101,7 @@ export async function quote(symbol: string): Promise<Quote> {
97101 name : info . companyName ?? null ,
98102 price,
99103 change,
100- changePercent : isFinite ( pctChange as number ) ? pctChange : null ,
104+ changePercent : pctChange ,
101105 open : null ,
102106 high : dayHigh ?? null ,
103107 low : dayLow ?? null ,
@@ -119,6 +123,40 @@ export async function quote(symbol: string): Promise<Quote> {
119123 marketState : info . marketStatus ?? null ,
120124 time : null ,
121125 source : "nasdaq" ,
126+ extended,
127+ } ;
128+ }
129+
130+ const pct = ( v : unknown ) : number | null => {
131+ if ( typeof v !== "string" ) return null ;
132+ const n = Number ( v . replace ( / [ % + , ] / g, "" ) ) ;
133+ return Number . isFinite ( n ) ? n : null ;
134+ } ;
135+
136+ /**
137+ * Outside regular hours Nasdaq's `primaryData` is the pre-market/after-hours
138+ * trade (change measured from the last regular close) and `secondaryData` is
139+ * that regular close ("Closed at Oct 29, 2026 4:00 PM ET"). Showing primaryData
140+ * as the day's price mislabelled an after-hours move as the day's change.
141+ */
142+ export function splitSessions ( info : any ) : {
143+ regular : { price : number | null ; change : number | null ; changePercent : number | null } ;
144+ extended : ExtendedQuote | null ;
145+ } {
146+ const p = info ?. primaryData ?? { } ;
147+ const s = info ?. secondaryData ;
148+ const read = ( d : any ) => ( { price : money ( d ?. lastSalePrice ) , change : money ( d ?. netChange ) , changePercent : pct ( d ?. percentageChange ) } ) ;
149+ const closed = s && / c l o s e d / i. test ( String ( s . lastTradeTimestamp ?? "" ) ) && ( money ( s . lastSalePrice ) ?? 0 ) > 0 ;
150+ if ( ! closed ) return { regular : read ( p ) , extended : null } ;
151+ const ext = read ( p ) ;
152+ const status = String ( info ?. marketStatus ?? "" ) . toLowerCase ( ) ;
153+ const clock = / ( \d { 1 , 2 } : \d { 2 } [ A P ] M ) E T / . exec ( String ( p . lastTradeTimestamp ?? "" ) ) ;
154+ return {
155+ regular : read ( s ) ,
156+ extended :
157+ ext . price !== null && ext . price > 0
158+ ? { session : status . includes ( "pre" ) ? "pre" : "post" , ...ext , price : ext . price , time : clock ? `${ clock [ 1 ] } ET` : null }
159+ : null ,
122160 } ;
123161}
124162
@@ -155,21 +193,30 @@ export async function history(symbol: string, rangeKey: string): Promise<Candle[
155193/** Regular US session, in minutes after midnight ET. */
156194const SESSION_OPEN_MIN = 9 * 60 + 30 ;
157195const SESSION_CLOSE_MIN = 16 * 60 ;
196+ /** Extended hours: pre-market from 4:00, after-hours until 20:00 ET. */
197+ const EXT_OPEN_MIN = 4 * 60 ;
198+ const EXT_CLOSE_MIN = 20 * 60 ;
158199
159200/**
160201 * Folds Nasdaq's per-minute intraday points into OHLC candles of `bucketMin`
161202 * minutes, regular session only. Nasdaq encodes ET wall-clock time in `x` as
162203 * if it were UTC (an 11:39 AM ET point has x = 11:39Z), the same convention
163204 * its daily chart uses, so UTC getters yield the ET session time.
164205 */
165- export function minutePointsToCandles ( points : Array < { x : number ; y : number ; w ?: number } > , bucketMin = 5 ) : Candle [ ] {
206+ export function minutePointsToCandles (
207+ points : Array < { x : number ; y : number ; w ?: number } > ,
208+ bucketMin = 5 ,
209+ extended = false
210+ ) : Candle [ ] {
166211 const bucketSec = bucketMin * 60 ;
167212 const candles : Candle [ ] = [ ] ;
213+ const [ from , to ] = extended ? [ EXT_OPEN_MIN , EXT_CLOSE_MIN ] : [ SESSION_OPEN_MIN , SESSION_CLOSE_MIN ] ;
168214 for ( const p of points ) {
169215 if ( ! Number . isFinite ( p ?. x ) || ! Number . isFinite ( p ?. y ) ) continue ;
170216 const d = new Date ( p . x ) ;
171217 const minuteOfDay = d . getUTCHours ( ) * 60 + d . getUTCMinutes ( ) ;
172- if ( minuteOfDay < SESSION_OPEN_MIN || minuteOfDay >= SESSION_CLOSE_MIN ) continue ;
218+ if ( minuteOfDay < from || minuteOfDay >= to ) continue ;
219+ const session = minuteOfDay < SESSION_OPEN_MIN ? "pre" : minuteOfDay >= SESSION_CLOSE_MIN ? "post" : undefined ;
173220 const time = Math . floor ( p . x / 1000 / bucketSec ) * bucketSec ;
174221 const volume = Number . isFinite ( p . w ) ? Math . round ( p . w as number ) : 0 ;
175222 const last = candles [ candles . length - 1 ] ;
@@ -179,7 +226,7 @@ export function minutePointsToCandles(points: Array<{ x: number; y: number; w?:
179226 last . close = p . y ;
180227 last . volume += volume ;
181228 } else {
182- candles . push ( { time, open : p . y , high : p . y , low : p . y , close : p . y , volume } ) ;
229+ candles . push ( { time, open : p . y , high : p . y , low : p . y , close : p . y , volume, ... ( session ? { session } : { } ) } ) ;
183230 }
184231 }
185232 return candles ;
@@ -190,12 +237,12 @@ export function minutePointsToCandles(points: Array<{ x: number; y: number; w?:
190237 * when there's nothing in-session yet (pre-market, holidays) so callers fall
191238 * back to the daily chart instead of rendering an empty one.
192239 */
193- export async function intraday ( symbol : string , bucketMin = 5 ) : Promise < Candle [ ] > {
240+ export async function intraday ( symbol : string , bucketMin = 5 , extended = false ) : Promise < Candle [ ] > {
194241 const assetclass = await resolveAssetClass ( symbol ) ;
195242 const data = await nfetch (
196243 `https://api.nasdaq.com/api/quote/${ encodeURIComponent ( symbol ) } /chart?assetclass=${ assetclass } &charttype=rs`
197244 ) ;
198- const candles = minutePointsToCandles ( data . chart ?? [ ] , bucketMin ) ;
245+ const candles = minutePointsToCandles ( data . chart ?? [ ] , bucketMin , extended ) ;
199246 if ( candles . length === 0 ) throw new Error ( `nasdaq: no in-session intraday data for ${ symbol } ` ) ;
200247 return candles ;
201248}
0 commit comments