Skip to content

Commit dd52ef2

Browse files
feat(chart): EXT toggle for extended hours; after-hours line in the quote panel
1D gets an EXT button: pre-market and after-hours candles drawn faded on a shaded background, with the full 4:00-20:00 day reserved. The quote panel shows the regular close as the main price plus a separate 'After hours' / 'Pre-market' line with change and time, with LEARN explanations.
1 parent cfb1e5a commit dd52ef2

4 files changed

Lines changed: 86 additions & 9 deletions

File tree

‎web/components/widgets/ChartWidget.tsx‎

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import { useWidgetSymbol, type WidgetInstance } from "../../store/terminal";
2020
const RANGES = ["1D", "5D", "1M", "6M", "YTD", "1Y", "5Y", "MAX"] as const;
2121
/** 9:30-16:00 in the 5-minute buckets the server builds for 1D. */
2222
const SESSION_SLOTS_1D = 78;
23+
/** 4:00-20:00 with pre-market and after-hours. */
24+
const EXT_SESSION_SLOTS_1D = 192;
2325
const MIN_VISIBLE_SLOTS = 60;
2426
const CHART_TYPES = ["candles", "bars", "line", "area"] as const;
2527
const INDICATORS = ["SMA20", "SMA50", "SMA200", "EMA20", "VWAP", "BOLL", "RSI", "MACD"] as const;
@@ -39,15 +41,18 @@ const INDICATOR_COLOR: Record<string, string> = {
3941
export default function ChartWidget({ widget }: { widget: WidgetInstance }) {
4042
const symbol = useWidgetSymbol(widget);
4143
const [range, setRange] = useState<Range>("6M");
44+
// Pre-market + after-hours candles on 1D. Off by default, like most terminals.
45+
const [ext, setExt] = useState(false);
46+
const withExt = range === "1D" && ext;
4247
const [chartType, setChartType] = useState<ChartType>("candles");
4348
const [active, setActive] = useState<Set<Indicator>>(new Set(["SMA20"]));
4449
const [legend, setLegend] = useState<Candle | null>(null);
4550
const containerRef = useRef<HTMLDivElement>(null);
4651
const chartRef = useRef<IChartApi | null>(null);
4752

4853
const { data: candles, error } = useQuery({
49-
queryKey: ["history", symbol, range],
50-
queryFn: () => apiGet<Candle[]>(`/api/history/${encodeURIComponent(symbol)}?range=${range}`),
54+
queryKey: ["history", symbol, range, withExt],
55+
queryFn: () => apiGet<Candle[]>(`/api/history/${encodeURIComponent(symbol)}?range=${range}${withExt ? "&ext=1" : ""}`),
5156
refetchInterval: range === "1D" ? 8_000 : 60_000,
5257
});
5358

@@ -149,22 +154,44 @@ export default function ChartWidget({ widget }: { widget: WidgetInstance }) {
149154

150155
const upColor = "#00c853";
151156
const downColor = "#ff3d3d";
157+
// Extended-hours candles are drawn faded so the regular session stands out.
158+
const extColor = (c: Candle) => (c.close >= c.open ? "rgba(0,200,83,0.38)" : "rgba(255,61,61,0.38)");
159+
const ohlc = (c: Candle) => {
160+
const base = { time: ts(c.time), open: c.open, high: c.high, low: c.low, close: c.close };
161+
if (!c.session) return base;
162+
const color = extColor(c);
163+
return { ...base, color, borderColor: color, wickColor: color };
164+
};
165+
166+
// Shade pre-market / after-hours behind everything: a full-height filled area
167+
// (1 = extended, 0 = regular) on its own hidden scale. Series added first are
168+
// drawn underneath; an area has no gaps between bars, unlike a histogram.
169+
if (candles.some((c) => c.session)) {
170+
const fill = "rgba(79,195,247,0.07)";
171+
const shade = chart.addSeries(AreaSeries, {
172+
priceScaleId: "session", lineVisible: false, lineWidth: 1, topColor: fill, bottomColor: fill,
173+
priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false,
174+
autoscaleInfoProvider: () => ({ priceRange: { minValue: 0, maxValue: 1 } }),
175+
});
176+
shade.priceScale().applyOptions({ scaleMargins: { top: 0, bottom: 0 }, visible: false });
177+
shade.setData(candles.map((c) => ({ time: ts(c.time), value: c.session ? 1 : 0 })));
178+
}
152179

153180
if (chartType === "candles") {
154181
chart
155182
.addSeries(CandlestickSeries, {
156183
upColor, downColor, borderUpColor: upColor, borderDownColor: downColor,
157184
wickUpColor: upColor, wickDownColor: downColor,
158185
})
159-
.setData(candles.map((c) => ({ time: ts(c.time), open: c.open, high: c.high, low: c.low, close: c.close })));
186+
.setData(candles.map(ohlc));
160187
} else if (chartType === "bars") {
161188
chart
162189
.addSeries(BarSeries, { upColor, downColor })
163-
.setData(candles.map((c) => ({ time: ts(c.time), open: c.open, high: c.high, low: c.low, close: c.close })));
190+
.setData(candles.map((c) => (c.session ? { ...ohlc(c), color: extColor(c) } : ohlc(c))));
164191
} else if (chartType === "line") {
165192
chart
166193
.addSeries(LineSeries, { color: "#ff9900", lineWidth: 1 })
167-
.setData(candles.map((c) => ({ time: ts(c.time), value: c.close })));
194+
.setData(candles.map((c) => ({ time: ts(c.time), value: c.close, ...(c.session ? { color: "rgba(255,153,0,0.4)" } : {}) })));
168195
} else {
169196
chart
170197
.addSeries(AreaSeries, { lineColor: "#ff9900", topColor: "rgba(255,153,0,0.25)", bottomColor: "rgba(255,153,0,0)" })
@@ -175,7 +202,11 @@ export default function ChartWidget({ widget }: { widget: WidgetInstance }) {
175202
const vol = chart.addSeries(HistogramSeries, { priceScaleId: "vol", priceFormat: { type: "volume" } });
176203
vol.priceScale().applyOptions({ scaleMargins: { top: 0.85, bottom: 0 } });
177204
vol.setData(
178-
candles.map((c) => ({ time: ts(c.time), value: c.volume, color: c.close >= c.open ? "rgba(0,200,83,0.4)" : "rgba(255,61,61,0.4)" }))
205+
candles.map((c) => ({
206+
time: ts(c.time),
207+
value: c.volume,
208+
color: c.session ? (c.close >= c.open ? "rgba(0,200,83,0.15)" : "rgba(255,61,61,0.15)") : c.close >= c.open ? "rgba(0,200,83,0.4)" : "rgba(255,61,61,0.4)",
209+
}))
179210
);
180211

181212
const overlay = (points: Point[], color: string) =>
@@ -225,14 +256,14 @@ export default function ChartWidget({ widget }: { widget: WidgetInstance }) {
225256
// session 1D showed a dozen giant candles. Reserve the full session instead (78 five-minute
226257
// slots, 9:30-16:00) and let it fill in left to right; other ranges get a minimum of 60 slots
227258
// so short histories (new listings) don't balloon either.
228-
const minSlots = range === "1D" ? SESSION_SLOTS_1D : MIN_VISIBLE_SLOTS;
259+
const minSlots = range === "1D" ? (withExt ? EXT_SESSION_SLOTS_1D : SESSION_SLOTS_1D) : MIN_VISIBLE_SLOTS;
229260
if (candles.length < minSlots) chart.timeScale().setVisibleLogicalRange({ from: -1.5, to: minSlots - 0.5 });
230261
else chart.timeScale().fitContent();
231262
return () => {
232263
chart.remove();
233264
chartRef.current = null;
234265
};
235-
}, [candles, chartType, indicatorData, range, byTime]);
266+
}, [candles, chartType, indicatorData, range, withExt, byTime]);
236267

237268
const toggleIndicator = (ind: Indicator) =>
238269
setActive((prev) => {
@@ -250,6 +281,16 @@ export default function ChartWidget({ widget }: { widget: WidgetInstance }) {
250281
{r}
251282
</button>
252283
))}
284+
{range === "1D" && (
285+
<button
286+
className={`term-btn ${ext ? "active" : ""}`}
287+
onClick={() => setExt(!ext)}
288+
aria-pressed={ext}
289+
title="Pre-market (4:00-9:30) and after-hours (16:00-20:00) candles, US stocks"
290+
>
291+
<Explain nested term="extendedHours">EXT</Explain>
292+
</button>
293+
)}
253294
<span className="w-2" />
254295
{CHART_TYPES.map((t) => (
255296
<button key={t} className={`term-btn ${chartType === t ? "active" : ""}`} onClick={() => setChartType(t)}>

‎web/components/widgets/QuoteWidget.tsx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,19 @@ export default function QuoteWidget({ widget }: { widget: WidgetInstance }) {
6161
{data.exchange ?? ""} · {data.currency ?? ""} · {data.source}
6262
</span>
6363
</div>
64+
{data.extended && (
65+
<div className="flex items-baseline gap-2 text-[11px] mb-1">
66+
<Explain className="dim uppercase" term={data.extended.session === "pre" ? "preMarket" : "afterHours"}>
67+
{data.extended.session === "pre" ? "Pre-market" : "After hours"}
68+
</Explain>
69+
<Flash value={data.extended.price} className="font-bold">{fmt(data.extended.price)}</Flash>
70+
<span className={pctClass(data.extended.changePercent)}>
71+
{data.extended.change !== null && data.extended.change >= 0 ? "+" : ""}
72+
{fmt(data.extended.change)} ({fmt(data.extended.changePercent)}%)
73+
</span>
74+
{data.extended.time && <span className="dim">{data.extended.time}</span>}
75+
</div>
76+
)}
6477
<div className="dim text-[11px] mb-2 truncate">{data.name}</div>
6578
<div className="grid grid-cols-2 gap-x-4">
6679
{rows.map(([label, value]) => (

‎web/lib/api.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,12 @@ export type Quote = {
5353
source: string;
5454
sector?: string;
5555
label?: string;
56+
/** Pre-market / after-hours trade; `price` above stays the regular session's. */
57+
extended?: { session: "pre" | "post"; price: number; change: number | null; changePercent: number | null; time: string | null } | null;
5658
};
5759

58-
export type Candle = { time: number; open: number; high: number; low: number; close: number; volume: number };
60+
/** `session` marks pre-market / after-hours candles (1D chart with EXT on). */
61+
export type Candle = { time: number; open: number; high: number; low: number; close: number; volume: number; session?: "pre" | "post" };
5962

6063
export function fmt(n: number | null | undefined, digits = 2): string {
6164
if (n === null || n === undefined || !isFinite(n)) return "—";

‎web/lib/glossary.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,30 @@ export const GLOSSARY: Record<string, GlossaryEntry> = {
6969
yieldCurve: { title: "Yield curve", what: "US Treasury interest rates across maturities, from 3 months to 30 years.", why: "It usually slopes upward. When short rates are above long rates ('inverted'), many read it as a recession warning." },
7070
replay: { title: "Chart replay", what: "Trade a real past chart one candle at a time, without seeing what comes next.", why: "Practises decisions without hindsight: the stock and dates stay hidden until the end." },
7171
buyHold: { title: "Buy and hold", what: "Buying at the start and doing nothing until the end.", why: "The standard benchmark: active trading only 'worked' if it beat this." },
72+
73+
// --- extended hours ---
74+
preMarket: {
75+
title: "Pre-market",
76+
what: "Trading before the regular session, from 4:00 to 9:30 New York time. The change shown is measured from the last regular close.",
77+
why: "It shows how the stock is reacting to overnight news, such as earnings released before the open.",
78+
tip: "Few shares trade then, so prices can jump and the gap between bid and ask is often wide. The regular open can be quite different.",
79+
},
80+
afterHours: {
81+
title: "After hours",
82+
what: "Trading after the regular session closes, from 16:00 to 20:00 New York time. The change shown is measured from today's 16:00 close.",
83+
why: "Companies often report earnings right after the close, so the first reaction happens here.",
84+
tip: "Thin trading means after-hours prices are less reliable than regular-session ones, and many orders don't execute then.",
85+
},
86+
extendedHours: {
87+
title: "Extended hours (EXT)",
88+
what: "Adds pre-market (4:00-9:30) and after-hours (16:00-20:00) candles to the 1D chart, drawn faded so they stand apart from the regular session.",
89+
why: "News often moves prices outside regular hours, and this shows where the stock traded before the open or after the close.",
90+
},
7291
};
7392

7493
/** UI labels -> glossary keys. Lookups also try the lower-cased label itself. */
7594
const ALIASES: Record<string, string> = {
95+
"pre-market": "preMarket", "after hours": "afterHours", "ext": "extendedHours",
7696
"prev close": "prevClose", "avg vol 3m": "avgVolume", "short vol %": "shortVolume", "mkt cap": "marketCap",
7797
"p/e (ttm)": "pe", "p/e": "pe", "eps (ttm)": "eps", "div yield": "dividendYield", "52w high": "week52High",
7898
"52w low": "week52Low", "shares out": "sharesOut", "chg%": "changePct", "vol": "volume", "oi · iv": "openInterest",

0 commit comments

Comments
 (0)