gloomberb 0.7.3 → 0.7.4

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 (66) hide show
  1. package/package.json +1 -1
  2. package/src/components/chart/chart-renderer.ts +109 -32
  3. package/src/components/chart/chart-types.ts +18 -0
  4. package/src/components/chart/chart.test.ts +152 -5
  5. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  6. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  7. package/src/components/chart/comparison-stock-chart.tsx +45 -8
  8. package/src/components/chart/market-session.ts +187 -0
  9. package/src/components/chart/native/chart-rasterizer.ts +65 -2
  10. package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
  11. package/src/components/chart/stock-chart.tsx +36 -8
  12. package/src/components/chart/time-axis-label.tsx +56 -2
  13. package/src/components/command-bar/command-bar.test.tsx +1 -1
  14. package/src/components/command-bar/command-bar.tsx +7 -1
  15. package/src/components/command-bar/list-model.ts +8 -4
  16. package/src/components/command-bar/view-model.test.ts +20 -0
  17. package/src/components/command-bar/view-model.ts +15 -3
  18. package/src/components/ticker-list-table.tsx +4 -15
  19. package/src/core/state/session-persistence.ts +25 -0
  20. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  21. package/src/market-data/coordinator.test.ts +122 -2
  22. package/src/market-data/coordinator.ts +192 -55
  23. package/src/market-data/hooks.test.tsx +31 -0
  24. package/src/market-data/hooks.tsx +1 -0
  25. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  26. package/src/plugins/builtin/chat-controller.ts +8 -7
  27. package/src/plugins/builtin/chat.test.tsx +55 -0
  28. package/src/plugins/builtin/chat.tsx +20 -6
  29. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  30. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  31. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  32. package/src/plugins/builtin/news-wire/index.tsx +0 -2
  33. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  34. package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
  35. package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
  36. package/src/plugins/builtin/sectors/index.tsx +30 -9
  37. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
  38. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
  39. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  40. package/src/sources/gloomberb-cloud.test.ts +55 -2
  41. package/src/sources/gloomberb-cloud.ts +180 -29
  42. package/src/sources/provider-router.test.ts +567 -10
  43. package/src/sources/provider-router.ts +323 -93
  44. package/src/sources/yahoo-finance.test.ts +37 -0
  45. package/src/sources/yahoo-finance.ts +48 -2
  46. package/src/state/app-bootstrap.test.ts +50 -23
  47. package/src/state/app-bootstrap.ts +26 -10
  48. package/src/state/session-persistence.test.ts +5 -3
  49. package/src/state/use-inline-tickers.ts +3 -0
  50. package/src/state/use-quote-streaming.ts +11 -0
  51. package/src/types/data-provider.ts +5 -1
  52. package/src/types/financials.ts +1 -0
  53. package/src/utils/api-client.test.ts +120 -0
  54. package/src/utils/api-client.ts +66 -19
  55. package/src/utils/exchanges.test.ts +16 -0
  56. package/src/utils/exchanges.ts +33 -0
  57. package/src/utils/options.ts +1 -1
  58. package/src/utils/price-history.test.ts +28 -1
  59. package/src/utils/price-history.ts +16 -0
  60. package/src/utils/quote-freshness.test.ts +150 -0
  61. package/src/utils/quote-freshness.ts +42 -1
  62. package/src/utils/quote-resolution.ts +2 -0
  63. package/src/utils/quote-time.test.ts +34 -0
  64. package/src/utils/quote-time.ts +18 -5
  65. package/src/version.ts +1 -1
  66. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -128,6 +128,7 @@ import type { PricePoint, TickerFinancials } from "../../types/financials";
128
128
  import type { TickerRecord } from "../../types/ticker";
129
129
  import { TimeAxisLabel } from "./time-axis-label";
130
130
  import { PriceAxisLabels } from "./price-axis-labels";
131
+ import { getChartMarketSessionKey, resolveChartMarketSession } from "./market-session";
131
132
 
132
133
  const MODE_CHIPS: Record<ChartRenderMode, string> = {
133
134
  area: "A",
@@ -1086,6 +1087,7 @@ function buildNativeBitmapKey(
1086
1087
  showVolume: boolean,
1087
1088
  paletteId: string,
1088
1089
  indicatorKey: string,
1090
+ marketSessionKey: string,
1089
1091
  ): string {
1090
1092
  const fingerprint = points
1091
1093
  .map((point) => {
@@ -1093,7 +1095,7 @@ function buildNativeBitmapKey(
1093
1095
  return `${date}:${point.open}:${point.high}:${point.low}:${point.close}:${point.volume ?? 0}`;
1094
1096
  })
1095
1097
  .join("|");
1096
- return [pointCount, pixelWidth, pixelHeight, mode, showVolume ? "1" : "0", paletteId, indicatorKey, fingerprint].join("::");
1098
+ return [pointCount, pixelWidth, pixelHeight, mode, showVolume ? "1" : "0", paletteId, indicatorKey, marketSessionKey, fingerprint].join("::");
1097
1099
  }
1098
1100
 
1099
1101
  function buildNativeCrosshairBitmapKey(
@@ -1715,7 +1717,7 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
1715
1717
  !compact && instrumentRef
1716
1718
  ? {
1717
1719
  instrument: instrumentRef,
1718
- bufferRange: indicatorBufferRange,
1720
+ bufferRange: viewState.bufferRange,
1719
1721
  granularity: "range",
1720
1722
  }
1721
1723
  : null,
@@ -3044,6 +3046,14 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3044
3046
  themeColors.text,
3045
3047
  themeColors.textDim,
3046
3048
  ]);
3049
+ const marketSession = useMemo(() => resolveChartMarketSession(ticker
3050
+ ? [{
3051
+ exchange: ticker.metadata.exchange,
3052
+ currency: ticker.metadata.currency,
3053
+ assetCategory: ticker.metadata.assetCategory,
3054
+ }]
3055
+ : []), [ticker]);
3056
+ const marketSessionKey = useMemo(() => getChartMarketSessionKey(marketSession), [marketSession]);
3047
3057
 
3048
3058
  const cursorX = viewState.cursorX !== null ? clamp(viewState.cursorX, 0, chartWidth - 1) : null;
3049
3059
  const cursorY = viewState.cursorY !== null ? clamp(viewState.cursorY, 0, chartHeight - 1) : null;
@@ -3071,7 +3081,8 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3071
3081
  axisMode,
3072
3082
  colors: chartColors,
3073
3083
  timeAxisDates,
3074
- }), [axisMode, chartColors, chartHeight, chartWidth, compact, projection.effectiveMode, projection.points, selectionCursorX, selectionCursorY, showVolume, timeAxisDates, volumeHeight]);
3084
+ marketSession,
3085
+ }), [axisMode, chartColors, chartHeight, chartWidth, compact, marketSession, projection.effectiveMode, projection.points, selectionCursorX, selectionCursorY, showVolume, timeAxisDates, volumeHeight]);
3075
3086
  const displayScene = useMemo(() => buildChartScene(projection.points, {
3076
3087
  width: chartWidth,
3077
3088
  height: chartHeight,
@@ -3084,7 +3095,8 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3084
3095
  colors: chartColors,
3085
3096
  timeAxisDates,
3086
3097
  indicators,
3087
- }), [axisMode, chartColors, chartHeight, chartWidth, compact, displayCursorX, displayCursorY, indicators, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
3098
+ marketSession,
3099
+ }), [axisMode, chartColors, chartHeight, chartWidth, compact, displayCursorX, displayCursorY, indicators, marketSession, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
3088
3100
  const snappedSelectionCursorX = selectionScene
3089
3101
  ? getPointTerminalColumn(selectionScene.activeIdx, projection.points.length, chartWidth, projection.effectiveMode)
3090
3102
  : null;
@@ -3130,7 +3142,8 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3130
3142
  colors: chartColors,
3131
3143
  timeAxisDates,
3132
3144
  indicators,
3133
- }), [axisMode, chartColors, chartHeight, chartWidth, compact, indicators, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
3145
+ marketSession,
3146
+ }), [axisMode, chartColors, chartHeight, chartWidth, compact, indicators, marketSession, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
3134
3147
 
3135
3148
  const staticResult = useMemo(() => renderChart(projection.points, {
3136
3149
  width: chartWidth,
@@ -3146,7 +3159,8 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3146
3159
  colors: chartColors,
3147
3160
  timeAxisDates,
3148
3161
  indicators,
3149
- }), [axisMode, chartAssetCategory, chartColors, chartCurrency, chartHeight, chartWidth, compact, indicators, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
3162
+ marketSession,
3163
+ }), [axisMode, chartAssetCategory, chartColors, chartCurrency, chartHeight, chartWidth, compact, indicators, marketSession, projection.effectiveMode, projection.points, showVolume, timeAxisDates, volumeHeight]);
3150
3164
 
3151
3165
  const interactiveResult = useMemo(() => (
3152
3166
  effectiveRenderer === "kitty" || useCanvasChart
@@ -3165,8 +3179,9 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3165
3179
  colors: chartColors,
3166
3180
  timeAxisDates,
3167
3181
  indicators,
3182
+ marketSession,
3168
3183
  })
3169
- ), [axisMode, chartAssetCategory, chartColors, chartCurrency, chartHeight, chartWidth, compact, displayCursorX, displayCursorY, effectiveRenderer, indicators, projection.effectiveMode, projection.points, showVolume, timeAxisDates, useCanvasChart, volumeHeight]);
3184
+ ), [axisMode, chartAssetCategory, chartColors, chartCurrency, chartHeight, chartWidth, compact, displayCursorX, displayCursorY, effectiveRenderer, indicators, marketSession, projection.effectiveMode, projection.points, showVolume, timeAxisDates, useCanvasChart, volumeHeight]);
3170
3185
 
3171
3186
  const result = effectiveRenderer === "kitty" || useCanvasChart ? staticResult : interactiveResult!;
3172
3187
 
@@ -3300,8 +3315,11 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3300
3315
  chartColors.volumeDown,
3301
3316
  chartColors.candleUp,
3302
3317
  chartColors.candleDown,
3318
+ chartColors.preMarketBgColor,
3319
+ chartColors.postMarketBgColor,
3303
3320
  ].join(","),
3304
3321
  indicatorRenderKey,
3322
+ marketSessionKey,
3305
3323
  );
3306
3324
  const cachedBitmap = lastNativeBaseBitmapRef.current?.key === bitmapKey
3307
3325
  ? lastNativeBaseBitmapRef.current.bitmap
@@ -3331,11 +3349,14 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3331
3349
  chartColors.fillColor,
3332
3350
  chartColors.gridColor,
3333
3351
  chartColors.lineColor,
3352
+ chartColors.postMarketBgColor,
3353
+ chartColors.preMarketBgColor,
3334
3354
  chartColors.volumeDown,
3335
3355
  chartColors.volumeUp,
3336
3356
  compact,
3337
3357
  effectiveRenderer,
3338
3358
  indicatorRenderKey,
3359
+ marketSessionKey,
3339
3360
  nativeBaseScene,
3340
3361
  nativeBaseSurfaceId,
3341
3362
  nativeSurfaceManager,
@@ -3829,9 +3850,10 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3829
3850
  colors: chartColors,
3830
3851
  timeAxisDates,
3831
3852
  indicators: canvasIndicators,
3853
+ marketSession,
3832
3854
  })
3833
3855
  : null
3834
- ), [axisMode, canvasIndicators, canvasProjection, chartColors, chartHeight, compact, showVolume, timeAxisDates, volumeHeight]);
3856
+ ), [axisMode, canvasIndicators, canvasProjection, chartColors, chartHeight, compact, marketSession, showVolume, timeAxisDates, volumeHeight]);
3835
3857
 
3836
3858
  const canvasBaseBitmapKey = useMemo(() => {
3837
3859
  if (!canvasBitmapSize || !canvasProjection || !hasHistory || isBlockingBody || bodyMessage) return null;
@@ -3850,8 +3872,11 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3850
3872
  chartColors.volumeDown,
3851
3873
  chartColors.candleUp,
3852
3874
  chartColors.candleDown,
3875
+ chartColors.preMarketBgColor,
3876
+ chartColors.postMarketBgColor,
3853
3877
  ].join(","),
3854
3878
  canvasIndicatorRenderKey,
3879
+ marketSessionKey,
3855
3880
  );
3856
3881
  }, [
3857
3882
  bodyMessage,
@@ -3863,11 +3888,14 @@ export const ResolvedStockChart = memo(function ResolvedStockChart({
3863
3888
  chartColors.fillColor,
3864
3889
  chartColors.gridColor,
3865
3890
  chartColors.lineColor,
3891
+ chartColors.postMarketBgColor,
3892
+ chartColors.preMarketBgColor,
3866
3893
  chartColors.volumeDown,
3867
3894
  chartColors.volumeUp,
3868
3895
  compact,
3869
3896
  hasHistory,
3870
3897
  isBlockingBody,
3898
+ marketSessionKey,
3871
3899
  showVolume,
3872
3900
  ]);
3873
3901
 
@@ -23,6 +23,51 @@ function clamp(value: number, min: number, max: number): number {
23
23
  return Math.max(min, Math.min(max, value));
24
24
  }
25
25
 
26
+ function normalizeDate(value: Date | string | number): Date | null {
27
+ const date = value instanceof Date ? value : new Date(value);
28
+ return Number.isFinite(date.getTime()) ? date : null;
29
+ }
30
+
31
+ export function resolveCursorDateFromAxis({
32
+ dates,
33
+ width,
34
+ cursorColumn,
35
+ cursorPixelX,
36
+ cellWidthPx,
37
+ }: {
38
+ dates: Array<Date | string | number>;
39
+ width: number;
40
+ cursorColumn: number | null;
41
+ cursorPixelX?: number | null;
42
+ cellWidthPx?: number | null;
43
+ }): Date | null {
44
+ const normalizedDates = dates
45
+ .map(normalizeDate)
46
+ .filter((date): date is Date => date !== null);
47
+ if (normalizedDates.length === 0) return null;
48
+ if (normalizedDates.length === 1) return normalizedDates[0]!;
49
+
50
+ let ratio: number | null = null;
51
+ if (
52
+ cursorPixelX !== null
53
+ && cursorPixelX !== undefined
54
+ && Number.isFinite(cursorPixelX)
55
+ && cellWidthPx !== null
56
+ && cellWidthPx !== undefined
57
+ && Number.isFinite(cellWidthPx)
58
+ && cellWidthPx > 0
59
+ ) {
60
+ const pixelWidth = Math.max(width * cellWidthPx, 1);
61
+ ratio = clamp(cursorPixelX / Math.max(pixelWidth - 1, 1), 0, 1);
62
+ } else if (cursorColumn !== null && Number.isFinite(cursorColumn) && width > 1) {
63
+ ratio = clamp(cursorColumn / Math.max(width - 1, 1), 0, 1);
64
+ }
65
+
66
+ if (ratio === null) return null;
67
+ const index = Math.round(ratio * (normalizedDates.length - 1));
68
+ return normalizedDates[clamp(index, 0, normalizedDates.length - 1)] ?? null;
69
+ }
70
+
26
71
  export function buildCursorTimeAxisOverlay({
27
72
  segments,
28
73
  width,
@@ -61,13 +106,22 @@ export function TimeAxisLabel({
61
106
  cursorColor,
62
107
  }: TimeAxisLabelProps) {
63
108
  const { cellWidthPx = 8, fractionalViewport = false } = useUiCapabilities();
109
+ const resolvedCursorDate = useMemo(() => {
110
+ return resolveCursorDateFromAxis({
111
+ dates,
112
+ width,
113
+ cursorColumn,
114
+ cursorPixelX: fractionalViewport ? cursorPixelX : null,
115
+ cellWidthPx,
116
+ }) ?? cursorDate;
117
+ }, [cellWidthPx, cursorColumn, cursorDate, cursorPixelX, dates, fractionalViewport, width]);
64
118
  const segments = useMemo(() => buildCursorTimeAxisSegments({
65
119
  timeLabels,
66
120
  width,
67
121
  cursorColumn,
68
- cursorDate,
122
+ cursorDate: resolvedCursorDate,
69
123
  dates,
70
- }), [cursorColumn, cursorDate, dates, timeLabels, width]);
124
+ }), [cursorColumn, dates, resolvedCursorDate, timeLabels, width]);
71
125
  const overlay = useMemo(() => buildCursorTimeAxisOverlay({
72
126
  segments,
73
127
  width,
@@ -1276,7 +1276,7 @@ describe("CommandBar", () => {
1276
1276
  );
1277
1277
 
1278
1278
  await testSetup.renderOnce();
1279
- const frame = await waitForFrameToContain("DES");
1279
+ const frame = await waitForFrameToContain("Exact Match");
1280
1280
  const resultLines = frame
1281
1281
  .split("\n")
1282
1282
  .filter((line) => /\b(AMD|AMDL|AMUU|DES)\b/.test(line) && /\b(NASDAQ|ASX)\b/.test(line));
@@ -4000,7 +4000,11 @@ export function CommandBar({
4000
4000
  rootResultModel.items,
4001
4001
  rootTickerSearchArg,
4002
4002
  ]);
4003
- const orderedRootResults = useMemo(() => orderListResults(rootResults), [rootResults]);
4003
+ const rootSectionOrder = rootPlainTickerSearchArg ? "app-first" : "default";
4004
+ const orderedRootResults = useMemo(
4005
+ () => orderListResults(rootResults, { sectionOrder: rootSectionOrder }),
4006
+ [rootResults, rootSectionOrder],
4007
+ );
4004
4008
  const activeRootProviderResultsKey = useMemo(() => {
4005
4009
  if (!rootTickerSearchArg || rootProviderResultsQuery !== rootTickerSearchArg || !rootProviderResults) return null;
4006
4010
  return [
@@ -4316,6 +4320,7 @@ export function CommandBar({
4316
4320
  emptyDetail: emptyState.detail,
4317
4321
  footerLeft: getScreenFooterLeft(null),
4318
4322
  footerRight: getScreenFooterRight(null),
4323
+ sectionOrder: rootSectionOrder,
4319
4324
  };
4320
4325
  }
4321
4326
 
@@ -4463,6 +4468,7 @@ export function CommandBar({
4463
4468
  rootModeInfo.kind,
4464
4469
  rootQuery,
4465
4470
  orderedRootResults,
4471
+ rootSectionOrder,
4466
4472
  rootSearching,
4467
4473
  rootSelectedIdx,
4468
4474
  rootShortcutIntent,
@@ -1,4 +1,4 @@
1
- import { buildSections } from "./view-model";
1
+ import { buildSections, type CommandBarSectionOrder } from "./view-model";
2
2
 
3
3
  export interface ResultItem {
4
4
  id: string;
@@ -31,6 +31,7 @@ export interface ListScreenState {
31
31
  emptyDetail: string;
32
32
  footerLeft: string;
33
33
  footerRight: string;
34
+ sectionOrder?: CommandBarSectionOrder;
34
35
  }
35
36
 
36
37
  export type CommandBarListRow =
@@ -41,13 +42,16 @@ export type CommandBarListRow =
41
42
  | { kind: "spinner"; id: string; label: string }
42
43
  | { kind: "filler"; id: string };
43
44
 
44
- export function orderListResults(results: ResultItem[]): ResultItem[] {
45
- return buildSections(results).flatMap((section) => section.items);
45
+ export function orderListResults(
46
+ results: ResultItem[],
47
+ options?: { sectionOrder?: CommandBarSectionOrder },
48
+ ): ResultItem[] {
49
+ return buildSections(results, options).flatMap((section) => section.items);
46
50
  }
47
51
 
48
52
  export function buildListRows(listState: ListScreenState): CommandBarListRow[] {
49
53
  const rows: CommandBarListRow[] = [];
50
- const sections = buildSections(listState.results);
54
+ const sections = buildSections(listState.results, { sectionOrder: listState.sectionOrder });
51
55
  let globalIdx = 0;
52
56
  sections.forEach((section, sectionIndex) => {
53
57
  if (sectionIndex > 0) {
@@ -47,6 +47,26 @@ describe("command bar view model helpers", () => {
47
47
  expect(sections.map((section) => section.category)).toEqual(["Tickers", "Config", "Danger", "Debug"]);
48
48
  });
49
49
 
50
+ test("keeps provider ticker suggestions behind app sections in app-first order", () => {
51
+ const sections = buildSections([
52
+ { id: "pane", category: "Panes" },
53
+ { id: "primary", category: "Primary Listing" },
54
+ { id: "other", category: "Other Listings" },
55
+ { id: "fund", category: "Funds & Derivatives" },
56
+ { id: "saved", category: "Saved" },
57
+ { id: "exact", category: "Exact Match" },
58
+ ], { sectionOrder: "app-first" });
59
+
60
+ expect(sections.map((section) => section.category)).toEqual([
61
+ "Exact Match",
62
+ "Saved",
63
+ "Panes",
64
+ "Primary Listing",
65
+ "Other Listings",
66
+ "Funds & Derivatives",
67
+ ]);
68
+ });
69
+
50
70
  test("derives row presentation for toggles and current rows", () => {
51
71
  expect(getRowPresentation({
52
72
  id: "plugin:news",
@@ -15,6 +15,8 @@ export interface CommandBarSection<T> {
15
15
  items: T[];
16
16
  }
17
17
 
18
+ export type CommandBarSectionOrder = "default" | "app-first";
19
+
18
20
  export interface CommandBarItemView {
19
21
  id: string;
20
22
  label: string;
@@ -59,7 +61,10 @@ export function resolveCommandBarMode(query: string, commandList?: Command[]): C
59
61
  }
60
62
  }
61
63
 
62
- export function buildSections<T extends { category: string }>(items: T[]): Array<CommandBarSection<T>> {
64
+ export function buildSections<T extends { category: string }>(
65
+ items: T[],
66
+ options?: { sectionOrder?: CommandBarSectionOrder },
67
+ ): Array<CommandBarSection<T>> {
63
68
  const sections: Array<CommandBarSection<T>> = [];
64
69
  for (const item of items) {
65
70
  let section = sections.find((candidate) => candidate.category === item.category);
@@ -72,7 +77,9 @@ export function buildSections<T extends { category: string }>(items: T[]): Array
72
77
  return sections
73
78
  .map((section, index) => ({ section, index }))
74
79
  .sort((a, b) => {
75
- const priorityDiff = getCategoryPriority(a.section.category) - getCategoryPriority(b.section.category);
80
+ const leftPriority = getCategoryPriority(a.section.category, options?.sectionOrder);
81
+ const rightPriority = getCategoryPriority(b.section.category, options?.sectionOrder);
82
+ const priorityDiff = leftPriority - rightPriority;
76
83
  return priorityDiff !== 0 ? priorityDiff : a.index - b.index;
77
84
  })
78
85
  .map(({ section }) => section);
@@ -126,10 +133,15 @@ export function truncateText(text: string, width: number): string {
126
133
  return `${text.slice(0, width - 3)}...`;
127
134
  }
128
135
 
129
- function getCategoryPriority(category: string): number {
136
+ function getCategoryPriority(category: string, sectionOrder: CommandBarSectionOrder = "default"): number {
130
137
  const normalized = category.trim().toLowerCase();
131
138
  if (normalized === "exact match") return -50;
132
139
  if (normalized === "saved") return -40;
140
+ if (sectionOrder === "app-first") {
141
+ if (normalized === "primary listing") return 100;
142
+ if (normalized === "other listings") return 110;
143
+ if (normalized === "funds & derivatives") return 120;
144
+ }
133
145
  if (normalized === "primary listing") return -30;
134
146
  if (normalized === "other listings") return -20;
135
147
  if (normalized === "funds & derivatives") return -10;
@@ -56,17 +56,6 @@ const FLASHABLE_QUOTE_COLUMN_IDS = new Set([
56
56
  "pnl_pct",
57
57
  ]);
58
58
 
59
- function resolveQuoteFlashColor(direction: QuoteFlashDirection, fallbackColor: string): string {
60
- switch (direction) {
61
- case "up":
62
- return colors.positive;
63
- case "down":
64
- return colors.negative;
65
- default:
66
- return fallbackColor === colors.textDim ? colors.text : colors.textBright;
67
- }
68
- }
69
-
70
59
  export interface TickerListTableProps {
71
60
  columns: ColumnConfig[];
72
61
  tickers: TickerRecord[];
@@ -239,13 +228,13 @@ const TickerListRow = memo(function TickerListRow({
239
228
  const { text, color } = resolveCell(column, ticker, financials);
240
229
  const baseFg = color || (isSelected ? colors.selectedText : colors.text);
241
230
  const shouldFlash = flashDirection != null && FLASHABLE_QUOTE_COLUMN_IDS.has(column.id);
242
- const cellFg = shouldFlash
243
- ? resolveQuoteFlashColor(flashDirection, baseFg)
244
- : baseFg;
245
231
 
246
232
  return (
247
233
  <Box key={column.id} width={column.width + 1}>
248
- <Text fg={cellFg}>
234
+ <Text
235
+ fg={baseFg}
236
+ attributes={shouldFlash ? TextAttributes.DIM : TextAttributes.NONE}
237
+ >
249
238
  {padTo(text, column.width, column.align)}
250
239
  </Text>
251
240
  </Box>
@@ -73,6 +73,23 @@ function targetFromTicker(ticker: TickerRecord): HydrationTarget {
73
73
  });
74
74
  }
75
75
 
76
+ function resolvePortfolioPaneCollectionId(
77
+ config: AppConfig,
78
+ paneState: Record<string, unknown> | undefined,
79
+ instance: AppConfig["layout"]["instances"][number],
80
+ ): string | null {
81
+ const stateCollectionId = typeof paneState?.collectionId === "string" ? paneState.collectionId : null;
82
+ if (stateCollectionId) return stateCollectionId;
83
+ if (typeof instance.params?.collectionId === "string") return instance.params.collectionId;
84
+ return config.portfolios[0]?.id ?? config.watchlists[0]?.id ?? null;
85
+ }
86
+
87
+ function tickerInCollection(ticker: TickerRecord, collectionId: string | null): boolean {
88
+ if (!collectionId) return false;
89
+ return ticker.metadata.portfolios.includes(collectionId)
90
+ || ticker.metadata.watchlists.includes(collectionId);
91
+ }
92
+
76
93
  export function buildAppSessionSnapshot(state: SessionStateInput): AppSessionSnapshot {
77
94
  const seen = new Set<string>();
78
95
  const hydrationTargets: HydrationTarget[] = [];
@@ -104,6 +121,14 @@ export function buildAppSessionSnapshot(state: SessionStateInput): AppSessionSna
104
121
  if (instance.binding?.kind === "fixed") {
105
122
  pushTarget(state.tickers.get(instance.binding.symbol));
106
123
  }
124
+ if (instance.paneId === "portfolio-list") {
125
+ const collectionId = resolvePortfolioPaneCollectionId(state.config, paneState, instance);
126
+ for (const ticker of state.tickers.values()) {
127
+ if (tickerInCollection(ticker, collectionId)) {
128
+ pushTarget(ticker);
129
+ }
130
+ }
131
+ }
107
132
  }
108
133
 
109
134
  return {
@@ -43,6 +43,79 @@ async function flushCoordinator(): Promise<void> {
43
43
  }
44
44
 
45
45
  describe("MarketDataCoordinator key subscriptions", () => {
46
+ test("subscribes active quote targets as one provider batch", () => {
47
+ const subscriptions: QuoteSubscriptionTarget[][] = [];
48
+ const provider = createTestDataProvider({
49
+ id: "test-provider",
50
+ subscribeQuotes: (targets) => {
51
+ subscriptions.push(targets);
52
+ return () => {};
53
+ },
54
+ });
55
+ const coordinator = new MarketDataCoordinator(provider);
56
+
57
+ coordinator.subscribeQuotes([
58
+ {
59
+ instrument: { symbol: "AAPL", exchange: "NASDAQ" },
60
+ priority: { surface: "portfolio", visible: true, selected: true, weight: 100 },
61
+ },
62
+ { instrument: { symbol: "MSFT", exchange: "NASDAQ" } },
63
+ ]);
64
+
65
+ expect(subscriptions).toHaveLength(1);
66
+ expect(subscriptions[0]?.map((target) => target.symbol).sort()).toEqual(["AAPL", "MSFT"]);
67
+ expect(subscriptions[0]?.find((target) => target.symbol === "AAPL")).toMatchObject({
68
+ surface: "portfolio",
69
+ visible: true,
70
+ selected: true,
71
+ weight: 100,
72
+ });
73
+ });
74
+
75
+ test("keeps the highest-priority target when duplicate surfaces subscribe", () => {
76
+ const subscriptions: QuoteSubscriptionTarget[][] = [];
77
+ let disposals = 0;
78
+ const provider = createTestDataProvider({
79
+ id: "test-provider",
80
+ subscribeQuotes: (targets) => {
81
+ subscriptions.push(targets);
82
+ return () => {
83
+ disposals += 1;
84
+ };
85
+ },
86
+ });
87
+ const coordinator = new MarketDataCoordinator(provider);
88
+ const instrument = { symbol: "AAPL", exchange: "NASDAQ" };
89
+
90
+ const unsubscribeDetail = coordinator.subscribeQuotes([{
91
+ instrument,
92
+ priority: { surface: "detail", visible: true, selected: true, weight: 100 },
93
+ }]);
94
+ coordinator.subscribeQuotes([{
95
+ instrument,
96
+ priority: { surface: "portfolio", visible: false, selected: false, weight: 10 },
97
+ }]);
98
+
99
+ expect(subscriptions).toHaveLength(1);
100
+ expect(subscriptions[0]?.[0]).toMatchObject({
101
+ surface: "detail",
102
+ visible: true,
103
+ selected: true,
104
+ weight: 100,
105
+ });
106
+
107
+ unsubscribeDetail();
108
+
109
+ expect(subscriptions).toHaveLength(2);
110
+ expect(disposals).toBe(1);
111
+ expect(subscriptions[1]?.[0]).toMatchObject({
112
+ surface: "portfolio",
113
+ visible: false,
114
+ selected: false,
115
+ weight: 10,
116
+ });
117
+ });
118
+
46
119
  test("notifies listeners for changed keys only", async () => {
47
120
  const { provider, emitQuote } = createProvider();
48
121
  const coordinator = new MarketDataCoordinator(provider);
@@ -97,29 +170,40 @@ describe("MarketDataCoordinator key subscriptions", () => {
97
170
  expect(coordinator.getVersion()).toBe(1);
98
171
  });
99
172
 
100
- test("ignores repeated stream quotes that only refresh timestamp noise", async () => {
173
+ test("applies repeated stream quotes that refresh quote freshness", async () => {
174
+ const realDateNow = Date.now;
101
175
  const { provider, emitQuote } = createProvider();
102
176
  const coordinator = new MarketDataCoordinator(provider);
103
177
  const aapl = { symbol: "AAPL", exchange: "NASDAQ" };
104
178
  let calls = 0;
105
179
 
106
- coordinator.subscribeKeys([buildQuoteKey(aapl)], () => { calls += 1; });
107
- coordinator.subscribeQuotes([{ instrument: aapl }]);
108
-
109
180
  const firstTimestamp = 1_700_000_000_000;
110
- emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 100, { lastUpdated: firstTimestamp }));
111
- await flushCoordinator();
112
-
113
- emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 100, { lastUpdated: firstTimestamp + 10_000 }));
114
- await flushCoordinator();
115
-
116
- expect(calls).toBe(1);
117
- expect(coordinator.getQuoteEntry(aapl).data?.lastUpdated).toBe(firstTimestamp);
118
-
119
- emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 101, { lastUpdated: firstTimestamp + 20_000 }));
120
- await flushCoordinator();
121
-
122
- expect(calls).toBe(2);
123
- expect(coordinator.getQuoteEntry(aapl).data?.price).toBe(101);
181
+ try {
182
+ coordinator.subscribeKeys([buildQuoteKey(aapl)], () => { calls += 1; });
183
+ coordinator.subscribeQuotes([{ instrument: aapl }]);
184
+
185
+ Date.now = () => firstTimestamp;
186
+ emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 100, { lastUpdated: firstTimestamp }));
187
+ await flushCoordinator();
188
+
189
+ Date.now = () => firstTimestamp + 10_000;
190
+ emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 100, { lastUpdated: firstTimestamp + 10_000 }));
191
+ await flushCoordinator();
192
+
193
+ expect(calls).toBe(2);
194
+ expect(coordinator.getQuoteEntry(aapl).data?.lastUpdated).toBe(firstTimestamp + 10_000);
195
+ expect(coordinator.getQuoteEntry(aapl).data?.receivedAt).toBe(firstTimestamp + 10_000);
196
+
197
+ Date.now = () => firstTimestamp + 20_000;
198
+ emitQuote({ symbol: "AAPL", exchange: "NASDAQ" }, quote("AAPL", 101, { lastUpdated: firstTimestamp + 10_000 }));
199
+ await flushCoordinator();
200
+
201
+ expect(calls).toBe(3);
202
+ expect(coordinator.getQuoteEntry(aapl).data?.price).toBe(101);
203
+ expect(coordinator.getQuoteEntry(aapl).data?.lastUpdated).toBe(firstTimestamp + 10_000);
204
+ expect(coordinator.getQuoteEntry(aapl).data?.receivedAt).toBe(firstTimestamp + 20_000);
205
+ } finally {
206
+ Date.now = realDateNow;
207
+ }
124
208
  });
125
209
  });