gloomberb 0.3.7 → 0.4.0

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 (133) hide show
  1. package/README.md +19 -8
  2. package/package.json +1 -1
  3. package/src/app.tsx +29 -65
  4. package/src/cli/base-converter.ts +31 -0
  5. package/src/cli/commands/search.ts +11 -3
  6. package/src/cli/commands/ticker.ts +22 -11
  7. package/src/cli/context.ts +27 -32
  8. package/src/cli/helpers.ts +1 -4
  9. package/src/cli/index.ts +140 -123
  10. package/src/cli/registry.test.ts +226 -0
  11. package/src/cli/registry.ts +201 -0
  12. package/src/cli.test.ts +219 -0
  13. package/src/components/chart/chart-controller.ts +309 -0
  14. package/src/components/chart/chart-data.ts +5 -6
  15. package/src/components/chart/chart-pane-settings.ts +27 -0
  16. package/src/components/chart/chart-resolution.test.ts +56 -0
  17. package/src/components/chart/chart-resolution.ts +362 -0
  18. package/src/components/chart/chart-types.ts +12 -4
  19. package/src/components/chart/chart-viewport.test.ts +25 -0
  20. package/src/components/chart/chart-viewport.ts +28 -0
  21. package/src/components/chart/comparison-chart-data.ts +20 -27
  22. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  23. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  24. package/src/components/chart/stock-chart.tsx +455 -106
  25. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  26. package/src/components/command-bar/command-bar.test.tsx +650 -5
  27. package/src/components/command-bar/command-bar.tsx +359 -63
  28. package/src/components/command-bar/command-registry.ts +8 -0
  29. package/src/components/command-bar/helpers.test.ts +23 -1
  30. package/src/components/command-bar/helpers.ts +14 -5
  31. package/src/components/command-bar/pane-template-display.ts +12 -0
  32. package/src/components/command-bar/root-shortcuts.ts +2 -1
  33. package/src/components/command-bar/workflow-ops.ts +4 -2
  34. package/src/components/detail-feed-view.test.tsx +124 -49
  35. package/src/components/detail-feed-view.tsx +396 -160
  36. package/src/components/layout/floating-pane.tsx +1 -1
  37. package/src/components/layout/pane-header.tsx +26 -2
  38. package/src/components/layout/shell.tsx +62 -50
  39. package/src/components/layout/status-bar.test.tsx +66 -0
  40. package/src/components/layout/status-bar.tsx +26 -5
  41. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  42. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  43. package/src/components/ticker-list-table.test.tsx +83 -0
  44. package/src/components/ticker-list-table.tsx +174 -97
  45. package/src/components/ui/data-table.tsx +27 -2
  46. package/src/components/ui/index.ts +1 -0
  47. package/src/components/ui/page-stack-view.tsx +60 -0
  48. package/src/components/use-double-click-activation.ts +31 -0
  49. package/src/index.tsx +10 -10
  50. package/src/market-data/coordinator.test.ts +92 -4
  51. package/src/market-data/coordinator.ts +32 -5
  52. package/src/market-data/hooks.test.tsx +120 -0
  53. package/src/market-data/hooks.tsx +53 -16
  54. package/src/market-data/request-types.ts +4 -2
  55. package/src/market-data/selectors.ts +24 -58
  56. package/src/plugins/builtin/chat-controller.test.ts +370 -3
  57. package/src/plugins/builtin/chat-controller.ts +310 -35
  58. package/src/plugins/builtin/chat.test.tsx +118 -6
  59. package/src/plugins/builtin/chat.tsx +175 -116
  60. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  61. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  62. package/src/plugins/builtin/news.tsx +1 -11
  63. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  64. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  65. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  66. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  67. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  68. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  69. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  70. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  71. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  72. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  73. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  74. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  75. package/src/plugins/builtin/sec.tsx +1 -11
  76. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  77. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  78. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  79. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  80. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  81. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  82. package/src/plugins/catalog.ts +65 -0
  83. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  84. package/src/plugins/ibkr/gateway-service.test.ts +45 -1
  85. package/src/plugins/ibkr/gateway-service.ts +141 -7
  86. package/src/plugins/pane-manager.test.ts +1 -1
  87. package/src/plugins/plugin-runtime.tsx +27 -7
  88. package/src/plugins/prediction-markets/cli.ts +28 -0
  89. package/src/plugins/prediction-markets/controller-data.ts +11 -3
  90. package/src/plugins/prediction-markets/controller.ts +141 -121
  91. package/src/plugins/prediction-markets/index.tsx +2 -0
  92. package/src/plugins/prediction-markets/launch.ts +51 -16
  93. package/src/plugins/prediction-markets/metrics.ts +9 -5
  94. package/src/plugins/prediction-markets/pane.test.tsx +119 -57
  95. package/src/plugins/prediction-markets/pane.tsx +74 -153
  96. package/src/plugins/prediction-markets/table.tsx +6 -3
  97. package/src/plugins/prediction-markets/types.ts +0 -1
  98. package/src/sources/gloomberb-cloud.test.ts +29 -0
  99. package/src/sources/gloomberb-cloud.ts +89 -1
  100. package/src/sources/provider-router.test.ts +265 -3
  101. package/src/sources/provider-router.ts +239 -11
  102. package/src/sources/yahoo-finance.test.ts +26 -0
  103. package/src/sources/yahoo-finance.ts +54 -3
  104. package/src/state/app-context-selector.test.tsx +99 -0
  105. package/src/state/app-context.test.ts +18 -0
  106. package/src/state/app-context.tsx +170 -65
  107. package/src/state/selectors.ts +25 -0
  108. package/src/theme/colors.ts +8 -8
  109. package/src/theme/themes.test.ts +2 -0
  110. package/src/theme/themes.ts +22 -10
  111. package/src/types/broker.ts +21 -0
  112. package/src/types/config.ts +17 -1
  113. package/src/types/data-provider.ts +18 -0
  114. package/src/types/financials.ts +31 -1
  115. package/src/types/plugin.ts +70 -0
  116. package/src/utils/api-client.test.ts +57 -0
  117. package/src/utils/api-client.ts +35 -12
  118. package/src/utils/back-navigation.ts +27 -0
  119. package/src/utils/format.test.ts +14 -0
  120. package/src/utils/format.ts +3 -3
  121. package/src/utils/market-status.ts +46 -1
  122. package/src/utils/quote-freshness.ts +127 -0
  123. package/src/utils/quote-resolution.test.ts +277 -0
  124. package/src/utils/quote-resolution.ts +641 -0
  125. package/src/utils/slugify.ts +3 -0
  126. package/src/utils/timestamp.ts +25 -0
  127. package/src/version.ts +1 -1
  128. package/src/cli/commands/portfolio.ts +0 -210
  129. package/src/cli/commands/watchlist.ts +0 -210
  130. package/src/plugins/builtin/data-providers.ts +0 -16
  131. package/src/sources/exchange-rates.ts +0 -18
  132. package/src/utils/ascii-chart.ts +0 -172
  133. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -1,10 +1,13 @@
1
1
  import {
2
2
  createContext,
3
+ useCallback,
3
4
  useContext,
4
5
  useEffect,
6
+ useLayoutEffect,
5
7
  useMemo,
6
8
  useReducer,
7
9
  useRef,
10
+ useSyncExternalStore,
8
11
  type SetStateAction,
9
12
  type ReactNode,
10
13
  } from "react";
@@ -12,7 +15,7 @@ import type { SessionStore } from "../data/session-store";
12
15
  import { saveConfig } from "../data/config-store";
13
16
  import { applyTheme } from "../theme/colors";
14
17
  import { isBrokerPortfolioId } from "../utils/broker-instances";
15
- import { hasLikelyQuoteUnitMismatch } from "../utils/currency-units";
18
+ import { resolveTickerFinancialsQuoteState } from "../utils/quote-resolution";
16
19
  import {
17
20
  cloneLayout,
18
21
  DEFAULT_LAYOUT,
@@ -146,10 +149,6 @@ function shouldPreserveUnknownCollectionId(collectionId: string | undefined): bo
146
149
  return isBrokerPortfolioId(collectionId);
147
150
  }
148
151
 
149
- function hasLikelyPriceUnitMismatch(current: Quote | undefined, next: Quote): boolean {
150
- return hasLikelyQuoteUnitMismatch(current, next);
151
- }
152
-
153
152
  function getConfiguredCollectionId(config: AppConfig, instance: PaneInstanceConfig): string {
154
153
  const candidates = [
155
154
  instance.params?.collectionId,
@@ -287,12 +286,6 @@ function nextRecentTickers(current: string[], symbol: string | null): string[] {
287
286
  return [symbol, ...current.filter((entry) => entry !== symbol)].slice(0, 50);
288
287
  }
289
288
 
290
- function shouldPreserveExistingQuote(current: Quote | undefined, next: Quote): boolean {
291
- return current?.dataSource === "live"
292
- && current.providerId !== "gloomberb-cloud"
293
- && next.providerId === "gloomberb-cloud";
294
- }
295
-
296
289
  function syncLayouts(layouts: SavedLayout[], activeLayoutIndex: number, layout: LayoutConfig): SavedLayout[] {
297
290
  return layouts.map((savedLayout, index) => (
298
291
  index === activeLayoutIndex ? { ...savedLayout, layout: cloneLayout(layout) } : savedLayout
@@ -392,6 +385,20 @@ function removeHistoryIndex(layoutHistory: Record<number, LayoutHistoryEntry>, r
392
385
  return next;
393
386
  }
394
387
 
388
+ /** If paneId is a floating pane, bump its zIndex to the top. */
389
+ function bringFloatingToFront(layout: LayoutConfig, paneId: string): LayoutConfig {
390
+ const entry = layout.floating.find((e) => e.instanceId === paneId);
391
+ if (!entry) return layout;
392
+ const maxZ = layout.floating.reduce((max, e) => Math.max(max, e.zIndex ?? 50), 0);
393
+ if ((entry.zIndex ?? 50) >= maxZ) return layout; // already on top
394
+ return {
395
+ ...layout,
396
+ floating: layout.floating.map((e) =>
397
+ e.instanceId === paneId ? { ...e, zIndex: maxZ + 1 } : e,
398
+ ),
399
+ };
400
+ }
401
+
395
402
  function withFocusedPane(state: AppState, config: AppConfig): AppState {
396
403
  const normalizedLayout = normalizePaneLayout(config.layout);
397
404
  const nextConfig = normalizedLayout === config.layout
@@ -451,30 +458,14 @@ export function appReducer(state: AppState, action: AppAction): AppState {
451
458
 
452
459
  case "SET_FINANCIALS": {
453
460
  const financials = new Map(state.financials);
454
- financials.set(action.symbol, action.data);
461
+ financials.set(action.symbol, resolveTickerFinancialsQuoteState(action.data)!);
455
462
  return { ...state, financials };
456
463
  }
457
464
 
458
465
  case "MERGE_QUOTE": {
459
- const current = state.financials.get(action.symbol);
460
- if (
461
- shouldPreserveExistingQuote(current?.quote, action.quote)
462
- || hasLikelyPriceUnitMismatch(current?.quote, action.quote)
463
- ) {
464
- return state;
465
- }
466
466
  const financials = new Map(state.financials);
467
- financials.set(action.symbol, {
468
- annualStatements: current?.annualStatements ?? [],
469
- quarterlyStatements: current?.quarterlyStatements ?? [],
470
- priceHistory: current?.priceHistory ?? [],
471
- fundamentals: current?.fundamentals,
472
- profile: current?.profile,
473
- quote: {
474
- ...(current?.quote ?? {}),
475
- ...action.quote,
476
- },
477
- });
467
+ const current = state.financials.get(action.symbol);
468
+ financials.set(action.symbol, resolveTickerFinancialsQuoteState(current, action.quote)!);
478
469
  return { ...state, financials };
479
470
  }
480
471
 
@@ -482,7 +473,7 @@ export function appReducer(state: AppState, action: AppAction): AppState {
482
473
  if (action.financials.size === 0) return state;
483
474
  const financials = new Map(state.financials);
484
475
  for (const [symbol, data] of action.financials) {
485
- financials.set(symbol, data);
476
+ financials.set(symbol, resolveTickerFinancialsQuoteState(data)!);
486
477
  }
487
478
  return { ...state, financials };
488
479
  }
@@ -724,19 +715,30 @@ export function appReducer(state: AppState, action: AppAction): AppState {
724
715
  });
725
716
  }
726
717
 
727
- case "FOCUS_PANE":
718
+ case "FOCUS_PANE": {
719
+ const layout = bringFloatingToFront(state.config.layout, action.paneId);
720
+ const config = layout !== state.config.layout
721
+ ? { ...state.config, layout, layouts: syncLayouts(state.config.layouts, state.config.activeLayoutIndex, layout) }
722
+ : state.config;
728
723
  return {
729
724
  ...state,
725
+ config,
730
726
  focusedPaneId: action.paneId,
731
727
  recentTickers: nextRecentTickers(state.recentTickers, resolveTickerForPane(state, action.paneId)),
732
728
  };
729
+ }
733
730
 
734
731
  case "FOCUS_NEXT": {
735
732
  if (action.paneOrder.length === 0) return state;
736
733
  const currentIndex = state.focusedPaneId ? action.paneOrder.indexOf(state.focusedPaneId) : -1;
737
734
  const nextPaneId = action.paneOrder[(currentIndex + 1) % action.paneOrder.length]!;
735
+ const layout = bringFloatingToFront(state.config.layout, nextPaneId);
736
+ const config = layout !== state.config.layout
737
+ ? { ...state.config, layout, layouts: syncLayouts(state.config.layouts, state.config.activeLayoutIndex, layout) }
738
+ : state.config;
738
739
  return {
739
740
  ...state,
741
+ config,
740
742
  focusedPaneId: nextPaneId,
741
743
  recentTickers: nextRecentTickers(state.recentTickers, resolveTickerForPane(state, nextPaneId)),
742
744
  };
@@ -746,8 +748,13 @@ export function appReducer(state: AppState, action: AppAction): AppState {
746
748
  if (action.paneOrder.length === 0) return state;
747
749
  const currentIndex = state.focusedPaneId ? action.paneOrder.indexOf(state.focusedPaneId) : 0;
748
750
  const nextPaneId = action.paneOrder[(currentIndex - 1 + action.paneOrder.length) % action.paneOrder.length]!;
751
+ const layout = bringFloatingToFront(state.config.layout, nextPaneId);
752
+ const config = layout !== state.config.layout
753
+ ? { ...state.config, layout, layouts: syncLayouts(state.config.layouts, state.config.activeLayoutIndex, layout) }
754
+ : state.config;
749
755
  return {
750
756
  ...state,
757
+ config,
751
758
  focusedPaneId: nextPaneId,
752
759
  recentTickers: nextRecentTickers(state.recentTickers, resolveTickerForPane(state, nextPaneId)),
753
760
  };
@@ -755,6 +762,9 @@ export function appReducer(state: AppState, action: AppAction): AppState {
755
762
 
756
763
  case "UPDATE_PANE_STATE": {
757
764
  const current = state.paneState[action.paneId] ?? {};
765
+ if (Object.keys(action.patch).every((key) => Object.is(current[key], action.patch[key]))) {
766
+ return state;
767
+ }
758
768
  const nextState = { ...current, ...action.patch };
759
769
  const recentTickers = Object.prototype.hasOwnProperty.call(action.patch, "cursorSymbol")
760
770
  ? nextRecentTickers(state.recentTickers, typeof nextState.cursorSymbol === "string" ? nextState.cursorSymbol : null)
@@ -799,20 +809,81 @@ export function appReducer(state: AppState, action: AppAction): AppState {
799
809
  }
800
810
  }
801
811
 
802
- interface AppContextValue {
812
+ interface AppContextStoreValue {
813
+ dispatch: React.Dispatch<AppAction>;
814
+ getState: () => AppState;
815
+ subscribe: (listener: () => void) => () => void;
816
+ }
817
+
818
+ interface AppContextLegacyValue {
803
819
  state: AppState;
804
820
  dispatch: React.Dispatch<AppAction>;
805
821
  }
806
822
 
823
+ type AppContextValue = AppContextStoreValue | AppContextLegacyValue;
824
+
807
825
  export const AppContext = createContext<AppContextValue | null>(null);
808
826
  const PaneContext = createContext<string | null>(null);
809
827
 
810
- export function useAppState(): AppContextValue {
828
+ function isAppStoreContextValue(context: AppContextValue): context is AppContextStoreValue {
829
+ return "getState" in context && "subscribe" in context;
830
+ }
831
+
832
+ function useRequiredAppContext(): AppContextValue {
811
833
  const context = useContext(AppContext);
812
834
  if (!context) throw new Error("useAppState must be used within AppProvider");
813
835
  return context;
814
836
  }
815
837
 
838
+ export function useAppState(): AppContextLegacyValue {
839
+ const context = useRequiredAppContext();
840
+ if (!isAppStoreContextValue(context)) {
841
+ return context;
842
+ }
843
+
844
+ const state = useSyncExternalStore(context.subscribe, context.getState, context.getState);
845
+ return useMemo(() => ({ state, dispatch: context.dispatch }), [context.dispatch, state]);
846
+ }
847
+
848
+ export function useAppDispatch(): React.Dispatch<AppAction> {
849
+ return useRequiredAppContext().dispatch;
850
+ }
851
+
852
+ export function useAppSelector<T>(selector: (state: AppState) => T): T {
853
+ const context = useRequiredAppContext();
854
+ const selectorRef = useRef(selector);
855
+ selectorRef.current = selector;
856
+ const lastSnapshotRef = useRef<{ selection: T; themeId: string } | undefined>(undefined);
857
+
858
+ if (!isAppStoreContextValue(context)) {
859
+ return selector(context.state);
860
+ }
861
+
862
+ const snapshot = useSyncExternalStore(
863
+ context.subscribe,
864
+ () => {
865
+ const state = context.getState();
866
+ const selection = selectorRef.current(state);
867
+ const themeId = state.config.theme;
868
+ const previous = lastSnapshotRef.current;
869
+ if (previous && Object.is(previous.selection, selection) && previous.themeId === themeId) {
870
+ return previous;
871
+ }
872
+ const nextSnapshot = { selection, themeId };
873
+ lastSnapshotRef.current = nextSnapshot;
874
+ return nextSnapshot;
875
+ },
876
+ () => {
877
+ const state = context.getState();
878
+ return {
879
+ selection: selectorRef.current(state),
880
+ themeId: state.config.theme,
881
+ };
882
+ },
883
+ );
884
+ return snapshot.selection;
885
+ }
886
+
816
887
  export function PaneInstanceProvider({ paneId, children }: { paneId: string; children: ReactNode }) {
817
888
  return <PaneContext value={paneId}>{children}</PaneContext>;
818
889
  }
@@ -824,20 +895,23 @@ export function usePaneInstanceId(): string {
824
895
  }
825
896
 
826
897
  export function usePaneInstance(): PaneInstanceConfig | null {
827
- const { state } = useAppState();
828
898
  const paneId = useContext(PaneContext);
829
- return paneId ? findPaneInstance(state.config.layout, paneId) ?? null : null;
899
+ return useAppSelector((state) => (paneId ? findPaneInstance(state.config.layout, paneId) ?? null : null));
830
900
  }
831
901
 
832
902
  export function usePaneTicker(paneId?: string) {
833
- const { state } = useAppState();
834
- const scopedPaneId = paneId ?? useContext(PaneContext) ?? state.focusedPaneId;
835
- const symbol = useMemo(() => (
903
+ const paneContextId = useContext(PaneContext);
904
+ const focusedPaneId = useAppSelector((state) => state.focusedPaneId);
905
+ const scopedPaneId = paneId ?? paneContextId ?? focusedPaneId;
906
+ const symbol = useAppSelector((state) => (
836
907
  scopedPaneId ? resolveTickerForPane(state, scopedPaneId) : null
837
- ), [state, scopedPaneId]);
838
- const ticker = useMemo(() => (
908
+ ));
909
+ const ticker = useAppSelector((state) => (
839
910
  symbol ? state.tickers.get(symbol) ?? null : null
840
- ), [state.tickers, symbol]);
911
+ ));
912
+ const cachedFinancials = useAppSelector((state) => (
913
+ symbol ? state.financials.get(symbol) ?? null : null
914
+ ));
841
915
  const marketFinancials = useTickerFinancials(symbol, ticker);
842
916
 
843
917
  return useMemo(() => {
@@ -845,9 +919,9 @@ export function usePaneTicker(paneId?: string) {
845
919
  return {
846
920
  symbol,
847
921
  ticker,
848
- financials: marketFinancials ?? (symbol ? state.financials.get(symbol) ?? null : null),
922
+ financials: marketFinancials ?? cachedFinancials,
849
923
  };
850
- }, [marketFinancials, scopedPaneId, state.financials, symbol, ticker]);
924
+ }, [cachedFinancials, marketFinancials, scopedPaneId, symbol, ticker]);
851
925
  }
852
926
 
853
927
  export function useSelectedTicker(paneId?: string) {
@@ -855,40 +929,46 @@ export function useSelectedTicker(paneId?: string) {
855
929
  }
856
930
 
857
931
  export function useFocusedTicker() {
858
- const { state } = useAppState();
859
- const symbol = useMemo(() => getFocusedTickerSymbol(state), [state]);
860
- const ticker = useMemo(() => (symbol ? state.tickers.get(symbol) ?? null : null), [state.tickers, symbol]);
932
+ const symbol = useAppSelector((state) => getFocusedTickerSymbol(state));
933
+ const ticker = useAppSelector((state) => (symbol ? state.tickers.get(symbol) ?? null : null));
934
+ const cachedFinancials = useAppSelector((state) => (symbol ? state.financials.get(symbol) ?? null : null));
861
935
  const marketFinancials = useTickerFinancials(symbol, ticker);
862
936
  return useMemo(() => {
863
937
  return {
864
938
  symbol,
865
939
  ticker,
866
- financials: marketFinancials ?? (symbol ? state.financials.get(symbol) ?? null : null),
940
+ financials: marketFinancials ?? cachedFinancials,
867
941
  };
868
- }, [marketFinancials, state.financials, symbol, ticker]);
942
+ }, [cachedFinancials, marketFinancials, symbol, ticker]);
869
943
  }
870
944
 
871
945
  export function usePaneCollection(paneId?: string) {
872
- const { state } = useAppState();
873
- const scopedPaneId = paneId ?? useContext(PaneContext) ?? state.focusedPaneId;
874
- return useMemo(() => {
875
- if (!scopedPaneId) return { collectionId: null, portfolio: null, watchlist: null };
876
- const collectionId = resolveCollectionForPane(state, scopedPaneId);
877
- return {
878
- collectionId,
879
- portfolio: collectionId ? state.config.portfolios.find((portfolio) => portfolio.id === collectionId) ?? null : null,
880
- watchlist: collectionId ? state.config.watchlists.find((watchlist) => watchlist.id === collectionId) ?? null : null,
881
- };
882
- }, [state, scopedPaneId]);
946
+ const paneContextId = useContext(PaneContext);
947
+ const focusedPaneId = useAppSelector((state) => state.focusedPaneId);
948
+ const scopedPaneId = paneId ?? paneContextId ?? focusedPaneId;
949
+ const collectionId = useAppSelector((state) => (
950
+ scopedPaneId ? resolveCollectionForPane(state, scopedPaneId) : null
951
+ ));
952
+ const portfolio = useAppSelector((state) => (
953
+ collectionId ? state.config.portfolios.find((entry) => entry.id === collectionId) ?? null : null
954
+ ));
955
+ const watchlist = useAppSelector((state) => (
956
+ collectionId ? state.config.watchlists.find((entry) => entry.id === collectionId) ?? null : null
957
+ ));
958
+ return useMemo(() => (
959
+ { collectionId, portfolio, watchlist }
960
+ ), [collectionId, portfolio, watchlist]);
883
961
  }
884
962
 
885
963
  export function usePaneStateValue<T>(key: string, fallback: T, paneId?: string): [T, (value: T) => void] {
886
- const { state, dispatch } = useAppState();
964
+ const dispatch = useAppDispatch();
887
965
  const scopedPaneId = paneId ?? usePaneInstanceId();
888
- const value = (state.paneState[scopedPaneId]?.[key] as T | undefined) ?? fallback;
889
- const setValue = (nextValue: T) => {
966
+ const value = useAppSelector((state) => (
967
+ (state.paneState[scopedPaneId]?.[key] as T | undefined) ?? fallback
968
+ ));
969
+ const setValue = useCallback((nextValue: T) => {
890
970
  dispatch({ type: "UPDATE_PANE_STATE", paneId: scopedPaneId, patch: { [key]: nextValue } });
891
- };
971
+ }, [dispatch, key, scopedPaneId]);
892
972
  return [value, setValue];
893
973
  }
894
974
 
@@ -967,6 +1047,24 @@ export function AppProvider({
967
1047
  createInitialState(config, initialSessionSnapshot)
968
1048
  ));
969
1049
  const previousRecentTickers = useRef(state.recentTickers);
1050
+ const stateRef = useRef(state);
1051
+ const listenersRef = useRef(new Set<() => void>());
1052
+ const storeRef = useRef<AppContextStoreValue | null>(null);
1053
+
1054
+ stateRef.current = state;
1055
+
1056
+ if (!storeRef.current) {
1057
+ storeRef.current = {
1058
+ dispatch,
1059
+ getState: () => stateRef.current,
1060
+ subscribe: (listener) => {
1061
+ listenersRef.current.add(listener);
1062
+ return () => {
1063
+ listenersRef.current.delete(listener);
1064
+ };
1065
+ },
1066
+ };
1067
+ }
970
1068
 
971
1069
  useEffect(() => {
972
1070
  if (previousRecentTickers.current !== state.recentTickers) {
@@ -975,7 +1073,14 @@ export function AppProvider({
975
1073
  }
976
1074
  }, [state.config, state.recentTickers]);
977
1075
 
1076
+ useLayoutEffect(() => {
1077
+ stateRef.current = state;
1078
+ for (const listener of listenersRef.current) {
1079
+ listener();
1080
+ }
1081
+ }, [state]);
1082
+
978
1083
  usePersistSessionSnapshot(sessionStore, state, APP_SESSION_ID, APP_SESSION_SCHEMA_VERSION);
979
1084
 
980
- return <AppContext value={{ state, dispatch }}>{children}</AppContext>;
1085
+ return <AppContext value={storeRef.current}>{children}</AppContext>;
981
1086
  }
@@ -35,6 +35,31 @@ export function getCollectionTickers(state: AppState, collectionId: string | nul
35
35
  return [];
36
36
  }
37
37
 
38
+ export function getCollectionTickerCount(state: AppState, collectionId: string | null): number {
39
+ if (!collectionId) return 0;
40
+
41
+ let count = 0;
42
+ if (state.config.portfolios.some((portfolio) => portfolio.id === collectionId)) {
43
+ for (const ticker of state.tickers.values()) {
44
+ if (ticker.metadata.portfolios.includes(collectionId)) {
45
+ count += 1;
46
+ }
47
+ }
48
+ return count;
49
+ }
50
+
51
+ if (state.config.watchlists.some((watchlist) => watchlist.id === collectionId)) {
52
+ for (const ticker of state.tickers.values()) {
53
+ if (ticker.metadata.watchlists.includes(collectionId)) {
54
+ count += 1;
55
+ }
56
+ }
57
+ return count;
58
+ }
59
+
60
+ return 0;
61
+ }
62
+
38
63
  export function getCollectionName(state: AppState, collectionId: string | null): string {
39
64
  if (!collectionId) return "";
40
65
  const portfolio = state.config.portfolios.find((entry) => entry.id === collectionId);
@@ -86,26 +86,26 @@ export function commandBarSelectedText(): string {
86
86
 
87
87
  /** Background for docked pane bodies */
88
88
  export function paneBg(focused: boolean): string {
89
- if (focused) return colors.bg;
90
- return colors.panel;
89
+ if (focused) return blendHex(colors.bg, colors.borderFocused, 0.06);
90
+ return blendHex(colors.panel, colors.border, 0.08);
91
91
  }
92
92
 
93
93
  /** Background for floating pane bodies — elevated above docked panes */
94
94
  export function floatingPaneBg(focused: boolean): string {
95
- if (focused) return blendHex(colors.bg, colors.border, 0.12);
96
- return blendHex(colors.panel, colors.border, 0.35);
95
+ if (focused) return blendHex(colors.bg, colors.borderFocused, 0.08);
96
+ return blendHex(colors.panel, colors.border, 0.18);
97
97
  }
98
98
 
99
99
  /** Background for pane title bars */
100
100
  export function paneTitleBg(focused: boolean): string {
101
- if (focused) return blendHex(colors.bg, colors.borderFocused, 0.15);
102
- return blendHex(colors.panel, colors.border, 0.25);
101
+ if (focused) return blendHex(colors.bg, colors.borderFocused, 0.22);
102
+ return blendHex(colors.panel, colors.border, 0.15);
103
103
  }
104
104
 
105
105
  /** Background for floating pane title bars */
106
106
  export function floatingPaneTitleBg(focused: boolean): string {
107
- if (focused) return blendHex(colors.bg, colors.borderFocused, 0.18);
108
- return blendHex(colors.panel, colors.border, 0.4);
107
+ if (focused) return blendHex(colors.bg, colors.borderFocused, 0.25);
108
+ return blendHex(colors.panel, colors.border, 0.25);
109
109
  }
110
110
 
111
111
  /** Title text color for panes */
@@ -6,6 +6,7 @@ import { DEFAULT_THEME, themes } from "./themes";
6
6
  const BODY_TEXT_MIN = 4.5;
7
7
  const SUBTLE_TEXT_MIN = 3.6;
8
8
  const CHROME_TEXT_MIN = 4.5;
9
+ const SELECTED_SURFACE_MIN = 1.75;
9
10
 
10
11
  function assertMinContrast(themeId: string, label: string, fg: string, bg: string, min: number): void {
11
12
  const ratio = contrastRatio(fg, bg);
@@ -28,6 +29,7 @@ describe("theme contrast", () => {
28
29
  assertMinContrast(id, `positive/${surfaceLabel}`, theme.positive, surface, SUBTLE_TEXT_MIN);
29
30
  assertMinContrast(id, `negative/${surfaceLabel}`, theme.negative, surface, SUBTLE_TEXT_MIN);
30
31
  assertMinContrast(id, `neutral/${surfaceLabel}`, theme.neutral, surface, SUBTLE_TEXT_MIN);
32
+ assertMinContrast(id, `selected/${surfaceLabel}`, theme.selected, surface, SELECTED_SURFACE_MIN);
31
33
  }
32
34
 
33
35
  assertMinContrast(id, "headerText/header", theme.headerText, theme.header, BODY_TEXT_MIN);
@@ -28,6 +28,10 @@ export interface Theme {
28
28
  commandBorder: string;
29
29
  }
30
30
 
31
+ const BODY_TEXT_MIN = 4.5;
32
+ const SUBTLE_TEXT_MIN = 3.6;
33
+ const SELECTED_SURFACE_MIN = 1.75;
34
+
31
35
  function highestMinimumContrast(surfaces: readonly string[], candidates: readonly string[]): string {
32
36
  return candidates.reduce((best, candidate) => {
33
37
  const bestScore = Math.min(...surfaces.map((surface) => contrastRatio(best, surface)));
@@ -38,53 +42,60 @@ function highestMinimumContrast(surfaces: readonly string[], candidates: readonl
38
42
 
39
43
  function normalizeTheme(theme: Theme): Theme {
40
44
  const bodySurfaces = [theme.bg, theme.panel] as const;
45
+ const bodyContrastExtreme = highestMinimumContrast(bodySurfaces, ["#ffffff", "#000000"]);
41
46
  const text = blendForContrastOnSurfaces(
42
47
  theme.text,
43
48
  bodySurfaces,
44
49
  highestMinimumContrast(bodySurfaces, [theme.textBright, theme.headerText, "#f2f2f2"]),
45
- 4.5,
50
+ BODY_TEXT_MIN,
46
51
  );
47
52
  const textDim = blendForContrastOnSurfaces(
48
53
  theme.textDim,
49
54
  bodySurfaces,
50
55
  highestMinimumContrast(bodySurfaces, [text, theme.textBright, "#d0d7de"]),
51
- 4.5,
56
+ BODY_TEXT_MIN,
52
57
  );
53
58
  const textMuted = blendForContrastOnSurfaces(
54
59
  theme.textMuted,
55
60
  bodySurfaces,
56
61
  highestMinimumContrast(bodySurfaces, [textDim, text, "#c0c7d1"]),
57
- 3.6,
62
+ SUBTLE_TEXT_MIN,
58
63
  );
59
64
  const positive = blendForContrastOnSurfaces(
60
65
  theme.positive,
61
66
  bodySurfaces,
62
67
  blendHex(theme.positive, theme.textBright, 0.5),
63
- 3.6,
68
+ SUBTLE_TEXT_MIN,
64
69
  );
65
70
  const negative = blendForContrastOnSurfaces(
66
71
  theme.negative,
67
72
  bodySurfaces,
68
73
  blendHex(theme.negative, theme.textBright, 0.45),
69
- 3.6,
74
+ SUBTLE_TEXT_MIN,
70
75
  );
71
76
  const neutral = blendForContrastOnSurfaces(
72
77
  theme.neutral,
73
78
  bodySurfaces,
74
79
  highestMinimumContrast(bodySurfaces, [textDim, text, "#c0c7d1"]),
75
- 3.6,
80
+ SUBTLE_TEXT_MIN,
81
+ );
82
+ const selected = blendForContrastOnSurfaces(
83
+ theme.selected,
84
+ bodySurfaces,
85
+ blendHex(theme.selected, bodyContrastExtreme, 0.62),
86
+ SELECTED_SURFACE_MIN,
76
87
  );
77
88
  const headerText = blendForContrast(
78
89
  theme.headerText,
79
90
  theme.header,
80
91
  higherContrast(text, theme.textBright, theme.header),
81
- 4.5,
92
+ BODY_TEXT_MIN,
82
93
  );
83
94
  const selectedText = blendForContrast(
84
95
  theme.selectedText,
85
- theme.selected,
86
- higherContrast(text, theme.textBright, theme.selected),
87
- 4.5,
96
+ selected,
97
+ highestMinimumContrast([selected], [text, theme.textBright, "#f2f2f2"]),
98
+ BODY_TEXT_MIN,
88
99
  );
89
100
 
90
101
  return {
@@ -96,6 +107,7 @@ function normalizeTheme(theme: Theme): Theme {
96
107
  negative,
97
108
  neutral,
98
109
  headerText,
110
+ selected,
99
111
  selectedText,
100
112
  };
101
113
  }
@@ -1,5 +1,6 @@
1
1
  import type { Quote, TickerFinancials, PricePoint, OptionsChain } from "./financials";
2
2
  import type { TimeRange } from "../components/chart/chart-types";
3
+ import type { ChartResolutionSupport, ManualChartResolution } from "../components/chart/chart-resolution";
3
4
  import type { BrokerInstanceConfig } from "./config";
4
5
  import type { QuoteSubscriptionTarget } from "./data-provider";
5
6
  import type { BrokerContractRef, InstrumentSearchResult } from "./instrument";
@@ -78,8 +79,28 @@ export interface BrokerAdapter {
78
79
  getTickerFinancials?(ticker: string, instance: BrokerInstanceConfig, exchange?: string, instrument?: BrokerContractRef | null): Promise<TickerFinancials>;
79
80
  getQuote?(ticker: string, instance: BrokerInstanceConfig, exchange?: string, instrument?: BrokerContractRef | null): Promise<Quote>;
80
81
  getPriceHistory?(ticker: string, instance: BrokerInstanceConfig, exchange: string, range: TimeRange, instrument?: BrokerContractRef | null): Promise<PricePoint[]>;
82
+ getPriceHistoryForResolution?(
83
+ ticker: string,
84
+ instance: BrokerInstanceConfig,
85
+ exchange: string,
86
+ bufferRange: TimeRange,
87
+ resolution: ManualChartResolution,
88
+ instrument?: BrokerContractRef | null,
89
+ ): Promise<PricePoint[]>;
81
90
  /** Fetch higher-resolution price data for a specific date window (e.g. when zoomed in). */
82
91
  getDetailedPriceHistory?(ticker: string, instance: BrokerInstanceConfig, exchange: string, startDate: Date, endDate: Date, barSize: string, instrument?: BrokerContractRef | null): Promise<PricePoint[]>;
92
+ getChartResolutionSupport?(
93
+ ticker: string,
94
+ instance: BrokerInstanceConfig,
95
+ exchange?: string,
96
+ instrument?: BrokerContractRef | null,
97
+ ): Promise<ChartResolutionSupport[]> | ChartResolutionSupport[];
98
+ getChartResolutionCapabilities?(
99
+ ticker: string,
100
+ instance: BrokerInstanceConfig,
101
+ exchange?: string,
102
+ instrument?: BrokerContractRef | null,
103
+ ): Promise<ManualChartResolution[]> | ManualChartResolution[];
83
104
  getOptionsChain?(ticker: string, instance: BrokerInstanceConfig, exchange?: string, expirationDate?: number, instrument?: BrokerContractRef | null): Promise<OptionsChain>;
84
105
  subscribeQuotes?(
85
106
  instance: BrokerInstanceConfig,
@@ -149,7 +149,13 @@ export const DEFAULT_LAYOUT: LayoutConfig = {
149
149
  kind: "split",
150
150
  axis: "horizontal",
151
151
  ratio: 0.4,
152
- first: { kind: "pane", instanceId: "portfolio-list:main" },
152
+ first: {
153
+ kind: "split",
154
+ axis: "vertical",
155
+ ratio: 0.5,
156
+ first: { kind: "pane", instanceId: "portfolio-list:main" },
157
+ second: { kind: "pane", instanceId: "chat:main" },
158
+ },
153
159
  second: { kind: "pane", instanceId: "ticker-detail:main" },
154
160
  },
155
161
  instances: [
@@ -172,9 +178,19 @@ export const DEFAULT_LAYOUT: LayoutConfig = {
172
178
  settings: {
173
179
  hideTabs: false,
174
180
  lockedTabId: "overview",
181
+ chartRangePreset: "5Y",
182
+ chartResolution: "1wk",
175
183
  },
176
184
  binding: { kind: "follow", sourceInstanceId: "portfolio-list:main" },
177
185
  },
186
+ {
187
+ instanceId: "chat:main",
188
+ paneId: "chat",
189
+ settings: {
190
+ hideTabs: false,
191
+ },
192
+ binding: { kind: "none" },
193
+ },
178
194
  ],
179
195
  floating: [],
180
196
  };
@@ -1,5 +1,6 @@
1
1
  import type { Quote, Fundamentals, TickerFinancials, PricePoint, OptionsChain } from "./financials";
2
2
  import type { TimeRange } from "../components/chart/chart-types";
3
+ import type { ChartResolutionSupport, ManualChartResolution } from "../components/chart/chart-resolution";
3
4
  import type { BrokerContractRef, InstrumentSearchResult } from "./instrument";
4
5
  import type { CachePolicyMap } from "./persistence";
5
6
 
@@ -71,8 +72,25 @@ export interface DataProvider {
71
72
  /** Fetch article summary/description by URL (lazy-loaded on selection) */
72
73
  getArticleSummary(url: string): Promise<string | null>;
73
74
  getPriceHistory(ticker: string, exchange: string, range: TimeRange, context?: MarketDataRequestContext): Promise<PricePoint[]>;
75
+ getPriceHistoryForResolution?(
76
+ ticker: string,
77
+ exchange: string,
78
+ bufferRange: TimeRange,
79
+ resolution: ManualChartResolution,
80
+ context?: MarketDataRequestContext,
81
+ ): Promise<PricePoint[]>;
74
82
  /** Fetch higher-resolution price data for a specific date window (e.g. when zoomed in). */
75
83
  getDetailedPriceHistory?(ticker: string, exchange: string, startDate: Date, endDate: Date, barSize: string, context?: MarketDataRequestContext): Promise<PricePoint[]>;
84
+ getChartResolutionSupport?(
85
+ ticker: string,
86
+ exchange?: string,
87
+ context?: MarketDataRequestContext,
88
+ ): Promise<ChartResolutionSupport[]> | ChartResolutionSupport[];
89
+ getChartResolutionCapabilities?(
90
+ ticker: string,
91
+ exchange?: string,
92
+ context?: MarketDataRequestContext,
93
+ ): Promise<ManualChartResolution[]> | ManualChartResolution[];
76
94
  getOptionsChain?(ticker: string, exchange?: string, expirationDate?: number, context?: MarketDataRequestContext): Promise<OptionsChain>;
77
95
  subscribeQuotes?(
78
96
  targets: QuoteSubscriptionTarget[],