Skip to content

Commit cfb1e5a

Browse files
fix(quote): keep pre-market/after-hours apart from the regular-session price
Outside regular hours Nasdaq's primaryData is the extended-hours trade, with its change measured from the 16:00 close; it was shown as the day's price and change (SPY after hours: -0.62% shown, the day was -0.37%). splitSessions() now uses the closed secondaryData as the regular price and returns the extended trade separately as quote.extended (Yahoo: pre/postMarket* fields). previousClose is derived from price - change, which matches the change shown. Also adds ext=1 on 1D history: 4:00-20:00 ET five-minute candles, with pre/post candles tagged via candle.session. Only the literal '1' on 1D enables it, so the cache key space stays bounded.
1 parent 231d66c commit cfb1e5a

4 files changed

Lines changed: 179 additions & 17 deletions

File tree

‎server/src/providers/nasdaq.test.ts‎

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "vitest";
2-
import { minutePointsToCandles } from "./nasdaq.js";
2+
import { minutePointsToCandles, splitSessions } from "./nasdaq.js";
3+
import { yahooExtended } from "./yahoo.js";
34

45
// Nasdaq encodes ET wall-clock time as if it were UTC (see nasdaq.ts).
56
const at = (hhmm: string, y: number, w = 100) => ({ x: Date.parse(`2026-10-14T${hhmm}:00Z`), y, w });
@@ -29,3 +30,82 @@ describe("minutePointsToCandles", () => {
2930
expect(candles[0].close).toBe(7);
3031
});
3132
});
33+
34+
// Nasdaq encodes ET wall-clock time as if it were UTC.
35+
const etAt = (hhmm: string) => Date.parse(`2026-10-29T${hhmm}:00Z`);
36+
37+
describe("minutePointsToCandles extended hours", () => {
38+
const pts = [
39+
{ x: etAt("04:00"), y: 770, w: 10 },
40+
{ x: etAt("09:29"), y: 771, w: 10 },
41+
{ x: etAt("09:30"), y: 772, w: 10 },
42+
{ x: etAt("15:59"), y: 769.49, w: 10 },
43+
{ x: etAt("16:00"), y: 769.3, w: 10 },
44+
{ x: etAt("19:59"), y: 764.74, w: 10 },
45+
{ x: etAt("20:00"), y: 1, w: 10 },
46+
{ x: etAt("03:59"), y: 1, w: 10 },
47+
];
48+
49+
it("regular mode keeps 9:30-16:00 only, with no session tags", () => {
50+
const c = minutePointsToCandles(pts);
51+
expect(c.map((x) => x.close)).toEqual([772, 769.49]);
52+
expect(c.every((x) => x.session === undefined)).toBe(true);
53+
});
54+
55+
it("extended mode keeps 4:00-20:00 and tags pre/post candles", () => {
56+
const c = minutePointsToCandles(pts, 5, true);
57+
expect(c.map((x) => [x.close, x.session ?? "regular"])).toEqual([
58+
[770, "pre"], [771, "pre"], [772, "regular"], [769.49, "regular"], [769.3, "post"], [764.74, "post"],
59+
]);
60+
});
61+
});
62+
63+
describe("splitSessions", () => {
64+
// Shape captured from Nasdaq /info for SPY at 19:56 ET on Oct 29, 2026.
65+
const afterHours = {
66+
marketStatus: "After-Hours",
67+
primaryData: { lastSalePrice: "$764.744", netChange: "-4.746", percentageChange: "-0.62%", lastTradeTimestamp: "Oct 29, 2026 7:56 PM ET" },
68+
secondaryData: { lastSalePrice: "$769.49", netChange: "-2.82", percentageChange: "-0.37%", lastTradeTimestamp: "Closed at Oct 29, 2026 4:00 PM ET" },
69+
};
70+
71+
it("after hours: the regular close is the price, the after-hours trade is separate", () => {
72+
const { regular, extended } = splitSessions(afterHours);
73+
expect(regular).toEqual({ price: 769.49, change: -2.82, changePercent: -0.37 });
74+
expect(extended).toEqual({ session: "post", price: 764.744, change: -4.746, changePercent: -0.62, time: "7:56 PM ET" });
75+
});
76+
77+
it("pre-market is labelled pre", () => {
78+
const pre = { ...afterHours, marketStatus: "Pre-Market", primaryData: { ...afterHours.primaryData, lastTradeTimestamp: "Oct 30, 2026 8:05 AM ET" } };
79+
expect(splitSessions(pre).extended).toMatchObject({ session: "pre", time: "8:05 AM ET" });
80+
});
81+
82+
it("regular hours (no closed secondary): primary is the price, no extended", () => {
83+
const open = { marketStatus: "Market Open", primaryData: afterHours.primaryData, secondaryData: null };
84+
expect(splitSessions(open)).toEqual({ regular: { price: 764.744, change: -4.746, changePercent: -0.62 }, extended: null });
85+
});
86+
87+
it("an empty secondary price is not mistaken for a $0 close", () => {
88+
const blank = { ...afterHours, secondaryData: { ...afterHours.secondaryData, lastSalePrice: "" } };
89+
expect(splitSessions(blank).extended).toBeNull();
90+
expect(splitSessions(blank).regular.price).toBe(764.744);
91+
});
92+
93+
it("ignores junk input", () => {
94+
expect(splitSessions(null)).toEqual({ regular: { price: null, change: null, changePercent: null }, extended: null });
95+
expect(splitSessions({ primaryData: { percentageChange: "abc%" } }).regular.changePercent).toBeNull();
96+
});
97+
});
98+
99+
describe("yahooExtended", () => {
100+
const base = { regularMarketPrice: 769.49, postMarketPrice: 764.74, postMarketChange: -4.75, postMarketChangePercent: -0.617, postMarketTime: 1793318160 };
101+
it("post / closed states use postMarket fields", () => {
102+
expect(yahooExtended({ ...base, marketState: "POST" })).toMatchObject({ session: "post", price: 764.74, change: -4.75 });
103+
expect(yahooExtended({ ...base, marketState: "CLOSED" })?.session).toBe("post");
104+
expect(yahooExtended({ ...base, marketState: "POST" })?.time).toMatch(/^\d{1,2}:\d{2} [AP]M ET$/);
105+
});
106+
it("regular hours or missing price gives nothing", () => {
107+
expect(yahooExtended({ ...base, marketState: "REGULAR" })).toBeNull();
108+
expect(yahooExtended({ marketState: "PRE", preMarketPrice: 0 })).toBeNull();
109+
expect(yahooExtended({ marketState: "PRE", preMarketPrice: 771.2 })).toMatchObject({ session: "pre", price: 771.2, time: null });
110+
});
111+
});

‎server/src/providers/nasdaq.ts‎

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Quote, Candle } from "./yahoo.js";
1+
import type { Quote, Candle, ExtendedQuote } from "./yahoo.js";
22
import { httpFetch } from "../http.js";
33

44
const 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 && /closed/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} [AP]M) ET/.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. */
156194
const SESSION_OPEN_MIN = 9 * 60 + 30;
157195
const 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
}

‎server/src/providers/yahoo.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,22 @@ export type Quote = {
5050
marketState: string | null;
5151
time: number | null;
5252
source: string;
53+
/** Pre-market / after-hours trading, kept apart from the regular-session price above. */
54+
extended?: ExtendedQuote | null;
5355
};
5456

55-
export type Candle = { time: number; open: number; high: number; low: number; close: number; volume: number };
57+
export type ExtendedQuote = {
58+
session: "pre" | "post";
59+
price: number;
60+
/** Versus the reference the provider uses: the latest regular-session close. */
61+
change: number | null;
62+
changePercent: number | null;
63+
/** Wall-clock label such as "7:56 PM ET", or null. */
64+
time: string | null;
65+
};
66+
67+
/** `session` is set only on extended-hours candles (1D with ext=1). */
68+
export type Candle = { time: number; open: number; high: number; low: number; close: number; volume: number; session?: "pre" | "post" };
5669

5770
// ---- crumb/cookie session (needed for the v7 quote and options endpoints) ----
5871

@@ -174,9 +187,28 @@ export async function quotes(symbols: string[]): Promise<Quote[]> {
174187
marketState: r.marketState ?? null,
175188
time: n(r.regularMarketTime),
176189
source: "yahoo",
190+
extended: yahooExtended(r),
177191
}));
178192
}
179193

194+
const etClock = (unixSec: number | null): string | null =>
195+
unixSec === null
196+
? null
197+
: new Date(unixSec * 1000).toLocaleTimeString("en-US", { timeZone: "America/New_York", hour: "numeric", minute: "2-digit" }) + " ET";
198+
199+
/** Yahoo reports extended hours in separate pre/postMarket* fields; marketState says which applies now. */
200+
export function yahooExtended(r: any): ExtendedQuote | null {
201+
const state = String(r?.marketState ?? "").toUpperCase();
202+
const pick = (session: "pre" | "post", key: "preMarket" | "postMarket"): ExtendedQuote | null => {
203+
const price = n(r?.[`${key}Price`]);
204+
if (price === null || price <= 0) return null;
205+
return { session, price, change: n(r[`${key}Change`]), changePercent: n(r[`${key}ChangePercent`]), time: etClock(n(r[`${key}Time`])) };
206+
};
207+
if (state.startsWith("PRE")) return pick("pre", "preMarket");
208+
if (state.startsWith("POST") || state === "CLOSED") return pick("post", "postMarket");
209+
return null;
210+
}
211+
180212
/** Quote fallback that works without crumb, using the chart endpoint's metadata. */
181213
export async function quoteFromChart(symbol: string): Promise<Quote> {
182214
const json = await yfetch(

‎server/src/routes/market.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,10 +255,13 @@ marketRouter.get("/history/:symbol", async (req, res) => {
255255
// Allowlisted: every distinct string was a distinct cache key, so arbitrary
256256
// values bypassed the cache and forced a fresh upstream fan-out each time.
257257
if (!HISTORY_RANGES.has(rangeKey)) return res.status(400).json({ error: "unsupported range" });
258+
// Extended hours (4:00-20:00 ET) only exist for the 1D intraday view. Only the
259+
// literal "1" enables it, so the cache key space stays two values per symbol.
260+
const ext = rangeKey === "1D" && req.query.ext === "1";
258261
try {
259262
// 1D is the live view (the chart polls it every 8s), so it gets a short TTL.
260263
const ttl = rangeKey === "1D" ? INTRADAY_TTL : HISTORY_TTL;
261-
const data = await cached(`history:${symbol}:${rangeKey}`, ttl, () =>
264+
const data = await cached(`history:${symbol}:${rangeKey}${ext ? ":ext" : ""}`, ttl, () =>
262265
binance.CRYPTO_SYMBOLS.has(symbol)
263266
? binance.history(symbol, rangeKey)
264267
: isVix(symbol)
@@ -268,7 +271,7 @@ marketRouter.get("/history/:symbol", async (req, res) => {
268271
// daily chart answered first, so "1D" showed a few daily bars with
269272
// no candle for the session in progress.
270273
...(rangeKey === "1D"
271-
? ([["nasdaq-intraday", () => nasdaq.intraday(symbol)]] as Array<[string, () => Promise<yahoo.Candle[]>]>)
274+
? ([["nasdaq-intraday", () => nasdaq.intraday(symbol, 5, ext)]] as Array<[string, () => Promise<yahoo.Candle[]>]>)
272275
: []),
273276
["nasdaq", () => nasdaq.history(symbol, rangeKey)],
274277
["yahoo", () => yahoo.history(symbol, yahooRange(rangeKey).range, yahooRange(rangeKey).interval)],

0 commit comments

Comments
 (0)