gloomberb 0.17.2 → 0.17.3

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 (159) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/futures-curve.ts +16 -0
  3. package/src/api-client/index.ts +18 -8
  4. package/src/api-client/types.ts +3 -1
  5. package/src/cli/commands/market.ts +60 -9
  6. package/src/cli/commands/search.ts +43 -2
  7. package/src/cli/commands/ticker.ts +100 -21
  8. package/src/cli/desktop-pane-shot.ts +34 -9
  9. package/src/cli/fx-pair.ts +17 -0
  10. package/src/cli/glossary.ts +134 -6
  11. package/src/cli/history-rows.ts +16 -4
  12. package/src/cli/index.ts +44 -12
  13. package/src/cli/known-gaps.ts +21 -2
  14. package/src/cli/listing-arg.ts +19 -4
  15. package/src/cli/listing-suggestion.ts +33 -2
  16. package/src/cli/pane-functions/dom.ts +39 -6
  17. package/src/cli/pane-functions/freshness.ts +48 -5
  18. package/src/cli/pane-functions/function-help.ts +1 -1
  19. package/src/cli/pane-functions/headless.ts +85 -19
  20. package/src/cli/pane-functions/index.ts +60 -4
  21. package/src/cli/pane-functions/market-session.ts +69 -6
  22. package/src/cli/pane-functions/resolver.ts +4 -2
  23. package/src/cli/pane-functions/screenshot.ts +14 -1
  24. package/src/cli/scroll-truncation.ts +5 -1
  25. package/src/cli/symbol-args.ts +41 -3
  26. package/src/cli/symbol-list.ts +19 -7
  27. package/src/cli/unknown-command.ts +70 -0
  28. package/src/components/chart/native/surface/visibility.ts +37 -7
  29. package/src/components/layout/header.tsx +348 -102
  30. package/src/components/markdown-text.tsx +40 -66
  31. package/src/components/market-board/index.tsx +12 -6
  32. package/src/components/reader/detail-reader.tsx +28 -6
  33. package/src/components/ticker/badge/index.tsx +2 -0
  34. package/src/components/ui/display.tsx +6 -1
  35. package/src/components/ui/external-link.tsx +10 -2
  36. package/src/data/memory-resource-store.ts +1 -1
  37. package/src/data/resource-store.ts +13 -1
  38. package/src/docs/snapshot.json +13 -13
  39. package/src/i18n/es.ts +1 -0
  40. package/src/i18n/ja.ts +1 -0
  41. package/src/i18n/ko.ts +1 -0
  42. package/src/i18n/zh-cn.ts +1 -0
  43. package/src/i18n/zh-tw.ts +1 -0
  44. package/src/market-data/futures-contract.ts +10 -0
  45. package/src/market-data/market/format.ts +7 -2
  46. package/src/market-data/market/freshness.ts +25 -1
  47. package/src/market-data/quotes/regular-session-valuation.ts +32 -1
  48. package/src/market-data/quotes/resolution.ts +2 -0
  49. package/src/plugins/builtin/account-management/pane.tsx +17 -13
  50. package/src/plugins/builtin/attention/headless.ts +19 -10
  51. package/src/plugins/builtin/attention/model.ts +22 -0
  52. package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
  53. package/src/plugins/builtin/changelog/index.tsx +11 -8
  54. package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
  55. package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
  56. package/src/plugins/builtin/chat/attachments/model.ts +5 -3
  57. package/src/plugins/builtin/chat/content/footer.tsx +6 -0
  58. package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
  59. package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
  60. package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
  61. package/src/plugins/builtin/chat/layout.ts +91 -31
  62. package/src/plugins/builtin/chat/markdown.ts +94 -0
  63. package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
  64. package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
  65. package/src/plugins/builtin/chat/message/render-state.ts +3 -0
  66. package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
  67. package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
  68. package/src/plugins/builtin/company-attention/headless.ts +8 -5
  69. package/src/plugins/builtin/company-attention/model.ts +53 -2
  70. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  71. package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
  72. package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
  73. package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
  74. package/src/plugins/builtin/cot/client.ts +12 -4
  75. package/src/plugins/builtin/cot/pane.tsx +2 -2
  76. package/src/plugins/builtin/crypto-board/model.ts +7 -3
  77. package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
  78. package/src/plugins/builtin/debug/index.tsx +15 -15
  79. package/src/plugins/builtin/dividend-yield/client.ts +8 -1
  80. package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
  81. package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
  82. package/src/plugins/builtin/docs/index.tsx +5 -3
  83. package/src/plugins/builtin/docs/pane.tsx +18 -1
  84. package/src/plugins/builtin/docs/targets.ts +5 -3
  85. package/src/plugins/builtin/earnings-calls/data.ts +22 -0
  86. package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
  87. package/src/plugins/builtin/equity-screener/headless.ts +4 -3
  88. package/src/plugins/builtin/equity-screener/live.ts +27 -1
  89. package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
  90. package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
  91. package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
  92. package/src/plugins/builtin/futures/index.tsx +32 -4
  93. package/src/plugins/builtin/futures/model.ts +65 -3
  94. package/src/plugins/builtin/futures/table.tsx +31 -11
  95. package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
  96. package/src/plugins/builtin/holders/pane.tsx +6 -1
  97. package/src/plugins/builtin/mna/client.ts +12 -2
  98. package/src/plugins/builtin/mna/detail.tsx +2 -2
  99. package/src/plugins/builtin/mna/pane.tsx +2 -2
  100. package/src/plugins/builtin/notes/owner.tsx +5 -4
  101. package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
  102. package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
  103. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  104. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
  105. package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
  106. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
  107. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +2 -9
  108. package/src/plugins/builtin/research/event-model.ts +14 -0
  109. package/src/plugins/builtin/research/headless-schema.ts +31 -1
  110. package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
  111. package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
  112. package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
  113. package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
  114. package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
  115. package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
  116. package/src/plugins/builtin/research-search/data.ts +11 -0
  117. package/src/plugins/builtin/research-search/pane.tsx +13 -2
  118. package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
  119. package/src/plugins/builtin/seasonality/client.ts +23 -0
  120. package/src/plugins/builtin/seasonality/pane.tsx +4 -2
  121. package/src/plugins/builtin/sectors/index.tsx +7 -3
  122. package/src/plugins/builtin/shared/report-freshness.ts +6 -2
  123. package/src/plugins/builtin/short-interest/headless.ts +13 -3
  124. package/src/plugins/builtin/short-interest/index.tsx +1 -1
  125. package/src/plugins/builtin/short-interest/model.ts +62 -3
  126. package/src/plugins/builtin/short-interest/pane.tsx +2 -2
  127. package/src/plugins/builtin/short-interest/schedule.ts +107 -0
  128. package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
  129. package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
  130. package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
  131. package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
  132. package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
  133. package/src/plugins/builtin/themes/headless.ts +1 -1
  134. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
  135. package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
  136. package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
  137. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
  138. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
  139. package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
  140. package/src/plugins/builtin/volatility/chart-model.ts +1 -1
  141. package/src/plugins/builtin/volatility/charts.tsx +1 -1
  142. package/src/renderers/dom/styles.css +6 -0
  143. package/src/sources/gloomberb-cloud/normalizers.ts +1 -0
  144. package/src/sources/provider-router/batches.ts +2 -1
  145. package/src/sources/provider-router/cache.ts +3 -0
  146. package/src/sync/baseline.ts +19 -14
  147. package/src/sync/controller.ts +140 -40
  148. package/src/sync/core-contributors.ts +256 -32
  149. package/src/sync/types.ts +44 -0
  150. package/src/time-series/resolve.ts +19 -2
  151. package/src/types/data-provider.ts +8 -0
  152. package/src/types/financials.ts +40 -4
  153. package/src/types/headless.ts +43 -0
  154. package/src/types/plugin.ts +7 -0
  155. package/src/utils/declared-dividends.ts +39 -0
  156. package/src/utils/forward-dividend.ts +34 -11
  157. package/src/utils/fundamentals.ts +13 -2
  158. package/src/utils/markdown.ts +180 -0
  159. package/src/utils/market-capitalization.ts +22 -0
@@ -1,6 +1,6 @@
1
1
  import { scheduleConfigSave } from "../state/config-save-scheduler";
2
2
  import { debugLog } from "../utils/debug-log";
3
- import { stableStringify } from "../utils/hash";
3
+ import { fnv1aHex, stableStringify } from "../utils/hash";
4
4
  import type { AppConfig, BrokerInstanceConfig, SavedLayout } from "../types/config";
5
5
  import type { PricePoint, TickerFinancials } from "../types/financials";
6
6
  import type { Portfolio, TickerMetadata, TickerPosition, TickerRecord, Watchlist } from "../types/ticker";
@@ -40,6 +40,9 @@ import { withoutMissingWatchlists } from "../plugins/builtin/portfolio-list/muta
40
40
  */
41
41
  const SESSION_SAVED_LAYOUT_KEYS = ["paneState", "focusedPaneId", "activePanel"] as const;
42
42
 
43
+ /** Together these are the workspace: the tabs, the one showing, and its arrangement. */
44
+ const LAYOUT_SYNC_KEYS = ["layout", "layouts", "activeLayoutIndex"] as const;
45
+
43
46
  const SENSITIVE_KEY_PATTERN = /(token|secret|password|credential|private|api[_-]?key|access[_-]?key|refresh[_-]?key|session|cookie|dataDir|path|directory|localPath)/i;
44
47
 
45
48
  const log = debugLog.createLogger("sync");
@@ -351,6 +354,14 @@ function sanitizeTickerMetadata(metadata: TickerMetadata, financials?: TickerFin
351
354
  };
352
355
  }
353
356
 
357
+ function collectLayoutPayload(config: Pick<AppConfig, typeof LAYOUT_SYNC_KEYS[number]>) {
358
+ return sanitizeUnknown({
359
+ layout: config.layout,
360
+ layouts: config.layouts.map(withoutSessionLayoutState),
361
+ activeLayoutIndex: config.activeLayoutIndex,
362
+ }) as Record<string, unknown>;
363
+ }
364
+
354
365
  function collectCoreConfigPayload(config: AppConfig) {
355
366
  return sanitizeUnknown({
356
367
  configVersion: config.configVersion,
@@ -358,9 +369,7 @@ function collectCoreConfigPayload(config: AppConfig) {
358
369
  refreshIntervalMinutes: config.refreshIntervalMinutes,
359
370
  portfolios: config.portfolios.map(sanitizePortfolio),
360
371
  watchlists: config.watchlists.map(sanitizeWatchlist),
361
- layout: config.layout,
362
- layouts: config.layouts.map(withoutSessionLayoutState),
363
- activeLayoutIndex: config.activeLayoutIndex,
372
+ ...collectLayoutPayload(config),
364
373
  brokerInstances: config.brokerInstances.map(sanitizeBrokerInstance),
365
374
  // This build merges profiles one by one before it pushes, so a profile
366
375
  // missing from its list was removed, not one it never pulled.
@@ -622,11 +631,204 @@ function withLocalSessionLayoutState(
622
631
  });
623
632
  }
624
633
 
634
+ /**
635
+ * A layout as the user arranged it. Stacking order follows whichever floating
636
+ * pane was focused last, and a pane in its own desktop window is one a
637
+ * terminal draws inline as a floating pane, so neither tells two copies apart.
638
+ */
639
+ function arrangedLayout(layout: unknown): unknown {
640
+ if (!isRecord(layout)) return layout;
641
+ const windows = [
642
+ ...(Array.isArray(layout.floating) ? layout.floating : []),
643
+ ...(Array.isArray(layout.detached) ? layout.detached : []),
644
+ ].map((entry) => {
645
+ if (!isRecord(entry)) return entry;
646
+ const { zIndex: _zIndex, ...rest } = entry;
647
+ return rest;
648
+ });
649
+ const instanceId = (entry: unknown) => (isRecord(entry) ? String(entry.instanceId) : "");
650
+ windows.sort((left, right) => instanceId(left).localeCompare(instanceId(right)));
651
+ return { ...layout, floating: windows, detached: [] };
652
+ }
653
+
654
+ // Pulled and last synced payloads are compared on every sync and never change.
655
+ const layoutIntents = new WeakMap<object, string | null>();
656
+
657
+ /**
658
+ * A key for the workspace a payload describes, equal for two copies that only
659
+ * differ in what reflects one device, so a real edit can be told from them.
660
+ * Null when the payload holds no complete workspace.
661
+ */
662
+ function layoutIntent(payload: unknown): string | null {
663
+ if (!isRecord(payload) || !Array.isArray(payload.layouts)) return null;
664
+ if (!LAYOUT_SYNC_KEYS.every((key) => key in payload)) return null;
665
+ const cached = layoutIntents.get(payload);
666
+ if (cached !== undefined) return cached;
667
+ const intent = stableStringify(sanitizeUnknown({
668
+ layout: arrangedLayout(payload.layout),
669
+ layouts: payload.layouts.map((entry) => (
670
+ isRecord(entry)
671
+ ? { ...withoutSessionLayoutState(entry as unknown as SavedLayout), layout: arrangedLayout(entry.layout) }
672
+ : entry
673
+ )),
674
+ activeLayoutIndex: payload.activeLayoutIndex,
675
+ }));
676
+ const key = `${fnv1aHex(intent)}:${intent.length}`;
677
+ layoutIntents.set(payload, key);
678
+ return key;
679
+ }
680
+
681
+ /** The workspace keys of a payload, with the time its layout was last edited when it says. */
682
+ function pickLayoutPayload(payload: Record<string, unknown>): Record<string, unknown> {
683
+ const picked = Object.fromEntries([...LAYOUT_SYNC_KEYS, "layoutEditedAt"].flatMap((key) => (
684
+ key in payload ? [[key, payload[key]]] : []
685
+ )));
686
+ // An older build still sent the view it was looking at with each layout.
687
+ if (Array.isArray(picked.layouts)) {
688
+ picked.layouts = picked.layouts.map((entry) => (isRecord(entry) ? withoutSessionLayoutState(entry as unknown as SavedLayout) : entry));
689
+ }
690
+ return picked;
691
+ }
692
+
693
+ /**
694
+ * What this device remembers about its workspace between syncs: when it last
695
+ * edited it (null when that happened while the app was closed, so the time is
696
+ * unknown), or, when it took the workspace from the cloud, that copy as synced.
697
+ */
698
+ interface LayoutMemo {
699
+ intent: string;
700
+ editedAt: number | null;
701
+ synced?: Record<string, unknown>;
702
+ }
703
+
704
+ function readLayoutMemo(value: unknown): LayoutMemo | null {
705
+ if (!isRecord(value) || typeof value.intent !== "string") return null;
706
+ return {
707
+ intent: value.intent,
708
+ editedAt: typeof value.editedAt === "number" && Number.isFinite(value.editedAt) ? value.editedAt : null,
709
+ ...(isRecord(value.synced) ? { synced: value.synced } : {}),
710
+ };
711
+ }
712
+
713
+ /**
714
+ * When a pulled workspace was last edited. A copy from a build that did not
715
+ * date edits counts as edited when it was pushed, which is never earlier.
716
+ */
717
+ function pulledLayoutEditedAt(payload: Record<string, unknown>, pushedAt: string | undefined): number {
718
+ if (typeof payload.layoutEditedAt === "number" && Number.isFinite(payload.layoutEditedAt)) return payload.layoutEditedAt;
719
+ const pushed = pushedAt ? Date.parse(pushedAt) : Number.NaN;
720
+ return Number.isFinite(pushed) ? pushed : Number.POSITIVE_INFINITY;
721
+ }
722
+
723
+ /**
724
+ * Whether a pulled workspace replaces this device's, three ways: against the
725
+ * copy this device last synced, the one it had when the pull started, and the
726
+ * pulled one. Only a real edit here keeps the local copy; the pane state,
727
+ * focus and window stacking that change on every launch never do.
728
+ */
729
+ function shouldAdoptPulledLayout({
730
+ local,
731
+ atPullStart,
732
+ lastSynced,
733
+ pulled,
734
+ memo,
735
+ pulledEditedAt,
736
+ }: {
737
+ local: string | null;
738
+ atPullStart: string | null;
739
+ lastSynced: string | null;
740
+ pulled: string | null;
741
+ memo: LayoutMemo | null;
742
+ pulledEditedAt: number;
743
+ }): boolean {
744
+ if (pulled === null || pulled === local) return false;
745
+ // Edited while the pull was in flight: newer than anything it carries.
746
+ if (local !== atPullStart) return false;
747
+ const takenFromCloud = !!memo?.synced && memo.intent === local;
748
+ const editedHere = lastSynced !== null && local !== lastSynced && !takenFromCloud;
749
+ if (!editedHere) return true;
750
+ // The cloud still holds what this device last synced: only this side changed.
751
+ if (pulled === lastSynced) return false;
752
+ // Both sides changed since: the later edit wins, and an undated local one loses.
753
+ const localEditedAt = memo?.intent === local ? memo.editedAt : null;
754
+ return localEditedAt === null || localEditedAt <= pulledEditedAt;
755
+ }
756
+
757
+ /**
758
+ * The workspace as the cloud already has it unless this device really edited
759
+ * it, so what only reflects this device never replaces another device's copy,
760
+ * and a real edit goes out dated.
761
+ */
762
+ function withLayoutProvenance(
763
+ payload: Record<string, unknown>,
764
+ lastSyncedPayload: unknown,
765
+ memoValue: unknown,
766
+ ): Record<string, unknown> {
767
+ const local = layoutIntent(payload);
768
+ if (local === null) return payload;
769
+ const memo = readLayoutMemo(memoValue);
770
+ if (memo?.synced && memo.intent === local) return { ...payload, ...memo.synced };
771
+ if (isRecord(lastSyncedPayload) && layoutIntent(lastSyncedPayload) === local) {
772
+ return { ...payload, ...pickLayoutPayload(lastSyncedPayload) };
773
+ }
774
+ return {
775
+ ...payload,
776
+ layoutEditedAt: memo?.intent === local && memo.editedAt !== null ? memo.editedAt : Date.now(),
777
+ };
778
+ }
779
+
780
+ /**
781
+ * Dates a workspace edit when it happens, online or not, so a later conflict
782
+ * with another device's edit goes to the newer one.
783
+ */
784
+ function noteLayoutEdit(config: AppConfig, lastSyncedPayload: unknown, memoValue: unknown, launch: boolean): LayoutMemo | null {
785
+ const lastSynced = layoutIntent(lastSyncedPayload);
786
+ if (lastSynced === null) return null;
787
+ const local = layoutIntent(collectLayoutPayload(config));
788
+ if (local === null || local === lastSynced) return null;
789
+ if (readLayoutMemo(memoValue)?.intent === local) return null;
790
+ // Seen at launch, it was made while the app was closed or just before it
791
+ // quit; when is unknown, so it gives way to a dated edit elsewhere.
792
+ return { intent: local, editedAt: launch ? null : Date.now() };
793
+ }
794
+
795
+ /**
796
+ * Once this device holds the cloud's workspace, by taking it or because the
797
+ * two already matched, it remembers that copy as synced, keyed by how it
798
+ * looks here once applied. The next push then sends the cloud's own copy back
799
+ * rather than this device's rendering of it.
800
+ */
801
+ function rememberCloudLayout(
802
+ payload: Record<string, unknown>,
803
+ pushedAt: string | undefined,
804
+ applied: AppConfig,
805
+ memo: { get(): unknown; set(value: unknown): void },
806
+ adopted: boolean,
807
+ ): void {
808
+ const local = layoutIntent(collectLayoutPayload(applied));
809
+ if (local === null || (!adopted && local !== layoutIntent(payload))) return;
810
+ const current = readLayoutMemo(memo.get());
811
+ if (!adopted && current?.synced && current.intent === local) return;
812
+ const editedAt = pulledLayoutEditedAt(payload, pushedAt);
813
+ memo.set({
814
+ intent: local,
815
+ editedAt: Number.isFinite(editedAt) ? editedAt : null,
816
+ synced: pickLayoutPayload(payload),
817
+ } satisfies LayoutMemo);
818
+ }
819
+
820
+ interface LayoutMergeContext {
821
+ memo?: unknown;
822
+ /** When the pulled copy was pushed, for one from a build that did not date layout edits. */
823
+ pushedAt?: string;
824
+ }
825
+
625
826
  function mergeConfigPayload(
626
827
  config: AppConfig,
627
828
  payload: unknown,
628
829
  baselineConfig: AppConfig = config,
629
830
  lastSyncedPayload?: unknown,
831
+ layoutContext: LayoutMergeContext = {},
630
832
  ): AppConfig | null {
631
833
  if (!isRecord(payload)) return null;
632
834
  const next: AppConfig = { ...config };
@@ -634,11 +836,9 @@ function mergeConfigPayload(
634
836
  // was in flight; lastSyncedPayload catches edits made while the app was not
635
837
  // running at all (CLI writes, offline edits), which otherwise look pristine.
636
838
  const lastSynced = isRecord(lastSyncedPayload) ? lastSyncedPayload : null;
637
- const localPayload = lastSynced
638
- ? collectCoreConfigPayload(config) as Record<string, unknown>
639
- : null;
839
+ const localPayload = collectCoreConfigPayload(config) as Record<string, unknown>;
640
840
  const matchesLastSynced = (key: string) => (
641
- !lastSynced || valuesEqual(localPayload?.[key], lastSynced[key])
841
+ !lastSynced || valuesEqual(localPayload[key], lastSynced[key])
642
842
  );
643
843
  const canApply = <K extends keyof AppConfig>(key: K) => (
644
844
  valuesEqual(config[key], baselineConfig[key]) && matchesLastSynced(key as string)
@@ -689,15 +889,20 @@ function mergeConfigPayload(
689
889
  );
690
890
  }
691
891
 
692
- const layoutStateUntouched = config.layout === baselineConfig.layout
693
- && config.layouts === baselineConfig.layouts
694
- && config.activeLayoutIndex === baselineConfig.activeLayoutIndex
695
- && ["layout", "layouts", "activeLayoutIndex"].every(matchesLastSynced);
696
- if (
697
- layoutStateUntouched
698
- && ["layout", "layouts", "activeLayoutIndex"].every((key) => key in payload)
699
- && Array.isArray(payload.layouts)
700
- ) {
892
+ // Compared by content, not identity: the active layout mirrors pane state,
893
+ // so a cursor settling at launch replaced it mid-pull, the pulled workspace
894
+ // was refused, and the push that followed wrote this device's older one
895
+ // over it on every other device.
896
+ const localIntent = layoutIntent(localPayload);
897
+ const adoptLayout = shouldAdoptPulledLayout({
898
+ local: localIntent,
899
+ atPullStart: baselineConfig === config ? localIntent : layoutIntent(collectLayoutPayload(baselineConfig)),
900
+ lastSynced: layoutIntent(lastSynced),
901
+ pulled: layoutIntent(payload),
902
+ memo: readLayoutMemo(layoutContext.memo),
903
+ pulledEditedAt: pulledLayoutEditedAt(payload, layoutContext.pushedAt),
904
+ });
905
+ if (adoptLayout) {
701
906
  next.layout = payload.layout as unknown as AppConfig["layout"];
702
907
  next.layouts = withLocalSessionLayoutState(payload.layouts as AppConfig["layouts"], config.layouts);
703
908
  next.activeLayoutIndex = payload.activeLayoutIndex as number;
@@ -872,24 +1077,43 @@ function tickerChangedSinceLastSync(
872
1077
  return !valuesEqual(withoutQuote(local), withoutQuote(baseline));
873
1078
  }
874
1079
 
1080
+ const CORE_CONFIG_CONTRIBUTOR_ID = "core.config";
1081
+
875
1082
  export const coreConfigSyncContributor: SyncContributor = {
876
- id: "core.config",
1083
+ id: CORE_CONFIG_CONTRIBUTOR_ID,
877
1084
  schemaVersion: 1,
878
- collect: ({ state }) => collectCoreConfigPayload(state.config),
879
- apply: async (payload, { baselinePayload, baselineState, state, getState, isCurrent, dispatch, tickerRepository }) => {
880
- const nextConfig = mergeConfigPayload(state.config, payload, baselineState.config, baselinePayload);
881
- if (!nextConfig || valuesEqual(nextConfig, state.config)) return;
882
- // Adopting another device's workspace rearranges the screen under the
883
- // user, so it is worth a line in the log when a report says "my panes
884
- // moved on their own".
885
- if (nextConfig.layout !== state.config.layout) {
886
- log.info("config.layout.adopted", {
887
- panes: nextConfig.layout.instances.length,
888
- activeLayoutIndex: nextConfig.activeLayoutIndex,
889
- });
1085
+ collect: ({ state, baselinePayload, memo }) => withLayoutProvenance(
1086
+ collectCoreConfigPayload(state.config) as Record<string, unknown>,
1087
+ baselinePayload,
1088
+ memo?.get(),
1089
+ ),
1090
+ observe: ({ state, baselinePayload, memo, launch }) => {
1091
+ const edit = noteLayoutEdit(state.config, baselinePayload, memo.get(), launch);
1092
+ if (edit) memo.set(edit);
1093
+ },
1094
+ apply: async (payload, { snapshot, baselinePayload, baselineState, state, getState, isCurrent, dispatch, tickerRepository, memo }) => {
1095
+ const pushedAt = snapshot?.contributors[CORE_CONFIG_CONTRIBUTOR_ID]?.updatedAt;
1096
+ const nextConfig = mergeConfigPayload(state.config, payload, baselineState.config, baselinePayload, {
1097
+ memo: memo?.get(),
1098
+ pushedAt,
1099
+ });
1100
+ if (!nextConfig) return;
1101
+ const configChanged = !valuesEqual(nextConfig, state.config);
1102
+ if (configChanged) {
1103
+ // Adopting another device's workspace rearranges the screen under the
1104
+ // user, so it is worth a line in the log when a report says "my panes
1105
+ // moved on their own".
1106
+ if (nextConfig.layout !== state.config.layout) {
1107
+ log.info("config.layout.adopted", {
1108
+ panes: nextConfig.layout.instances.length,
1109
+ activeLayoutIndex: nextConfig.activeLayoutIndex,
1110
+ });
1111
+ }
1112
+ dispatch({ type: "SET_CONFIG", config: nextConfig });
1113
+ scheduleConfigSave(nextConfig);
890
1114
  }
891
- dispatch({ type: "SET_CONFIG", config: nextConfig });
892
- scheduleConfigSave(nextConfig);
1115
+ if (memo && isRecord(payload)) rememberCloudLayout(payload, pushedAt, getState().config, memo, nextConfig.layouts !== state.config.layouts);
1116
+ if (!configChanged) return;
893
1117
 
894
1118
  // A profile removed on another device takes its positions with it here
895
1119
  // too; kept, this device would push them back and recreate the tickers
package/src/sync/types.ts CHANGED
@@ -19,8 +19,29 @@ export interface SyncSnapshot {
19
19
  contributors: Record<string, SyncContributorPayload>;
20
20
  }
21
21
 
22
+ /**
23
+ * What this device remembers about its own copy of one contributor's data
24
+ * between syncs, kept across launches: when it last edited it, or which cloud
25
+ * copy it took. Never sent anywhere.
26
+ */
27
+ interface SyncContributorMemo {
28
+ get(): unknown;
29
+ set(value: unknown): void;
30
+ }
31
+
22
32
  interface SyncCollectContext {
23
33
  state: AppState;
34
+ /** The payload this device last pushed for this contributor, or null when it never synced. */
35
+ baselinePayload?: unknown;
36
+ memo?: SyncContributorMemo;
37
+ }
38
+
39
+ interface SyncObserveContext {
40
+ state: AppState;
41
+ baselinePayload: unknown;
42
+ memo: SyncContributorMemo;
43
+ /** True for the first look after the app started: a change seen then was made while it was closed. */
44
+ launch: boolean;
24
45
  }
25
46
 
26
47
  interface SyncApplyContext {
@@ -37,6 +58,7 @@ interface SyncApplyContext {
37
58
  isCurrent: () => boolean;
38
59
  dispatch: Dispatch<AppAction>;
39
60
  tickerRepository: AppTickerRepositoryPort;
61
+ memo?: SyncContributorMemo;
40
62
  }
41
63
 
42
64
  export interface SyncContributor {
@@ -44,6 +66,12 @@ export interface SyncContributor {
44
66
  schemaVersion: number;
45
67
  collect(context: SyncCollectContext): unknown | Promise<unknown>;
46
68
  apply?(payload: unknown, context: SyncApplyContext): void | Promise<void>;
69
+ /**
70
+ * Called before every sync attempt and shortly after local changes, online
71
+ * or not, so an edit can be dated when it happens rather than when the
72
+ * network is next reachable.
73
+ */
74
+ observe?(context: SyncObserveContext): void;
47
75
  }
48
76
 
49
77
  export interface SyncSnapshotResponse {
@@ -63,9 +91,22 @@ export interface SyncTransport {
63
91
  id: string;
64
92
  isAvailable(): boolean;
65
93
  pullSnapshot(): Promise<SyncSnapshotResponse>;
94
+ /**
95
+ * `baseRevision` is the stored revision the snapshot was merged with (0 when
96
+ * there was none). A transport that can refuse a write made against an older
97
+ * revision throws `SyncRevisionConflictError`, and the controller pulls again.
98
+ */
66
99
  pushSnapshot(snapshot: SyncSnapshot, options?: { baseRevision?: number | null }): Promise<SyncPushResult>;
67
100
  }
68
101
 
102
+ /** Another device wrote since this one pulled; its push would have replaced that write. */
103
+ export class SyncRevisionConflictError extends Error {
104
+ constructor(message = "The synced copy changed since it was pulled.") {
105
+ super(message);
106
+ this.name = "SyncRevisionConflictError";
107
+ }
108
+ }
109
+
69
110
  export interface SyncSettings {
70
111
  syncEnabled: boolean;
71
112
  weeklyRoundupEnabled: boolean;
@@ -79,6 +120,9 @@ export interface SyncSettings {
79
120
  export interface SyncBaselineStore {
80
121
  load(): Record<string, unknown> | null;
81
122
  save(payloads: Record<string, unknown>): void;
123
+ /** Contributor memos by contributor id; see `SyncContributorMemo`. */
124
+ loadMemos?(): Record<string, unknown> | null;
125
+ saveMemos?(memos: Record<string, unknown>): void;
82
126
  }
83
127
 
84
128
  export interface RegisteredSyncContributor {
@@ -1235,6 +1235,21 @@ function baseSecuritySeries(
1235
1235
  };
1236
1236
  }
1237
1237
 
1238
+ const FRED_CATALOG_UNITS: Readonly<Record<string, string>> = {
1239
+ "percent": "%",
1240
+ "millions of u.s. dollars": "million USD",
1241
+ "millions of dollars": "million USD",
1242
+ "billions of dollars": "billion USD",
1243
+ "billions of u.s. dollars": "billion USD",
1244
+ "billions of us dollars": "billion USD",
1245
+ "dollars per barrel": "USD/barrel",
1246
+ "thousands of persons": "thousand people",
1247
+ };
1248
+
1249
+ function fredCatalogUnit(units: string): string {
1250
+ return FRED_CATALOG_UNITS[units.toLowerCase()] ?? units;
1251
+ }
1252
+
1238
1253
  function baseEconomicSeries(
1239
1254
  spec: ChartSeriesSpec,
1240
1255
  loaded: FredSeriesLoadResult,
@@ -1243,13 +1258,15 @@ function baseEconomicSeries(
1243
1258
  if (spec.source.kind !== "economic") return null;
1244
1259
  const { data } = loaded;
1245
1260
  const units = data.info?.units?.trim() || "value";
1261
+ const catalogUnit = fredCatalogUnit(units);
1262
+ // "Percent Change from Year Ago" and "Percent per Annum" are percent too.
1246
1263
  const isPercent = units.toLowerCase().includes("percent");
1247
1264
  return {
1248
1265
  id: spec.id,
1249
1266
  label: spec.label?.trim() || data.info?.title?.trim() || spec.source.seriesId,
1250
1267
  color: spec.color ?? SERIES_COLORS[index % SERIES_COLORS.length]!,
1251
- unit: isPercent ? "%" : units,
1252
- unitGroup: isPercent ? "percent" : `economic:${units.toLowerCase()}`,
1268
+ unit: isPercent ? "%" : catalogUnit,
1269
+ unitGroup: isPercent ? "percent" : `economic:${catalogUnit.toLowerCase()}`,
1253
1270
  nativeFrequency: "auto",
1254
1271
  timestampMode: "period-end",
1255
1272
  dataShape: "scalar",
@@ -180,6 +180,14 @@ export interface QuoteBatchOptions {
180
180
  * provider with no cache answers every target with no quote.
181
181
  */
182
182
  cacheOnly?: boolean;
183
+ /**
184
+ * Ask the sources even for a target whose cached quote is still fresh; that
185
+ * copy answers only when they cannot. A command that prints quotes once
186
+ * shows the service's quote of the moment, so every form of one listing
187
+ * (BRK.B saved on NYSE, BRK-B, BRK.B:NYSE) prints the same quote, never
188
+ * each form's own copy from whenever it was last asked for.
189
+ */
190
+ revalidate?: boolean;
183
191
  /** The caller stopped waiting; a transport that can abandon the request does. */
184
192
  signal?: AbortSignal;
185
193
  }
@@ -5,6 +5,20 @@ export type SessionConfidence = "explicit" | "derived" | "unknown";
5
5
  export type QuoteDataSource = "live" | "delayed" | "snapshot";
6
6
  export type QuoteDelivery = "stream" | "poll";
7
7
 
8
+ /**
9
+ * The regular sessions an index quote carries, dated by its source: the
10
+ * calendar its publisher's name does not give (SNP, DJI, NASDAQ GIDS). Times
11
+ * are ms.
12
+ */
13
+ export interface QuoteIndexSession {
14
+ /** The zone the sessions' dates are read in. */
15
+ timeZone: string;
16
+ /** The regular session the quote's day follows: today's, or the next one while it has not opened. */
17
+ current: { start: number; end: number };
18
+ /** The latest regular sessions, oldest first. */
19
+ regular: Array<{ start: number; end: number }>;
20
+ }
21
+
8
22
  export interface QuoteFieldProvenance {
9
23
  providerId: string;
10
24
  dataSource?: QuoteDataSource;
@@ -100,6 +114,8 @@ export interface Quote extends DepositaryReceiptFacts {
100
114
  routingExchangeFullName?: string;
101
115
  marketState?: MarketState;
102
116
  sessionConfidence?: SessionConfidence;
117
+ /** An index's own regular sessions, for the open its pre-market waits for and the close its last print was. */
118
+ indexSession?: QuoteIndexSession;
103
119
  preMarketPrice?: number;
104
120
  preMarketChange?: number;
105
121
  preMarketChangePercent?: number;
@@ -245,15 +261,17 @@ export interface Fundamentals extends DepositaryReceiptFacts {
245
261
  return3Y?: number;
246
262
  lastQuarterGrowth?: number;
247
263
  sharesOutstanding?: number;
248
- /** Free float: shares outstanding less insider and strategic holdings. */
264
+ /** Free float: shares outstanding less insider and strategic holdings, never above `sharesOutstanding`. */
249
265
  floatShares?: number;
250
266
  beta?: number;
251
- /** Shares sold short at the source's latest settlement. */
267
+ /** Shares short at the latest FINRA settlement, the one `SI` and `SIW` show; dated by `shortInterestDate`. */
252
268
  sharesShort?: number;
253
- /** Shares short over float from the same observation, as a fraction (0.0127 for 1.27%). */
269
+ /** `sharesShort` over `floatShares`, as a fraction (0.0127 for 1.27%). */
254
270
  shortPercentOfFloat?: number;
255
- /** Days to cover: shares short over average daily volume. */
271
+ /** Days to cover as FINRA states it for the settlement: shares short over the period's average daily volume. */
256
272
  shortRatio?: number;
273
+ /** The settlement date (YYYY-MM-DD) of `sharesShort`, `shortPercentOfFloat` and `shortRatio`. */
274
+ shortInterestDate?: string;
257
275
  /** Fractions of shares outstanding. */
258
276
  insiderPercentHeld?: number;
259
277
  institutionPercentHeld?: number;
@@ -366,6 +384,20 @@ export interface DividendAction {
366
384
  amount: number;
367
385
  }
368
386
 
387
+ /**
388
+ * A distribution the issuer has declared and not yet paid, as the source
389
+ * states it. Kept apart from the paid `dividends`: no history, yield or growth
390
+ * figure takes it.
391
+ */
392
+ export interface DeclaredDividendAction {
393
+ exDate: string;
394
+ /** Per share, in the listing's dividend currency (`currency`). */
395
+ amount: number;
396
+ declarationDate?: string;
397
+ recordDate?: string;
398
+ payDate?: string;
399
+ }
400
+
369
401
  export interface SplitAction {
370
402
  date: string;
371
403
  description?: string;
@@ -417,6 +449,8 @@ export interface CorporateActionsData {
417
449
  micCode?: string;
418
450
  exchangeTimezone?: string;
419
451
  dividends: DividendAction[];
452
+ /** Declared and unpaid, for the listings Gloom Cloud has them for; absent elsewhere and from older servers. */
453
+ declaredDividends?: DeclaredDividendAction[];
420
454
  splits: SplitAction[];
421
455
  earnings: EarningsAction[];
422
456
  }
@@ -757,6 +791,8 @@ export interface PricePoint {
757
791
  partial?: boolean;
758
792
  /** The bar's prices were rebuilt from shorter bars where the source left it empty or too coarse. */
759
793
  filled?: boolean;
794
+ /** A future's session that has closed but whose settlement is not confirmed yet: its close is the last trade. */
795
+ unsettled?: boolean;
760
796
  }
761
797
 
762
798
  export interface StatementHistoryAttempt {
@@ -257,6 +257,12 @@ export interface HeadlessPaneFreshness {
257
257
  sources?: Array<{ source: string; asOf: string | number | Date }>;
258
258
  /** Say how old the oldest observation is (`oldest 4 min`): rows polled apart from one source. */
259
259
  oldestAge?: boolean;
260
+ /**
261
+ * Rows quoted on several markets (a US line beside Xetra and Paris ones)
262
+ * date each market's session in the status line, `US, XETRA and EPA Tue 13
263
+ * Oct 2026 close`, instead of only the newest row's.
264
+ */
265
+ datesEachVenue?: boolean;
260
266
  /**
261
267
  * Exports also cite the oldest observation whenever the rows are dated
262
268
  * apart, not only when it is a day older: a portfolio valued at one
@@ -265,6 +271,12 @@ export interface HeadlessPaneFreshness {
265
271
  citeOldest?: boolean;
266
272
  /** What the figures are, said after the status: `funding = current estimate, pays 08:00 UTC`. */
267
273
  note?: string;
274
+ /**
275
+ * When the figures reach readers and when the next ones are due, said right
276
+ * after the as-of date: `published Fri 9 Oct, next settlement Thu 15 Oct,
277
+ * published Mon 26 Oct` for a settlement a regulator publishes days later.
278
+ */
279
+ schedule?: string;
268
280
  }
269
281
 
270
282
  interface HeadlessPaneResultBase {
@@ -295,6 +307,12 @@ interface HeadlessPaneResultBase {
295
307
 
296
308
  export interface HeadlessRowsResult extends HeadlessPaneResultBase {
297
309
  columns?: HeadlessPaneColumn[];
310
+ /**
311
+ * The columns the text report prints in place of `columns`, when the
312
+ * exports carry more than a terminal table should (each cell's source and
313
+ * as-of beside it). CSV, NDJSON and JSON keep `columns`.
314
+ */
315
+ textColumns?: HeadlessPaneColumn[];
298
316
  rows: HeadlessPaneRow[];
299
317
  /** What the report says when `rows` is empty, in place of "No data.": why there are none. */
300
318
  empty?: string;
@@ -401,7 +419,22 @@ export interface HeadlessPaneDefinition<Shape extends HeadlessPaneShape = Headle
401
419
  * points at GIP's 1-minute bars). `key` is the listing as read, RMS:ASX.
402
420
  */
403
421
  usListingsOnlyHint?(key: string): string;
422
+ /**
423
+ * `fn` exits 1 when the report has no name with data: no rows, or every row
424
+ * is one of `unavailableSymbols`. A list that gives data for some names
425
+ * exits 0 and names the others in its errors.
426
+ */
427
+ failsWithoutData?: boolean;
404
428
  options: HeadlessPaneOptionDef[];
429
+ /**
430
+ * Widens the argument before the function runs, so its report, its capture
431
+ * and its pane all read the same symbols: RV's `--peers` adds a symbol's
432
+ * competitors. Null runs the argument as typed.
433
+ */
434
+ expandArgument?(
435
+ args: HeadlessPaneLoadArgs,
436
+ ctx: HeadlessPaneContext,
437
+ ): Promise<HeadlessArgumentExpansion | null>;
405
438
  /** What is true of every load: its source, and whether it is a feed. */
406
439
  freshness?: HeadlessPaneFreshness;
407
440
  columns?: HeadlessPaneColumn[];
@@ -426,3 +459,13 @@ export interface HeadlessPaneDefinition<Shape extends HeadlessPaneShape = Headle
426
459
  }
427
460
 
428
461
  export type HeadlessPaneResult = HeadlessPaneResultByShape[HeadlessPaneShape];
462
+
463
+ /** What a function runs with once it has widened its argument (`expandArgument`). */
464
+ export interface HeadlessArgumentExpansion {
465
+ /** The argument as it would be typed: `DUOL, COUR, CHGG`. */
466
+ argument: string;
467
+ /** Merged into the pane's settings, which the load reads as `ctx.settings` and the pane as its own. */
468
+ settings?: Record<string, unknown>;
469
+ /** The pane's title when the widened argument needs saying (`RV DUOL + auto peers by industry: Airlines`). */
470
+ title?: string;
471
+ }
@@ -3,6 +3,7 @@ import type { AppTickerRepositoryPort } from "../core/app-service-ports";
3
3
  import type { HeadlessPaneDefinition, HeadlessPaneFreshness, HeadlessPaneOptionDef } from "./headless";
4
4
 
5
5
  export type {
6
+ HeadlessArgumentExpansion,
6
7
  HeadlessBundleResult,
7
8
  HeadlessBundleSection,
8
9
  HeadlessFreshnessStatus,
@@ -189,6 +190,12 @@ export interface PaneDef {
189
190
  * the last ones. Leave it off for long or paged lists.
190
191
  */
191
192
  shotFitsRows?: true;
193
+ /**
194
+ * A report of the pane grows to the whole text of the page its reader has
195
+ * open (a docs page), so it is not cut at the first screen. A list the reader
196
+ * opens from keeps its height.
197
+ */
198
+ shotFitsReader?: true;
192
199
  /** Add an Excel-compatible CSV action for the pane's single active DataTable. */
193
200
  tableExport?: true;
194
201
  settings?: PaneSettingsDef | ((context: PaneSettingsContext) => PaneSettingsDef | null);