gloomberb 0.16.2 → 0.16.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 (124) hide show
  1. package/package.json +1 -1
  2. package/src/brokers/account-cache.ts +45 -0
  3. package/src/cli/command-options.ts +74 -0
  4. package/src/cli/commands/command-utils.ts +29 -0
  5. package/src/cli/commands/market.ts +170 -54
  6. package/src/cli/commands/overview.ts +62 -19
  7. package/src/cli/commands/ticker.ts +138 -43
  8. package/src/cli/desktop-pane-shot.ts +3 -0
  9. package/src/cli/help.ts +26 -6
  10. package/src/cli/history-rows.ts +187 -15
  11. package/src/cli/index.ts +12 -7
  12. package/src/cli/options.ts +5 -1
  13. package/src/cli/pane-functions/capabilities.ts +5 -0
  14. package/src/cli/pane-functions/dom.ts +69 -16
  15. package/src/cli/pane-functions/function-help.ts +4 -2
  16. package/src/cli/pane-functions/headless.ts +163 -31
  17. package/src/cli/pane-functions/index.ts +9 -2
  18. package/src/cli/pane-functions/report.ts +8 -1
  19. package/src/cli/pane-functions/resolver.ts +28 -2
  20. package/src/cli/pane-functions/screenshot.ts +4 -0
  21. package/src/cli/registry.ts +6 -3
  22. package/src/cli/report-tables.ts +460 -0
  23. package/src/cli/result.ts +13 -3
  24. package/src/components/data-table/export.ts +6 -51
  25. package/src/components/data-table/view.tsx +3 -1
  26. package/src/components/layout/shell/active-drag.ts +67 -35
  27. package/src/components/layout/shell/cursor-occlusion.ts +6 -5
  28. package/src/components/layout/shell/drag/index.ts +12 -7
  29. package/src/components/layout/shell/drag/live.ts +122 -0
  30. package/src/components/layout/shell/drag/overlays.tsx +179 -35
  31. package/src/components/layout/shell/drag/runtime.ts +62 -50
  32. package/src/components/layout/shell/drag/slide.ts +10 -0
  33. package/src/components/layout/shell/index.tsx +37 -51
  34. package/src/components/layout/shell/native/pointer-runtime.ts +13 -13
  35. package/src/components/layout/shell/native/surfaces.ts +30 -14
  36. package/src/components/layout/shell/pane/layers.tsx +419 -179
  37. package/src/components/layout/shell/terminal-pointer-runtime.ts +18 -18
  38. package/src/components/layout/shell/window-mode/overlays.tsx +12 -4
  39. package/src/data/json-persistence.ts +17 -3
  40. package/src/data/json-storage.ts +2 -0
  41. package/src/data/memory-resource-store.ts +46 -0
  42. package/src/market-data/market/freshness.ts +45 -0
  43. package/src/market-data/quotes/freshness.ts +5 -0
  44. package/src/plugins/builtin/analytics/pane-model.ts +10 -6
  45. package/src/plugins/builtin/attention/pane.tsx +1 -1
  46. package/src/plugins/builtin/catalysts/detail.tsx +1 -1
  47. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  48. package/src/plugins/builtin/holders/client.ts +3 -6
  49. package/src/plugins/builtin/holders/format.ts +31 -1
  50. package/src/plugins/builtin/holders/headless.ts +26 -5
  51. package/src/plugins/builtin/holders/pane.tsx +22 -13
  52. package/src/plugins/builtin/holders/report-header.ts +46 -5
  53. package/src/plugins/builtin/kelly-sizer/pane.tsx +12 -6
  54. package/src/plugins/builtin/kelly-sizer/portfolio.ts +25 -0
  55. package/src/plugins/builtin/members/pane.tsx +10 -0
  56. package/src/plugins/builtin/perps/pane.tsx +1 -1
  57. package/src/plugins/builtin/portfolio-list/account-metrics.ts +16 -1
  58. package/src/plugins/builtin/portfolio-list/allocation.ts +3 -0
  59. package/src/plugins/builtin/portfolio-list/cached-account.ts +18 -9
  60. package/src/plugins/builtin/portfolio-list/cli/render.ts +2 -0
  61. package/src/plugins/builtin/portfolio-list/headless.ts +2 -0
  62. package/src/plugins/builtin/portfolio-list/pane/index.tsx +7 -4
  63. package/src/plugins/builtin/portfolio-list/summary/index.tsx +22 -4
  64. package/src/plugins/builtin/portfolio-list/summary/live-accounts.ts +11 -1
  65. package/src/plugins/builtin/research/relative-valuation-model.ts +7 -3
  66. package/src/plugins/builtin/reverse-dcf/model.ts +2 -1
  67. package/src/plugins/builtin/shared/members-menu.tsx +260 -0
  68. package/src/plugins/builtin/short-volume/pane.tsx +1 -1
  69. package/src/plugins/builtin/social-mentions/pane.tsx +1 -1
  70. package/src/plugins/builtin/themes/pane.tsx +3 -0
  71. package/src/plugins/builtin/ticker-detail/financials/aggregation.ts +4 -0
  72. package/src/plugins/builtin/ticker-detail/financials/model.ts +25 -2
  73. package/src/plugins/builtin/ticker-detail/headless.ts +20 -4
  74. package/src/plugins/builtin/ticker-detail/overview/model.ts +9 -4
  75. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +3 -2
  76. package/src/plugins/builtin/world-venue-map/basemap-layer.tsx +2 -0
  77. package/src/plugins/builtin/world-venue-map/basemap.ts +18 -0
  78. package/src/plugins/builtin/world-venue-map/country-labels.ts +78 -0
  79. package/src/plugins/builtin/world-venue-map/geo-svg.tsx +2 -1
  80. package/src/plugins/builtin/world-venue-map/index.ts +5 -5
  81. package/src/plugins/builtin/world-venue-map/layered-pane.tsx +27 -14
  82. package/src/plugins/builtin/world-venue-map/layers.ts +68 -10
  83. package/src/plugins/builtin/world-venue-map/map.tsx +198 -62
  84. package/src/plugins/builtin/world-venue-map/model.ts +16 -0
  85. package/src/plugins/builtin/world-venue-map/natural-earth/index.chunk.ts +3 -0
  86. package/src/plugins/builtin/world-venue-map/natural-earth/labels.chunk.ts +244 -0
  87. package/src/plugins/builtin/world-venue-map/pane.tsx +16 -10
  88. package/src/renderers/browser/app-services.ts +3 -1
  89. package/src/renderers/dom/data-table/dom.ts +31 -0
  90. package/src/renderers/dom/data-table/index.tsx +5 -0
  91. package/src/renderers/dom/host/box.tsx +113 -17
  92. package/src/renderers/dom/host/drag-shield.ts +24 -0
  93. package/src/renderers/dom/host/live-frame.ts +54 -0
  94. package/src/renderers/dom/host/mouse.ts +8 -1
  95. package/src/renderers/dom/host/scroll-box.tsx +6 -5
  96. package/src/renderers/dom/host/tabs.tsx +3 -2
  97. package/src/renderers/dom/styles.css +13 -5
  98. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +7 -0
  99. package/src/renderers/electrobun/bun/desktop/backend.ts +2 -0
  100. package/src/renderers/electrobun/bun/desktop/initialization.ts +13 -0
  101. package/src/renderers/electrobun/shared/protocol.ts +10 -1
  102. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +3 -0
  103. package/src/renderers/electrobun/view/host/native.ts +0 -10
  104. package/src/renderers/electrobun/view/host/window-move.ts +28 -0
  105. package/src/renderers/electrobun/view/remote/persistence.ts +15 -2
  106. package/src/renderers/electrobun/view/ui-host.tsx +2 -5
  107. package/src/renderers/opentui/ui-host.tsx +7 -1
  108. package/src/sources/gloomberb-cloud/normalizers.ts +16 -20
  109. package/src/sources/provider-router/batches.ts +3 -3
  110. package/src/sources/provider-router/financial-routes.ts +2 -2
  111. package/src/sources/provider-router/financials.ts +70 -7
  112. package/src/sources/provider-router/primary.ts +2 -2
  113. package/src/state/pane-scroll-registry.tsx +67 -35
  114. package/src/test-support/margin-account.ts +108 -0
  115. package/src/time-series/resolution.ts +15 -0
  116. package/src/types/financials.ts +32 -3
  117. package/src/ui/host.tsx +12 -0
  118. package/src/ui/index.tsx +8 -3
  119. package/src/ui/scroll-box-axis.ts +26 -0
  120. package/src/utils/currency-units.ts +8 -3
  121. package/src/utils/depositary-receipt.ts +54 -10
  122. package/src/utils/display-number.ts +63 -0
  123. package/src/utils/fundamentals.ts +17 -1
  124. package/src/utils/price-history-integrity.ts +19 -6
package/src/ui/index.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createElement, forwardRef, useCallback, useRef, type ComponentProps } from "react";
2
2
  import { useForwardedScrollBoxRef, useRegisterPaneScrollBox } from "../state/pane-scroll-registry";
3
3
  import { useUiHost, type UiHost } from "./host";
4
+ import { scrollBoxScrollsVertically } from "./scroll-box-axis";
4
5
  import { useRemoteUiNode } from "../remote/semantic-tree";
5
6
  import { assignRef } from "../react/assign-ref";
6
7
  import {
@@ -48,6 +49,7 @@ export type {
48
49
  MediaSurfaceHandle,
49
50
  MediaSurfaceProps,
50
51
  InputRenderable,
52
+ LiveBoxFrame,
51
53
  NativeCursorState,
52
54
  NativeRendererHost,
53
55
  NativePostProcessFn,
@@ -128,8 +130,11 @@ export const ScrollBox = forwardRef<any, ComponentProps<UiHost["ScrollBox"]>>((p
128
130
  const { ScrollBox: HostScrollBox } = useUiHost();
129
131
  const localRef = useForwardedScrollBoxRef<any>(ref);
130
132
  const rawProps = props as Record<string, unknown>;
133
+ // The same rule the hosts scroll by: a ScrollBox that omits scrollY scrolls,
134
+ // so it is a scrollbox to the remote tree and to the pane scroll keys.
135
+ const scrollsVertically = scrollBoxScrollsVertically(props);
131
136
  const remoteNodeId = useRemoteUiNode(
132
- props.scrollY === true
137
+ scrollsVertically
133
138
  ? {
134
139
  role: remotePropRole(rawProps, "scrollbox"),
135
140
  label: remotePropLabel(rawProps),
@@ -150,7 +155,7 @@ export const ScrollBox = forwardRef<any, ComponentProps<UiHost["ScrollBox"]>>((p
150
155
  ...remoteMetadataFromProps(rawProps),
151
156
  width: rawProps.width,
152
157
  height: rawProps.height,
153
- scrollY: props.scrollY === true,
158
+ scrollY: true,
154
159
  scrollTop: localRef.current?.scrollTop,
155
160
  viewportHeight: localRef.current?.viewport?.height,
156
161
  },
@@ -158,7 +163,7 @@ export const ScrollBox = forwardRef<any, ComponentProps<UiHost["ScrollBox"]>>((p
158
163
  : null,
159
164
  );
160
165
  useRegisterPaneScrollBox(localRef, {
161
- enabled: props.scrollY === true,
166
+ enabled: scrollsVertically,
162
167
  onScrollActivity: typeof props.onMouseScroll === "function"
163
168
  ? props.onMouseScroll as (event: { scroll: { direction: "up" | "down"; delta: number } }) => void
164
169
  : undefined,
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Which axes a ScrollBox scrolls on, decided from its props. The terminal and
3
+ * DOM hosts and the pane keyboard scroll registration all read it here, so a
4
+ * ScrollBox that omits `scrollY` behaves the same everywhere: it scrolls
5
+ * vertically, with the wheel and with the arrows, PageUp, PageDown, Home and
6
+ * End. Pass `scrollY={false}` to turn that off.
7
+ */
8
+ interface ScrollBoxAxisProps {
9
+ scrollX?: unknown;
10
+ scrollY?: unknown;
11
+ height?: unknown;
12
+ }
13
+
14
+ /**
15
+ * A one-row strip that only scrolls sideways: tab bars, table headers and
16
+ * chart legends. It is not a vertical scroller even though `scrollY` is
17
+ * omitted.
18
+ */
19
+ export function isHeaderStripScrollBox(props: ScrollBoxAxisProps): boolean {
20
+ return props.scrollX === true && props.scrollY !== true && props.height === 1;
21
+ }
22
+
23
+ /** Whether the ScrollBox scrolls vertically: unless `scrollY` is false, and never a header strip. */
24
+ export function scrollBoxScrollsVertically(props: ScrollBoxAxisProps): boolean {
25
+ return props.scrollY !== false && !isHeaderStripScrollBox(props);
26
+ }
@@ -13,11 +13,16 @@ const SUB_UNIT_NAMES: Record<string, string> = {
13
13
  ZAc: "cents",
14
14
  };
15
15
 
16
- /** A currency code as a reader should see it; a sub-unit says which one: "GBp (pence)". */
17
- export function currencyUnitLabel(code?: string | null): string {
16
+ /**
17
+ * A currency code as a reader should see it; a sub-unit says which one,
18
+ * "GBp (pence)", and with `ofMajor` what it divides: "ILA (agorot, 1/100 ILS)".
19
+ */
20
+ export function currencyUnitLabel(code?: string | null, { ofMajor = false }: { ofMajor?: boolean } = {}): string {
18
21
  const raw = (code ?? "").trim();
19
22
  const unit = SUB_UNIT_NAMES[raw];
20
- return unit ? `${raw} (${unit})` : raw;
23
+ const major = SUB_UNIT_CURRENCIES[raw];
24
+ if (!unit) return raw;
25
+ return ofMajor && major ? `${raw} (${unit}, 1/${major.divisor} ${major.currency})` : `${raw} (${unit})`;
21
26
  }
22
27
 
23
28
  const LIKELY_UNIT_MISMATCH_RATIOS: Record<string, number[]> = {
@@ -1,26 +1,70 @@
1
+ import type { Fundamentals, Quote, ShareBasis } from "../types/financials";
2
+
1
3
  // A word of its own, so a company called "ADS-TEC" is not one.
2
4
  const DEPOSITARY_NAME = /(?:^|\s)(?:ADRs?|ADSs?|American depositary (?:receipts?|shares?))(?=$|[\s,)])/i;
3
5
  const DEPOSITARY_TYPES = new Set(["ADR", "ADS", "DEPOSITARYRECEIPT"]);
4
6
  /** A profile that opens by naming the security: "Toyota Motor Corporation Sponsored ADR is ...". */
5
7
  const DEPOSITARY_SUBJECT = /^.{0,160}?\s(?:ADRs?|ADSs?|American depositary (?:receipts?|shares?)) is\s/i;
6
8
 
7
- /**
8
- * Whether a US listing is a depositary receipt, as far as its data says. A
9
- * quote carries no ADR flag or ratio, so this reads the instrument type, the
10
- * name, and the security name a profile description opens with. Its share
11
- * count and market cap are then counted in receipts, not ordinary shares. A
12
- * listing in another currency is the ordinary line even when its profile
13
- * mentions the receipts.
14
- */
15
- export function isDepositaryReceipt(input: {
9
+ interface ListingEvidence {
10
+ /** Gloom Cloud's verdict; absent when it has no evidence either way. */
11
+ isDepositaryReceipt?: boolean | null;
16
12
  instrumentType?: string | null;
17
13
  name?: string | null;
18
14
  currency?: string | null;
19
15
  description?: string | null;
20
- }): boolean {
16
+ }
17
+
18
+ /**
19
+ * Whether a listing is a depositary receipt. Gloom Cloud's flag decides when
20
+ * it is set. Without it (an older service, or a listing it has no evidence
21
+ * for) this reads the instrument type, the name, and the security name a
22
+ * profile description opens with. A listing in another currency is the
23
+ * ordinary line even when its profile mentions the receipts.
24
+ */
25
+ export function isDepositaryReceipt(input: ListingEvidence): boolean {
26
+ if (typeof input.isDepositaryReceipt === "boolean") return input.isDepositaryReceipt;
21
27
  const currency = input.currency?.trim().toUpperCase();
22
28
  if (currency && currency !== "USD") return false;
23
29
  if (DEPOSITARY_TYPES.has((input.instrumentType ?? "").replace(/[\s_-]/g, "").toUpperCase())) return true;
24
30
  if (input.name && DEPOSITARY_NAME.test(input.name)) return true;
25
31
  return DEPOSITARY_SUBJECT.test(input.description?.trim() ?? "");
26
32
  }
33
+
34
+ /**
35
+ * Whether a record's share counts are in receipts. Gloom Cloud says so with
36
+ * `shareBasis`; without it, a receipt's counts are read as receipts, as its
37
+ * market cap is.
38
+ */
39
+ function sharesInReceipts(input: ListingEvidence & { shareBasis?: ShareBasis | null }): boolean {
40
+ if (input.shareBasis === "depositary_receipt") return true;
41
+ if (input.shareBasis === "ordinary") return false;
42
+ return isDepositaryReceipt(input);
43
+ }
44
+
45
+ /**
46
+ * Whether a listing's shares outstanding are counted in receipts: the
47
+ * fundamentals' basis, else the listing's flag (the fundamentals' or the
48
+ * quote's), else what the quote and profile say.
49
+ */
50
+ export function sharesOutstandingInReceipts(
51
+ quote: Pick<Quote, "isDepositaryReceipt" | "instrumentType" | "name" | "currency"> | null | undefined,
52
+ fundamentals: Pick<Fundamentals, "isDepositaryReceipt" | "shareBasis"> | null | undefined,
53
+ description?: string | null,
54
+ ): boolean {
55
+ return sharesInReceipts({
56
+ instrumentType: quote?.instrumentType,
57
+ name: quote?.name,
58
+ currency: quote?.currency,
59
+ description,
60
+ isDepositaryReceipt: fundamentals?.isDepositaryReceipt ?? quote?.isDepositaryReceipt,
61
+ shareBasis: fundamentals?.shareBasis,
62
+ });
63
+ }
64
+
65
+ /** "1 ADR = 2 ordinary shares", or null without a usable ratio. */
66
+ export function adrRatioText(ratio: number | null | undefined): string | null {
67
+ if (typeof ratio !== "number" || !Number.isFinite(ratio) || ratio <= 0) return null;
68
+ const count = Number.isInteger(ratio) ? String(ratio) : String(Number(ratio.toPrecision(6)));
69
+ return `1 ADR = ${count} ordinary share${ratio === 1 ? "" : "s"}`;
70
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Reading back a number a table or report drew ("+1.25%", "1.20B", "$1,234.50"),
3
+ * so a CSV export can write the number a spreadsheet uses and name the unit
4
+ * in its header instead of in every cell.
5
+ */
6
+
7
+ /** What a cell draws for a missing value: a dash, a run of dashes, or the loading ellipsis. */
8
+ export const DISPLAY_PLACEHOLDER = /^(?:[-\u2013\u2014]{1,3}|\u2026|\.\.\.)$/;
9
+
10
+ /**
11
+ * A number as a table draws it: sign, currency symbol, thousands commas,
12
+ * decimals, a compact scale (12.3k, 1.20B) and a unit (%, x, bp, USD, or c
13
+ * for the cents grain futures quote in).
14
+ */
15
+ const DISPLAY_NUMBER = /^([+-]?)([A-Z]{0,3}[$\u20ac\u00a3\u00a5\u20b9\u20a9\u20bd\u20ba\u20aa\u20ab\u0e3f])?(?=\.?\d)(\d{1,3}(?:,\d{3})+|\d*)(?:\.(\d+))?([kKMBT])?(%|x|c|\u00a2| ?bps?| [A-Z]{3})?$/;
16
+
17
+ const SCALE_DIGITS: Record<string, number> = { k: 3, K: 3, M: 6, B: 9, T: 12 };
18
+
19
+ export interface DisplayNumber {
20
+ /** Plain decimal text: no sign for positives, no commas, no scale letter. */
21
+ number: string;
22
+ /** What the column header has to name once the cells are bare numbers. */
23
+ unit: string;
24
+ /** The place value of the last digit drawn: 0.01 for "1.25%", 10,000,000 for "1.20B". */
25
+ step: number;
26
+ }
27
+
28
+ export const BLANK_DISPLAY = "blank";
29
+
30
+ /** Moves the decimal point right without going through a float, so 1.23B is exactly 1230000000. */
31
+ function scaleDecimal(integer: string, fraction: string, digits: number): string {
32
+ const whole = `${integer}${fraction.padEnd(digits, "0").slice(0, digits)}`.replace(/^0+(?=\d)/, "") || "0";
33
+ const rest = fraction.slice(digits);
34
+ return rest ? `${whole}.${rest}` : whole;
35
+ }
36
+
37
+ export function parseDisplayNumber(text: string): DisplayNumber | typeof BLANK_DISPLAY | null {
38
+ // A direction arrow beside a signed move repeats the sign.
39
+ const trimmed = text.trim().replace(/\u2212/g, "-").replace(/\s*[\u2191\u2193]$/, "");
40
+ if (!trimmed || DISPLAY_PLACEHOLDER.test(trimmed)) return BLANK_DISPLAY;
41
+ const match = DISPLAY_NUMBER.exec(trimmed);
42
+ if (!match) return null;
43
+ const [, sign, currency = "", grouped, fraction = "", scale, unitText = ""] = match;
44
+ const integer = grouped!.replace(/,/g, "") || "0";
45
+ const digits = scale ? SCALE_DIGITS[scale]! : 0;
46
+ const magnitude = digits ? scaleDecimal(integer, fraction, digits) : fraction ? `${integer}.${fraction}` : integer;
47
+ const unit = unitText.trim().replace(/^bps$/, "bp").replace(/^\u00a2$/, "c");
48
+ return {
49
+ number: `${sign === "-" ? "-" : ""}${magnitude}`,
50
+ unit: [currency, unit].filter(Boolean).join(" "),
51
+ step: 10 ** (digits - fraction.length),
52
+ };
53
+ }
54
+
55
+ /** Adds the unit the cells dropped, unless the label already names it. */
56
+ export function labelWithUnit(label: string, unit: string): string {
57
+ if (!unit) return label;
58
+ // A word unit has to stand alone: "Max" does not name x.
59
+ const named = unit === "bp" ? /\bbps?\b/i.test(label)
60
+ : /^[A-Za-z]+$/.test(unit) ? new RegExp(`\\b${unit}\\b`, "i").test(label)
61
+ : label.includes(unit);
62
+ return named ? label : `${label} (${unit})`;
63
+ }
@@ -1,4 +1,4 @@
1
- import type { Fundamentals } from "../types/financials";
1
+ import type { Fundamentals, TickerFinancials } from "../types/financials";
2
2
 
3
3
  export const RETRACTABLE_VALUATION_FIELDS = ["enterpriseValue", "enterpriseToRevenue"] as const;
4
4
 
@@ -13,3 +13,19 @@ export function redactUnavailableFundamentals(value: Fundamentals | undefined):
13
13
  for (const field of unavailableFields) delete result[field];
14
14
  return result;
15
15
  }
16
+
17
+ /** A source's enterprise value, or undefined when it has none: banks and insurers come back as 0, which is a gap, not a value. */
18
+ export function reportedEnterpriseValue(fundamentals: Pick<Fundamentals, "enterpriseValue"> | undefined): number | undefined {
19
+ const value = fundamentals?.enterpriseValue;
20
+ return typeof value === "number" && Number.isFinite(value) && value !== 0 ? value : undefined;
21
+ }
22
+
23
+ /**
24
+ * The currency of the trailing EPS, revenue, income and cash flows: the one the fundamentals declare, else the
25
+ * one the statements report in. Never the quote's, which a depositary receipt can differ from (USD quote, TWD books).
26
+ */
27
+ export function fundamentalsCurrency(
28
+ financials: Pick<TickerFinancials, "fundamentals" | "financialCurrency"> | null | undefined,
29
+ ): string | undefined {
30
+ return financials?.fundamentals?.financialCurrency?.trim() || financials?.financialCurrency?.trim() || undefined;
31
+ }
@@ -27,13 +27,26 @@ function above(left: number, right: number): boolean {
27
27
  return left - right > 8 * Number.EPSILON * Math.max(Math.abs(left), Math.abs(right));
28
28
  }
29
29
 
30
- /** Missing OHLC fields are not contradictory: close-only histories remain usable. */
31
- export function pricePointIntegrity(point: PricePoint): PriceHistoryIntegrity | undefined {
30
+ /**
31
+ * Each way a bar's prices contradict each other, as short codes such as
32
+ * `high<open` or `low>close`; empty when they agree. Missing OHLC fields are
33
+ * not contradictory: close-only histories remain usable.
34
+ */
35
+ export function pricePointIntegrityFlags(point: PricePoint): string[] {
32
36
  const { open, high, low, close } = point;
33
- const invalid = (isFiniteNumber(high) && isFiniteNumber(low) && above(low, high))
34
- || [open, close].some((value) => isFiniteNumber(value)
35
- && ((isFiniteNumber(high) && above(value, high)) || (isFiniteNumber(low) && above(low, value))));
36
- if (!invalid) return undefined;
37
+ const flags: string[] = [];
38
+ for (const [name, value] of [["open", open], ["close", close]] as const) {
39
+ if (isFiniteNumber(value) && isFiniteNumber(high) && above(value, high)) flags.push(`high<${name}`);
40
+ }
41
+ for (const [name, value] of [["open", open], ["close", close]] as const) {
42
+ if (isFiniteNumber(value) && isFiniteNumber(low) && above(low, value)) flags.push(`low>${name}`);
43
+ }
44
+ if (isFiniteNumber(high) && isFiniteNumber(low) && above(low, high)) flags.push("low>high");
45
+ return flags;
46
+ }
47
+
48
+ export function pricePointIntegrity(point: PricePoint): PriceHistoryIntegrity | undefined {
49
+ if (pricePointIntegrityFlags(point).length === 0) return undefined;
37
50
  const date = new Date(point.date);
38
51
  return Object.freeze({
39
52
  reason: "inconsistent-ohlc",