gloomberb 0.15.7 → 0.15.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/api-client/data.ts +5 -0
  4. package/src/api-client/doe.ts +75 -0
  5. package/src/api-client/index.ts +1 -0
  6. package/src/api-client/paths.ts +3 -0
  7. package/src/api-client/types.ts +2 -0
  8. package/src/cli/pane-functions/function-help.ts +15 -0
  9. package/src/components/chart/composite/composite-chart.tsx +15 -2
  10. package/src/components/chart/composite/format.ts +24 -11
  11. package/src/components/chart/composite/rasterizer.ts +67 -16
  12. package/src/components/chart/composite/scene.ts +53 -2
  13. package/src/components/chart/composite/session-shading.ts +3 -2
  14. package/src/components/chart/composite/text-renderer.ts +52 -1
  15. package/src/components/chart/composite/time-axis.ts +100 -30
  16. package/src/components/chart/composite/tools.ts +2 -1
  17. package/src/components/chart/composite/types.ts +4 -0
  18. package/src/components/command-bar/workflow/collection-actions.ts +9 -7
  19. package/src/components/layout/shell/index.tsx +38 -28
  20. package/src/components/onboarding/portfolio-step/positions-panel.tsx +14 -5
  21. package/src/components/onboarding/wizard-model.ts +13 -5
  22. package/src/components/onboarding/wizard-positions.ts +56 -7
  23. package/src/components/ui/display.tsx +6 -3
  24. package/src/components/ui/fields.tsx +5 -0
  25. package/src/components/ui/header-case.ts +25 -3
  26. package/src/components/ui/query-bar.tsx +8 -1
  27. package/src/i18n/es.ts +1 -0
  28. package/src/i18n/ja.ts +1 -0
  29. package/src/i18n/ko.ts +1 -0
  30. package/src/i18n/zh-cn.ts +1 -0
  31. package/src/i18n/zh-tw.ts +1 -0
  32. package/src/market-data/market/trading-sessions.ts +65 -1
  33. package/src/plugins/builtin/chart-composer/cli-options.ts +4 -0
  34. package/src/plugins/builtin/chart-composer/headless-schema.ts +6 -0
  35. package/src/plugins/builtin/chart-composer/pane.tsx +28 -1
  36. package/src/plugins/builtin/composite-plugins.ts +2 -0
  37. package/src/plugins/builtin/doe/client.ts +73 -0
  38. package/src/plugins/builtin/doe/headless.ts +93 -0
  39. package/src/plugins/builtin/doe/index.tsx +41 -0
  40. package/src/plugins/builtin/doe/model.ts +215 -0
  41. package/src/plugins/builtin/doe/pane.tsx +148 -0
  42. package/src/plugins/builtin/doe/range-bar.tsx +45 -0
  43. package/src/plugins/builtin/earnings-calls/transcript-view.tsx +7 -7
  44. package/src/plugins/builtin/executives/pane.tsx +4 -4
  45. package/src/plugins/builtin/fear-greed/charts.tsx +1 -1
  46. package/src/plugins/builtin/holders/pane.tsx +1 -1
  47. package/src/plugins/builtin/kelly-sizer/pane.tsx +1 -1
  48. package/src/plugins/builtin/options/view.tsx +1 -1
  49. package/src/plugins/builtin/portfolio-list/cli/render.ts +1 -1
  50. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -20
  51. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -2
  52. package/src/plugins/builtin/portfolio-list/mutations.ts +53 -1
  53. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +6 -6
  54. package/src/plugins/builtin/risk-factors/pane.tsx +4 -4
  55. package/src/plugins/builtin/shared/ticker-request.ts +1 -1
  56. package/src/plugins/builtin/ticker-detail/financials/ratios.ts +42 -10
  57. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +1 -1
  58. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +1 -1
  59. package/src/plugins/catalog-browser.ts +2 -0
  60. package/src/sources/gloomberb-cloud/index.ts +47 -13
  61. package/src/sources/history-result.ts +1 -0
  62. package/src/sources/history-retention.ts +16 -0
  63. package/src/sources/provider-router/history.ts +4 -2
  64. package/src/sync/core-contributors.ts +18 -2
  65. package/src/tickers/open-target.ts +1 -1
  66. package/src/tickers/search/index.ts +44 -11
  67. package/src/tickers/search/upsert.ts +4 -1
  68. package/src/time-series/live-bars.ts +8 -3
  69. package/src/time-series/resolve.ts +224 -17
  70. package/src/time-series/spec.ts +1 -0
  71. package/src/time-series/types.ts +9 -1
  72. package/src/types/data-provider.ts +2 -0
  73. package/src/types/price-history.ts +2 -0
  74. package/src/utils/exchanges.ts +1 -0
  75. package/src/utils/price-history.ts +35 -1
  76. package/src/utils/sec.ts +2 -1
  77. package/src/utils/zoned-date-time.ts +67 -0
package/README.md CHANGED
@@ -76,6 +76,7 @@ Press `Ctrl+P` to open the command bar, or press `` ` `` to search for a ticker.
76
76
  | `HVG AAPL` / `HVT AAPL` | Realized volatility and volatility cones |
77
77
  | `SEAS AAPL` | Seasonality: monthly returns by year and year overlays |
78
78
  | `COT [code or root]` | CFTC positioning and cross-market extremes |
79
+ | `DOE` / `NGS` | EIA weekly oil stocks and gas storage, with builds, draws and five-year ranges |
79
80
  | `TOP` | Market stories |
80
81
  | `WIRP` / `FFIP` | US rate path and conditional FOMC probabilities |
81
82
  | `BTMM` | Funding rates, bill curves and Federal Reserve liquidity |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gloomberb",
3
- "version": "0.15.7",
3
+ "version": "0.15.8",
4
4
  "packageManager": "bun@1.3.11",
5
5
  "description": "Market research and portfolio tracker for the terminal",
6
6
  "module": "src/public/index.ts",
@@ -14,6 +14,7 @@ import type { ShortVolumePayload, ShortVolumeScope } from "./short-volume";
14
14
  import type { SocialMentionDayPosts, SocialMentionsPayload, SocialMentionsRange } from "./social-mentions";
15
15
  import type { FuturesCurveAsOfPayload, FuturesCurvePayload } from "./futures-curve";
16
16
  import type { CotBoardPayload, CotContractPayload, CotFamily, CotClass } from "./cot";
17
+ import type { DoeBoardPayload } from "./doe";
17
18
  import type { TapeSnapshot } from "./tape";
18
19
  import type { ExchangeRateSnapshot } from "../types/exchange-rate";
19
20
  import type { RatePathPayload } from "./rates";
@@ -385,6 +386,10 @@ export class CloudDataApi {
385
386
  return this.request<CotContractPayload>(`/cloud/cot/contracts/${encodeURIComponent(code)}?${new URLSearchParams({ report })}`);
386
387
  }
387
388
 
389
+ async getCloudDoeBoard(): Promise<DoeBoardPayload> {
390
+ return this.request<DoeBoardPayload>("/cloud/doe/board");
391
+ }
392
+
388
393
  async getCloudTape(symbol: string, exchange: string, signal?: AbortSignal): Promise<TapeSnapshot> {
389
394
  return this.request<TapeSnapshot>(`/cloud/tape/${encodeURIComponent(symbol)}?exchange=${encodeURIComponent(exchange)}`, { signal: signal ?? AbortSignal.timeout(30_000) });
390
395
  }
@@ -0,0 +1,75 @@
1
+ /** EIA weekly petroleum and natural gas storage, as GET /cloud/doe/board serves it. */
2
+ export type DoeReport = "petroleum" | "gas";
3
+ export type DoeTab = "crude" | "products" | "gas";
4
+ /** Thousand barrels, thousand barrels a day, percent, billion cubic feet: as published. */
5
+ export type DoeUnit = "kb" | "kbd" | "pct" | "bcf";
6
+
7
+ interface DoeFiveYear {
8
+ /** Calendar years in the band, oldest first; fewer than five where history is short. */
9
+ years: number[];
10
+ min: number;
11
+ max: number;
12
+ average: number;
13
+ /** The average change into this week over the same years. */
14
+ averageChange: number | null;
15
+ vsAverage: number;
16
+ vsAveragePercent: number | null;
17
+ /** 0 at the five-year low, 100 at the high, beyond either outside the range. */
18
+ position: number | null;
19
+ }
20
+
21
+ interface DoeYearAgo {
22
+ weekEnding: string;
23
+ value: number;
24
+ change: number;
25
+ changePercent: number | null;
26
+ }
27
+
28
+ interface DoeSeasonal {
29
+ /** The calendar year of the latest week. */
30
+ year: number;
31
+ /** Fridays in that year: 52 or 53. */
32
+ weeks: number;
33
+ /** [week of year, value]. */
34
+ current: Array<[number, number]>;
35
+ previous: Array<[number, number]>;
36
+ /** [week, min, average, max] of the band years on that week's day. */
37
+ band: Array<[number, number | null, number | null, number | null]>;
38
+ }
39
+
40
+ export interface DoeSeriesRow {
41
+ id: string;
42
+ key: string;
43
+ report: DoeReport;
44
+ tab: DoeTab;
45
+ label: string;
46
+ unit: DoeUnit;
47
+ weekEnding: string | null;
48
+ value: number | null;
49
+ previousWeekEnding: string | null;
50
+ weekChange: number | null;
51
+ yearAgo: DoeYearAgo | null;
52
+ fiveYear: DoeFiveYear | null;
53
+ /** One plain read worked out from the numbers. */
54
+ read: string | null;
55
+ seasonal: DoeSeasonal | null;
56
+ }
57
+
58
+ export interface DoeReportStatus {
59
+ id: DoeReport;
60
+ weekEnding: string | null;
61
+ releasedAt: string | null;
62
+ releaseHoliday: string | null;
63
+ nextWeekEnding: string | null;
64
+ nextReleaseAt: string | null;
65
+ nextReleaseHoliday: string | null;
66
+ }
67
+
68
+ export interface DoeBoardPayload {
69
+ source: "EIA";
70
+ generatedAt: string;
71
+ status: "available" | "partial" | "unavailable";
72
+ gaps: string[];
73
+ reports: DoeReportStatus[];
74
+ series: DoeSeriesRow[];
75
+ }
@@ -721,6 +721,7 @@ class GloomApiClient {
721
721
  getCloudShiller = this.data.getCloudShiller.bind(this.data);
722
722
  getCloudCotBoard = this.data.getCloudCotBoard.bind(this.data);
723
723
  getCloudCotContract = this.data.getCloudCotContract.bind(this.data);
724
+ getCloudDoeBoard = this.data.getCloudDoeBoard.bind(this.data);
724
725
  getCloudTape = this.data.getCloudTape.bind(this.data);
725
726
  getCloudYieldCurve = this.data.getCloudYieldCurve.bind(this.data);
726
727
  getCloudCurve = this.data.getCloudCurve.bind(this.data);
@@ -13,6 +13,8 @@ export type CloudHistoryParams = {
13
13
  endDate?: string;
14
14
  rangeKey?: string;
15
15
  historyRecovery?: HistoryRetention;
16
+ /** Ask for pre-market and after-hours bars too. */
17
+ session?: "extended";
16
18
  };
17
19
 
18
20
  export type CloudFredSeriesParams = {
@@ -172,6 +174,7 @@ export function cloudHistoryPath(
172
174
  if (params.endDate) search.set("endDate", params.endDate);
173
175
  if (params.rangeKey) search.set("rangeKey", params.rangeKey);
174
176
  if (params.historyRecovery) search.set("historyRecovery", JSON.stringify(params.historyRecovery));
177
+ if (params.session) search.set("session", params.session);
175
178
  return appendQuery("/market/history", search);
176
179
  }
177
180
 
@@ -1293,6 +1293,8 @@ export interface CloudMarketResponse<T> {
1293
1293
  historyRetention?: unknown;
1294
1294
  /** Source-declared history semantics; validated separately from quote metadata. */
1295
1295
  historySession?: unknown;
1296
+ /** "extended" when the history includes the pre-market and after-hours bars asked for. */
1297
+ session?: unknown;
1296
1298
  /** Untrusted until the history adapter validates its boundary dates. */
1297
1299
  coverage?: CloudHistoryCoverage;
1298
1300
  providerMeta?: {
@@ -582,6 +582,21 @@ export const FUNCTION_HELP: Readonly<Record<string, FunctionHelp>> = {
582
582
  data: same("Weekly: Tuesday positions, out on Friday"),
583
583
  bloomberg: ["COT"],
584
584
  },
585
+ DOE: {
586
+ summary: "The EIA's weekly petroleum and natural gas storage numbers: crude, Cushing and the SPR, gasoline, distillates, jet fuel and propane, refinery runs, trade and demand, and gas storage by region. Each row shows the week's build or draw, the change on a year ago and where it sits in its five-year range; the selected one is charted over the year against the five-year band with a plain read of the numbers.",
587
+ usage: ["DOE", "NGS"],
588
+ keys: [],
589
+ data: same("Weekly, on release"),
590
+ bloomberg: ["DOE"],
591
+ },
592
+ NGS: {
593
+ summary: "Lower 48 working gas in storage and each region's, the week's injection or withdrawal against a year ago and the five-year average, charted over the year. The Gas Storage tab of DOE.",
594
+ usage: ["NGS"],
595
+ keys: [],
596
+ data: same("Weekly, on release"),
597
+ bloomberg: [],
598
+ docs: "DOE",
599
+ },
585
600
  VIX: {
586
601
  summary: "Daily VIX tenor closes, the term structure and whether it is in contango or inverted, with the cross-asset volatility board beside it.",
587
602
  usage: ["VIX"],
@@ -1159,6 +1159,17 @@ function CompositePanelSurface({
1159
1159
  color: marker.color,
1160
1160
  };
1161
1161
  }, [formatAxisValue, panel]);
1162
+ const priorCloseMarker = useMemo(() => {
1163
+ const marker = panel.priorClose;
1164
+ const domain = marker ? panel.axes[marker.axis] : undefined;
1165
+ if (!marker || !domain) return null;
1166
+ return {
1167
+ side: marker.axis,
1168
+ yRatio: marker.yRatio,
1169
+ label: formatAxisValue ? formatAxisValue(marker.value, domain) : formatCompositeCursorValue(marker.value, domain),
1170
+ color: colors.textDim,
1171
+ };
1172
+ }, [colors.textDim, formatAxisValue, panel]);
1162
1173
  const levelMarkers = useMemo(() => levelDomain ? [...projectedLevels]
1163
1174
  // The picked level's label first, then alerts', when they compete for room.
1164
1175
  .sort((left, right) => Number(right.level.id === selectedLevelId) - Number(left.level.id === selectedLevelId)
@@ -1181,7 +1192,8 @@ function CompositePanelSurface({
1181
1192
  !!marker && (marker.side === null || marker.side === side)
1182
1193
  );
1183
1194
  const fixed = [lastPriceMarker, axisMarkers].filter(onSide).map(toAxisMarker);
1184
- const levelLabels = levelMarkers.filter(onSide).map(toAxisMarker);
1195
+ // The prior close's label yields to every level's.
1196
+ const levelLabels = [...levelMarkers, priorCloseMarker].filter(onSide).map(toAxisMarker);
1185
1197
  // A label is a row tall, so close levels would stack into an unreadable
1186
1198
  // pile and hide the last price: each keeps its line, and only the labels
1187
1199
  // with a row to themselves show. Desktop badges sit between rows.
@@ -2885,7 +2897,7 @@ export function CompositeChart({
2885
2897
  const timeAxisCursorLabel = xAxis?.formatCursor && scene.cursorXRatio !== null
2886
2898
  ? xAxis.formatCursor(scene.cursorXRatio)
2887
2899
  : scene.cursorDate
2888
- ? formatCompositeTimeAxisDate(scene.cursorDate, scene.startTime, scene.endTime)
2900
+ ? formatCompositeTimeAxisDate(scene.cursorDate, scene.startTime, scene.endTime, scene.timeZone)
2889
2901
  : null;
2890
2902
  // The crosshair labels the moving end, so the axis only adds the anchor.
2891
2903
  const timeAxisMarkers = toolSpan
@@ -2895,6 +2907,7 @@ export function CompositeChart({
2895
2907
  new Date(toolSpan.startTime),
2896
2908
  scene.startTime,
2897
2909
  scene.endTime,
2910
+ scene.timeZone,
2898
2911
  ),
2899
2912
  color: toolSpan.color,
2900
2913
  }]
@@ -1,6 +1,7 @@
1
1
  import { formatMarketPrice, formatMarketPriceWithCurrency, stablePriceFractionDigits, type MarketFormatOptions } from "../../../market-data/market/format";
2
2
  import type { ResolvedSeries, TimeSeriesPoint } from "../../../time-series/types";
3
3
  import type { CompositeAxisDomain, CompositePanelScene } from "./types";
4
+ import { timeZoneLabel, zonedWallClockMs } from "../../../utils/zoned-date-time";
4
5
 
5
6
  const CURRENCY_SYMBOLS: Record<string, string> = {
6
7
  USD: "$",
@@ -411,17 +412,28 @@ function utcTime(date: Date): string {
411
412
  return date.toISOString().slice(11, 16);
412
413
  }
413
414
 
415
+ /** The date's wall-clock reading in `timeZone` (UTC when absent), and the zone's short name. */
416
+ function onClock(date: Date, timeZone: string | undefined): { wall: Date; zone: string } {
417
+ if (!timeZone || timeZone === "UTC") return { wall: date, zone: "UTC" };
418
+ try {
419
+ return { wall: new Date(zonedWallClockMs(date.getTime(), timeZone)), zone: timeZoneLabel(timeZone) };
420
+ } catch {
421
+ return { wall: date, zone: "UTC" };
422
+ }
423
+ }
424
+
414
425
  function isIntradaySpan(startTime: number, endTime: number): boolean {
415
426
  return Number.isFinite(startTime)
416
427
  && Number.isFinite(endTime)
417
428
  && Math.abs(endTime - startTime) <= INTRADAY_SPAN_MAX_MS;
418
429
  }
419
430
 
420
- /** Shared-cursor timestamp using the chart's explicit UTC convention. */
421
- export function formatCompositeCursorDate(date: Date, startTime: number, endTime: number): string {
431
+ /** Shared-cursor timestamp on the chart's clock: the venue's for intraday bars, else UTC. */
432
+ export function formatCompositeCursorDate(date: Date, startTime: number, endTime: number, timeZone?: string): string {
433
+ const { wall, zone } = onClock(date, timeZone);
422
434
  return isIntradaySpan(startTime, endTime)
423
- ? `${utcDate(date)} ${utcTime(date)} UTC`
424
- : utcDate(date);
435
+ ? `${utcDate(wall)} ${utcTime(wall)} ${zone}`
436
+ : utcDate(wall);
425
437
  }
426
438
 
427
439
  function validUtcTimestamp(date: Date | undefined): string | null {
@@ -462,12 +474,13 @@ export function formatCompositePointDetails(point: TimeSeriesPoint | null | unde
462
474
  return details.join(" · ");
463
475
  }
464
476
 
465
- /** Compact UTC tick label selected from the full visible chart span. */
466
- export function formatCompositeTimeAxisDate(date: Date, startTime: number, endTime: number): string {
467
- if (!isIntradaySpan(startTime, endTime)) return utcDate(date);
468
- const startDate = utcDate(new Date(startTime));
469
- const endDate = utcDate(new Date(endTime));
477
+ /** Compact tick label on the chart's clock, selected from the full visible chart span. */
478
+ export function formatCompositeTimeAxisDate(date: Date, startTime: number, endTime: number, timeZone?: string): string {
479
+ const { wall, zone } = onClock(date, timeZone);
480
+ if (!isIntradaySpan(startTime, endTime)) return utcDate(wall);
481
+ const startDate = utcDate(onClock(new Date(startTime), timeZone).wall);
482
+ const endDate = utcDate(onClock(new Date(endTime), timeZone).wall);
470
483
  return startDate === endDate
471
- ? `${utcTime(date)} UTC`
472
- : `${utcDate(date).slice(5)} ${utcTime(date)} UTC`;
484
+ ? `${utcTime(wall)} ${zone}`
485
+ : `${utcDate(wall).slice(5)} ${utcTime(wall)} ${zone}`;
473
486
  }
@@ -39,6 +39,9 @@ interface RenderCompositePanelBitmapOptions {
39
39
  const LAST_PRICE_DASH_PIXELS = 6;
40
40
  const LAST_PRICE_GAP_PIXELS = 5;
41
41
  const LAST_PRICE_OPACITY = 0.9;
42
+ const PRIOR_CLOSE_DOT_PIXELS = 2;
43
+ const PRIOR_CLOSE_GAP_PIXELS = 3;
44
+ const PRIOR_CLOSE_OPACITY = 0.7;
42
45
 
43
46
  function pixelPoint(point: CompositeProjectedPoint, width: number, height: number): { x: number; y: number } {
44
47
  return {
@@ -118,6 +121,53 @@ function fillAreaGradient(
118
121
  }
119
122
  }
120
123
 
124
+ const BAND_FILL_OPACITY = 0.2;
125
+
126
+ /**
127
+ * The range a band series spans, one flat fill per pixel column between its
128
+ * points' `low` and `high`, read straight across between neighbouring points.
129
+ */
130
+ function fillBand(
131
+ data: Uint8Array,
132
+ width: number,
133
+ height: number,
134
+ series: CompositeProjectedSeries,
135
+ domain: CompositeAxisDomain,
136
+ color: RgbaColor,
137
+ ): void {
138
+ const edges = series.points.map((projected) => {
139
+ const { high, low } = projected.point;
140
+ const x = pixelPoint(projected, width, height).x;
141
+ if (high == null || low == null || !Number.isFinite(high) || !Number.isFinite(low)) return null;
142
+ const top = pixelY(high, domain, height);
143
+ const bottom = pixelY(low, domain, height);
144
+ return top === null || bottom === null ? null : { x, top: Math.min(top, bottom), bottom: Math.max(top, bottom) };
145
+ });
146
+ const filled = new Uint8Array(width);
147
+ const fillColumn = (x: number, top: number, bottom: number) => {
148
+ if (x < 0 || x >= width || filled[x]) return;
149
+ filled[x] = 1;
150
+ for (let y = Math.max(0, Math.round(top)); y <= Math.min(height - 1, Math.round(bottom)); y += 1) {
151
+ blendPixel(data, width, height, x, y, color, BAND_FILL_OPACITY);
152
+ }
153
+ };
154
+ for (let index = 0; index < edges.length; index += 1) {
155
+ const current = edges[index];
156
+ if (!current) continue;
157
+ const previous = index > 0 && !series.points[index]?.breakBefore ? edges[index - 1] : null;
158
+ if (!previous) {
159
+ fillColumn(Math.round(current.x), current.top, current.bottom);
160
+ continue;
161
+ }
162
+ const start = Math.round(previous.x);
163
+ const span = Math.max(1, Math.round(current.x) - start);
164
+ for (let x = start; x <= start + span; x += 1) {
165
+ const t = (x - start) / span;
166
+ fillColumn(x, previous.top + (current.top - previous.top) * t, previous.bottom + (current.bottom - previous.bottom) * t);
167
+ }
168
+ }
169
+ }
170
+
121
171
  function drawConnectedSeries(
122
172
  data: Uint8Array,
123
173
  width: number,
@@ -343,27 +393,19 @@ function drawOhlc(
343
393
  }
344
394
  }
345
395
 
346
- function drawLastPriceLevel(
396
+ function drawDashedLevel(
347
397
  data: Uint8Array,
348
398
  width: number,
349
399
  height: number,
350
400
  yRatio: number,
351
401
  color: RgbaColor,
402
+ dash = LAST_PRICE_DASH_PIXELS,
403
+ gap = LAST_PRICE_GAP_PIXELS,
404
+ opacity = LAST_PRICE_OPACITY,
352
405
  ): void {
353
406
  const y = Math.round(clamp(yRatio * Math.max(height - 1, 0), 0, Math.max(height - 1, 0)));
354
- const period = LAST_PRICE_DASH_PIXELS + LAST_PRICE_GAP_PIXELS;
355
- for (let x = 0; x < width; x += period) {
356
- fillRect(
357
- data,
358
- width,
359
- height,
360
- x,
361
- y,
362
- Math.min(x + LAST_PRICE_DASH_PIXELS - 1, width - 1),
363
- y,
364
- color,
365
- LAST_PRICE_OPACITY,
366
- );
407
+ for (let x = 0; x < width; x += dash + gap) {
408
+ fillRect(data, width, height, x, y, Math.min(x + dash - 1, width - 1), y, color, opacity);
367
409
  }
368
410
  }
369
411
 
@@ -388,9 +430,14 @@ export function renderCompositePanelBitmap(
388
430
  fillRect(data, width, height, 0, y, width - 1, y + 0.6, grid, 0.42);
389
431
  }
390
432
  if (panel.volumeProfile) paintVolumeProfile(data, width, height, panel.volumeProfile);
433
+ // Under the bars, which read across it.
434
+ if (panel.priorClose) {
435
+ drawDashedLevel(data, width, height, panel.priorClose.yRatio, parseHex(options.colors.textDim),
436
+ PRIOR_CLOSE_DOT_PIXELS, PRIOR_CLOSE_GAP_PIXELS, PRIOR_CLOSE_OPACITY);
437
+ }
391
438
 
392
439
  const ordered = [...panel.series].sort((left, right) => {
393
- const rank = (style: string) => style === "area" || style === "columns" ? 0 : 1;
440
+ const rank = (style: string) => style === "area" || style === "columns" || style === "band" ? 0 : 1;
394
441
  return rank(left.source.style) - rank(right.source.style);
395
442
  });
396
443
  const columnLayout = buildCompositeColumnLayout(panel);
@@ -430,6 +477,10 @@ export function renderCompositePanelBitmap(
430
477
  case "area":
431
478
  drawConnectedSeries(data, width, height, series, domain, color, true);
432
479
  break;
480
+ case "band":
481
+ fillBand(data, width, height, series, domain, color);
482
+ drawConnectedSeries(data, width, height, series, domain, color, false);
483
+ break;
433
484
  case "points":
434
485
  for (const point of series.points) {
435
486
  const projected = pixelPoint(point, width, height);
@@ -449,7 +500,7 @@ export function renderCompositePanelBitmap(
449
500
  }
450
501
 
451
502
  if (panel.lastPrice) {
452
- drawLastPriceLevel(data, width, height, panel.lastPrice.yRatio, parseHex(panel.lastPrice.color));
503
+ drawDashedLevel(data, width, height, panel.lastPrice.yRatio, parseHex(panel.lastPrice.color));
453
504
  }
454
505
 
455
506
  return { width, height, pixels: data };
@@ -24,7 +24,8 @@ import { compositeAxisMaxTicks, seriesPriceReference } from "./format";
24
24
  import type { CompositeTimeScale } from "./types";
25
25
  import { isFiniteNumber } from "../../../utils/guards";
26
26
  import { volumeProfile } from "../../../time-series/trader-studies";
27
- import { extendedHoursSpans } from "./session-shading";
27
+ import { extendedHoursSpans, isIntradaySeries } from "./session-shading";
28
+ import { listingTimeZone } from "../../../market-data/market/trading-sessions";
28
29
 
29
30
  const DAY_MS = 24 * 60 * 60 * 1000;
30
31
 
@@ -247,6 +248,10 @@ function seriesDomainValues(series: ResolvedSeries): number[] {
247
248
  for (const candidate of [point.open, point.high, point.low, point.close]) {
248
249
  if (isFiniteNumber(candidate)) values.push(candidate);
249
250
  }
251
+ } else if (series.style === "band") {
252
+ for (const candidate of [point.high, point.low]) {
253
+ if (isFiniteNumber(candidate)) values.push(candidate);
254
+ }
250
255
  }
251
256
  }
252
257
  if (series.style === "columns") values.push(0);
@@ -300,6 +305,24 @@ function axisPriceReferences(axisSeries: ResolvedSeries[]): Record<string, numbe
300
305
  return references;
301
306
  }
302
307
 
308
+ /**
309
+ * A one-day chart's previous close joins the axis when it lies within one
310
+ * session range of the bars, so a quiet session still shows where it opened
311
+ * from; a far gap leaves the bars their full height.
312
+ */
313
+ function priorCloseInReach(series: readonly ResolvedSeries[], values: readonly number[]): number[] {
314
+ const reference = series.find((entry) => isFiniteNumber(entry.priorClose) && entry.points.length > 0)?.priorClose;
315
+ if (reference === undefined || values.length === 0) return [];
316
+ let low = Number.POSITIVE_INFINITY;
317
+ let high = Number.NEGATIVE_INFINITY;
318
+ for (const value of values) {
319
+ if (value < low) low = value;
320
+ if (value > high) high = value;
321
+ }
322
+ const span = high - low;
323
+ return reference >= low - span && reference <= high + span ? [reference] : [];
324
+ }
325
+
303
326
  function buildAxisDomain(
304
327
  side: CompositeAxisSide,
305
328
  series: ResolvedSeries[],
@@ -308,7 +331,8 @@ function buildAxisDomain(
308
331
  ): CompositeAxisDomain | undefined {
309
332
  const axisSeries = series.filter((entry) => entry.axis === side);
310
333
  if (axisSeries.length === 0) return undefined;
311
- const { min, max } = paddedDomain(axisSeries.flatMap(seriesDomainValues), scale);
334
+ const values = axisSeries.flatMap(seriesDomainValues);
335
+ const { min, max } = paddedDomain([...values, ...priorCloseInReach(axisSeries, values)], scale);
312
336
  const first = axisSeries[0]!;
313
337
  return {
314
338
  side,
@@ -496,6 +520,30 @@ function attachLastPriceMarker(
496
520
  };
497
521
  }
498
522
 
523
+ /** Marks the previous session's close a one-day chart carries on its price series. */
524
+ function attachPriorClose(panels: CompositePanelScene[], series: readonly ResolvedSeries[]): void {
525
+ const entry = series.find((candidate) => isFiniteNumber(candidate.priorClose));
526
+ const panel = entry ? panels.find((candidate) => candidate.id === entry.panelId) : undefined;
527
+ const domain = entry ? panel?.axes[entry.axis] : undefined;
528
+ if (!entry || !panel || !domain || !panel.series.some((candidate) => candidate.source.id === entry.id)) return;
529
+ const yRatio = projectCompositeValue(entry.priorClose!, domain);
530
+ // Off the axis it marks nothing; the axis follows the session's own bars.
531
+ if (yRatio === null || yRatio < 0 || yRatio > 1) return;
532
+ panel.priorClose = { axis: entry.axis, value: entry.priorClose!, yRatio };
533
+ }
534
+
535
+ /**
536
+ * The zone intraday market bars read in: the venue's own clock, as the tape
537
+ * does. Daily and longer bars carry dates, which stay in UTC.
538
+ */
539
+ function sceneTimeZone(anchor: ResolvedSeries | undefined, series: readonly ResolvedSeries[]): string | undefined {
540
+ const market = anchor ?? series.find((entry) => entry.observationKind === "market");
541
+ if (!market || !isIntradaySeries(market)) return undefined;
542
+ const zone = market.timeBasis?.timeZone
543
+ ?? (market.listing ? listingTimeZone(market.listing.symbol, market.listing.exchange) : null);
544
+ return zone && zone !== "UTC" ? zone : undefined;
545
+ }
546
+
499
547
  /**
500
548
  * Profiles a volume-profile study over its bars in view and takes its points
501
549
  * off the panel, so it draws as a histogram rather than a line or cursor dots.
@@ -754,6 +802,8 @@ export function buildCompositeChartScene(
754
802
  });
755
803
  panelScenes.forEach(attachVolumeProfile);
756
804
  attachLastPriceMarker(panelScenes, usableSeries);
805
+ attachPriorClose(panelScenes, usableSeries);
806
+ const timeZone = sceneTimeZone(anchor, timelineSeries);
757
807
 
758
808
  return {
759
809
  width: Math.max(1, Math.floor(options.width)),
@@ -767,6 +817,7 @@ export function buildCompositeChartScene(
767
817
  cursorDate,
768
818
  cursorXRatio,
769
819
  cursorValues: buildCursorValues(panelScenes, cursorDate, { startTime, endTime, timeScale }),
820
+ ...(timeZone ? { timeZone } : {}),
770
821
  };
771
822
  }
772
823
 
@@ -21,7 +21,8 @@ function isRegular(exchange: string, time: number): boolean | null {
21
21
  return regular;
22
22
  }
23
23
 
24
- function isIntraday(series: ResolvedSeries): boolean {
24
+ /** Whether a series holds intraday bars, by its cadence or its acquired resolution. */
25
+ export function isIntradaySeries(series: ResolvedSeries): boolean {
25
26
  const cadence = series.timeBasis?.cadenceMs;
26
27
  if (cadence !== undefined) return cadence < DAY_MS;
27
28
  return !!series.historyResolution && isIntradayResolution(series.historyResolution);
@@ -39,7 +40,7 @@ export function extendedHoursSpans(
39
40
  ratios: readonly number[],
40
41
  ): CompositeExtendedHoursSpan[] {
41
42
  const exchange = anchor?.timeBasis?.exchange;
42
- if (!anchor || !exchange || !isIntraday(anchor) || dates.length === 0) return [];
43
+ if (!anchor || !exchange || !isIntradaySeries(anchor) || dates.length === 0) return [];
43
44
  const edge = (index: number, side: -1 | 1) => {
44
45
  const ratio = ratios[index]!;
45
46
  // At either end of the plot, mirror the gap to the bar on the other side.
@@ -113,6 +113,46 @@ function renderArea(
113
113
  renderLineLike(rows, series, width, height);
114
114
  }
115
115
 
116
+ /** A band's range as shade between its points' low and high rows, under its line. */
117
+ function renderBand(
118
+ rows: string[][],
119
+ series: CompositeProjectedSeries,
120
+ domain: CompositeAxisDomain,
121
+ width: number,
122
+ height: number,
123
+ ): void {
124
+ const edges = series.points.map((projected) => {
125
+ const { high, low } = projected.point;
126
+ if (high == null || low == null) return null;
127
+ const top = valueRow(high, domain, height);
128
+ const bottom = valueRow(low, domain, height);
129
+ return top === null || bottom === null
130
+ ? null
131
+ : { x: cellPoint(projected, width, height).x, top: Math.min(top, bottom), bottom: Math.max(top, bottom) };
132
+ });
133
+ const shade = (x: number, top: number, bottom: number) => {
134
+ for (let row = Math.round(top); row <= Math.round(bottom); row += 1) {
135
+ const current = rows[row]?.[x];
136
+ if (current === " " || current === "·") setCell(rows, x, row, "░");
137
+ }
138
+ };
139
+ for (let index = 0; index < edges.length; index += 1) {
140
+ const current = edges[index];
141
+ if (!current) continue;
142
+ const previous = index > 0 && !series.points[index]?.breakBefore ? edges[index - 1] : null;
143
+ if (!previous) {
144
+ shade(current.x, current.top, current.bottom);
145
+ continue;
146
+ }
147
+ const span = Math.max(1, current.x - previous.x);
148
+ for (let x = previous.x; x <= current.x; x += 1) {
149
+ const t = (x - previous.x) / span;
150
+ shade(x, previous.top + (current.top - previous.top) * t, previous.bottom + (current.bottom - previous.bottom) * t);
151
+ }
152
+ }
153
+ renderLineLike(rows, series, width, height);
154
+ }
155
+
116
156
  function renderColumns(
117
157
  rows: string[][],
118
158
  series: CompositeProjectedSeries,
@@ -221,7 +261,7 @@ export function renderCompositePanelText(
221
261
 
222
262
  const columnLayout = buildCompositeColumnLayout(panel);
223
263
  const orderedSeries = [...panel.series].sort((left, right) => {
224
- const layerRank = (style: string) => style === "area" || style === "columns" ? 0 : 1;
264
+ const layerRank = (style: string) => style === "area" || style === "columns" || style === "band" ? 0 : 1;
225
265
  return layerRank(left.source.style) - layerRank(right.source.style);
226
266
  });
227
267
  for (const series of orderedSeries) {
@@ -234,6 +274,9 @@ export function renderCompositePanelText(
234
274
  case "area":
235
275
  renderArea(rows, series, domain, plotWidth, height);
236
276
  break;
277
+ case "band":
278
+ renderBand(rows, series, domain, plotWidth, height);
279
+ break;
237
280
  case "candles":
238
281
  case "ohlc":
239
282
  case "hlc":
@@ -264,6 +307,14 @@ export function renderCompositePanelText(
264
307
  if (current === " " || current === "·") setCell(rows, x, row, "╌");
265
308
  }
266
309
  }
310
+ // The previous close, dotted, where the last price has not taken the row.
311
+ if (panel.priorClose) {
312
+ const row = clamp(Math.round(panel.priorClose.yRatio * Math.max(height - 1, 0)), 0, Math.max(height - 1, 0));
313
+ for (let x = 0; x < plotWidth; x += 1) {
314
+ const current = rows[row]?.[x];
315
+ if (current === " " || current === "·") setCell(rows, x, row, "┈");
316
+ }
317
+ }
267
318
 
268
319
  if (cursorXRatio !== null) {
269
320
  const cursorX = clamp(Math.round(cursorXRatio * Math.max(plotWidth - 1, 0)), 0, Math.max(plotWidth - 1, 0));