gloomberb 0.9.3 → 0.9.5

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 (176) hide show
  1. package/README.md +1 -0
  2. package/package.json +3 -2
  3. package/patches/@opentui%2Fcore@0.3.2.patch +231 -29
  4. package/patches/react-reconciler@0.33.0.patch +10 -0
  5. package/src/app/runtime/desktop-menu.ts +5 -1
  6. package/src/app/runtime/ticker-refresh.ts +4 -1
  7. package/src/app.tsx +10 -1
  8. package/src/brokers/account-cache.ts +1 -1
  9. package/src/brokers/sync-broker-instance.test.ts +107 -0
  10. package/src/brokers/sync-broker-instance.ts +22 -10
  11. package/src/cli/desktop-pane-shot.ts +2 -1
  12. package/src/cli/entry.ts +8 -1
  13. package/src/cli/native-smoke.ts +5 -0
  14. package/src/cli/pane-functions/screenshot.ts +13 -1
  15. package/src/components/chart/bar-chart-renderer.test.ts +108 -3
  16. package/src/components/chart/bar-chart-renderer.ts +226 -27
  17. package/src/components/chart/core/pointer.ts +32 -6
  18. package/src/components/chart/native/bitmap-support.test.ts +27 -0
  19. package/src/components/chart/native/bitmap-support.ts +55 -0
  20. package/src/components/chart/native/kitty/index.test.ts +47 -0
  21. package/src/components/chart/native/surface/visibility.ts +8 -1
  22. package/src/components/chart/price-axis-labels.tsx +3 -1
  23. package/src/components/chart/static/bar-chart-surface.test.tsx +97 -0
  24. package/src/components/chart/static/bar-chart-surface.tsx +151 -28
  25. package/src/components/chart/static/chart/axis-overlays.tsx +243 -0
  26. package/src/components/chart/static/chart/bitmap.ts +16 -9
  27. package/src/components/chart/static/chart/surface.test.tsx +92 -0
  28. package/src/components/chart/static/chart/surface.tsx +167 -24
  29. package/src/components/chart/stock-chart/index.test.ts +25 -0
  30. package/src/components/command-bar/layout-items/current-layout.ts +5 -1
  31. package/src/components/command-bar/list/view.tsx +1 -1
  32. package/src/components/command-bar/theme-picker.tsx +1 -1
  33. package/src/components/data-table/view.tsx +0 -12
  34. package/src/components/layout/detached-pane-shell.tsx +6 -3
  35. package/src/components/layout/floating-pane.tsx +6 -5
  36. package/src/components/layout/pane/content.tsx +20 -8
  37. package/src/components/layout/pane/footer/registration.tsx +6 -2
  38. package/src/components/layout/pane/frame.tsx +4 -4
  39. package/src/components/layout/pane/header.tsx +45 -4
  40. package/src/components/layout/pane/index.tsx +6 -4
  41. package/src/components/layout/pane/sizing.test.ts +43 -0
  42. package/src/components/layout/pane/sizing.ts +13 -4
  43. package/src/components/layout/shell/action-menu-overlay.tsx +1 -1
  44. package/src/components/layout/shell/cursor-occlusion.ts +204 -0
  45. package/src/components/layout/shell/drag/runtime.ts +9 -2
  46. package/src/components/layout/shell/index.test.tsx +92 -2
  47. package/src/components/layout/shell/index.tsx +45 -3
  48. package/src/components/layout/shell/layout-state.test.ts +33 -0
  49. package/src/components/layout/shell/layout-state.ts +4 -9
  50. package/src/components/layout/shell/pane/layers.tsx +10 -4
  51. package/src/components/layout/shell/visible-layout.ts +17 -0
  52. package/src/components/layout/status-bar.test.tsx +6 -1
  53. package/src/components/layout/status-bar.tsx +10 -6
  54. package/src/components/onboarding/onboarding-steps.tsx +8 -28
  55. package/src/components/onboarding/onboarding-wizard.test.tsx +13 -12
  56. package/src/components/onboarding/onboarding-wizard.tsx +2 -4
  57. package/src/components/onboarding/portfolio-step/index.tsx +0 -12
  58. package/src/components/onboarding/portfolio-step/types.ts +1 -3
  59. package/src/components/onboarding/wizard-keyboard.ts +2 -5
  60. package/src/components/pane-settings-dialog/desktop.tsx +1 -1
  61. package/src/components/price-performance.tsx +1 -1
  62. package/src/components/price-sparkline/view.tsx +15 -14
  63. package/src/components/speedometer-gauge/terminal.tsx +18 -15
  64. package/src/components/table-view-shared.tsx +12 -14
  65. package/src/components/ticker/list-table-view.tsx +1 -7
  66. package/src/components/ticker/list-table.test.tsx +0 -4
  67. package/src/components/ui/data-table/opentui/index.tsx +29 -22
  68. package/src/components/ui/data-table/types.ts +10 -4
  69. package/src/components/ui/fields.tsx +10 -3
  70. package/src/components/ui/list-view.tsx +1 -1
  71. package/src/components/ui/page-stack-view.tsx +6 -2
  72. package/src/components/ui/table-layout.test.ts +25 -0
  73. package/src/components/ui/table-layout.ts +22 -6
  74. package/src/components/ui/tabs.tsx +27 -12
  75. package/src/components/ui/ui.test.tsx +87 -9
  76. package/src/core/state/app/layout-reducer.ts +2 -2
  77. package/src/plugins/builtin/account-management/form-components.tsx +2 -2
  78. package/src/plugins/builtin/alerts/pane.tsx +1 -1
  79. package/src/plugins/builtin/analytics/index.test.tsx +6 -2
  80. package/src/plugins/builtin/analytics/pane-model.ts +43 -8
  81. package/src/plugins/builtin/buildout/cells.tsx +1 -1
  82. package/src/plugins/builtin/buildout/pane/index.tsx +1 -1
  83. package/src/plugins/builtin/chat/message/desktop.tsx +1 -1
  84. package/src/plugins/builtin/chat/message/inline-tokens.tsx +1 -1
  85. package/src/plugins/builtin/chat/message/terminal.tsx +2 -2
  86. package/src/plugins/builtin/chat/sidebar.tsx +1 -1
  87. package/src/plugins/builtin/chat/status-widget.tsx +1 -1
  88. package/src/plugins/builtin/cloud/auth-actions.tsx +2 -2
  89. package/src/plugins/builtin/correlation/matrix/symbol-cell.tsx +1 -1
  90. package/src/plugins/builtin/debug/index.tsx +3 -4
  91. package/src/plugins/builtin/help/components.tsx +1 -1
  92. package/src/plugins/builtin/holders/treemap.tsx +1 -1
  93. package/src/plugins/builtin/kelly-sizer/constants.ts +3 -0
  94. package/src/plugins/builtin/kelly-sizer/fields.ts +214 -0
  95. package/src/plugins/builtin/kelly-sizer/index.test.tsx +197 -0
  96. package/src/plugins/builtin/kelly-sizer/index.tsx +47 -0
  97. package/src/plugins/builtin/kelly-sizer/model.test.ts +152 -0
  98. package/src/plugins/builtin/kelly-sizer/model.ts +580 -0
  99. package/src/plugins/builtin/kelly-sizer/pane.tsx +558 -0
  100. package/src/plugins/builtin/kelly-sizer/portfolio.ts +71 -0
  101. package/src/plugins/builtin/kelly-sizer/sections.tsx +142 -0
  102. package/src/plugins/builtin/kelly-sizer/state.ts +67 -0
  103. package/src/plugins/builtin/kelly-sizer/test-support.tsx +174 -0
  104. package/src/plugins/builtin/kelly-sizer/types.ts +156 -0
  105. package/src/plugins/builtin/kelly-sizer/view.test.tsx +77 -0
  106. package/src/plugins/builtin/kelly-sizer/view.tsx +375 -0
  107. package/src/plugins/builtin/layout-manager/index.tsx +5 -1
  108. package/src/plugins/builtin/options/table.ts +5 -5
  109. package/src/plugins/builtin/options/view.test.tsx +141 -4
  110. package/src/plugins/builtin/options/view.tsx +76 -15
  111. package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +26 -1
  112. package/src/plugins/builtin/portfolio-list/account-metrics.ts +14 -0
  113. package/src/plugins/builtin/portfolio-list/index.test.ts +23 -0
  114. package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -0
  115. package/src/plugins/builtin/portfolio-list/summary/index.test.ts +59 -1
  116. package/src/plugins/builtin/portfolio-list/summary/index.tsx +36 -5
  117. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +149 -48
  118. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +25 -2
  119. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +5 -4
  120. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +16 -0
  121. package/src/plugins/builtin/ticker-detail/index.test.tsx +143 -0
  122. package/src/plugins/catalog-ui.ts +2 -0
  123. package/src/plugins/ibkr/broker-adapter.ts +19 -0
  124. package/src/plugins/ibkr/config.test.ts +31 -2
  125. package/src/plugins/ibkr/config.ts +9 -2
  126. package/src/plugins/ibkr/flex/index.test.ts +18 -9
  127. package/src/plugins/ibkr/flex/index.ts +30 -0
  128. package/src/plugins/ibkr/flex/statement-client.ts +68 -14
  129. package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
  130. package/src/plugins/ibkr/gateway/account-summary.ts +1 -0
  131. package/src/plugins/ibkr/gateway/service/index.test.ts +8 -0
  132. package/src/plugins/ibkr/trade/ticket-panel.tsx +1 -1
  133. package/src/plugins/pane-manager/gridlock.ts +13 -5
  134. package/src/plugins/pane-manager/layout-state.ts +25 -0
  135. package/src/plugins/pane-manager.test.ts +33 -0
  136. package/src/plugins/pane-manager.ts +6 -1
  137. package/src/plugins/prediction-markets/chart.tsx +10 -4
  138. package/src/plugins/prediction-markets/detail/book.tsx +141 -96
  139. package/src/plugins/prediction-markets/detail/overview.tsx +3 -0
  140. package/src/plugins/prediction-markets/detail/pane.tsx +71 -64
  141. package/src/plugins/prediction-markets/detail/shared.tsx +19 -0
  142. package/src/plugins/prediction-markets/detail/trades.tsx +64 -19
  143. package/src/plugins/prediction-markets/detail.test.tsx +50 -0
  144. package/src/plugins/prediction-markets/pane.test.tsx +43 -0
  145. package/src/plugins/prediction-markets/pane.tsx +50 -22
  146. package/src/plugins/prediction-markets/test-helpers.tsx +41 -23
  147. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +13 -2
  148. package/src/renderers/electrobun/view/data-table/index.tsx +19 -12
  149. package/src/renderers/electrobun/view/data-table/row.tsx +32 -18
  150. package/src/renderers/electrobun/view/desktop/controls.tsx +11 -5
  151. package/src/renderers/electrobun/view/host/box.tsx +6 -1
  152. package/src/renderers/electrobun/view/host/input.tsx +115 -18
  153. package/src/renderers/electrobun/view/host/style.ts +1 -0
  154. package/src/renderers/electrobun/view/host/tabs.tsx +7 -2
  155. package/src/renderers/electrobun/view/styles.css +18 -0
  156. package/src/renderers/opentui/host.tsx +17 -1
  157. package/src/renderers/opentui/native-smoke.ts +1 -0
  158. package/src/renderers/opentui/ui-host.tsx +57 -25
  159. package/src/sources/provider-router/batches.ts +16 -2
  160. package/src/sources/provider-router/financial-routes.ts +5 -0
  161. package/src/sources/provider-router/financials.ts +11 -23
  162. package/src/sources/provider-router/index.test.ts +189 -0
  163. package/src/sources/provider-router/primary.ts +3 -2
  164. package/src/sources/sec-edgar.test.ts +83 -0
  165. package/src/sources/sec-edgar.ts +231 -0
  166. package/src/sources/yahoo-finance.test.ts +51 -0
  167. package/src/sources/yahoo-finance.ts +49 -1
  168. package/src/state/app/context/index.test.tsx +5 -8
  169. package/src/types/broker.ts +1 -0
  170. package/src/types/config.ts +10 -0
  171. package/src/types/trading.ts +2 -0
  172. package/src/ui/host.tsx +16 -0
  173. package/src/ui/index.tsx +2 -0
  174. package/src/utils/financial-statements.ts +23 -0
  175. package/src/version.ts +1 -1
  176. package/src/components/onboarding/portfolio-step/manual-name-panel.tsx +0 -62
@@ -1,6 +1,7 @@
1
1
  import type { CachedResourceRecord } from "../../data/resource-store";
2
2
  import type { AnalystResearchData, CorporateActionsData, FinancialStatement, Quote, TickerFinancials } from "../../types/financials";
3
3
  import { hasLikelyQuoteUnitMismatch } from "../../utils/currency-units";
4
+ import { mergeFinancialStatementRows } from "../../utils/financial-statements";
4
5
  import { normalizePriceHistory, normalizeTickerFinancialsPriceHistory } from "../../utils/price-history";
5
6
  import { isQuoteStaleForCurrentSession } from "../../market-data/quotes/freshness";
6
7
  import {
@@ -122,33 +123,20 @@ export function hasDetailedStatementRows(data: TickerFinancials | null | undefin
122
123
  return rows.some((row) => DETAILED_STATEMENT_KEYS.some((key) => typeof row[key] === "number"));
123
124
  }
124
125
 
125
- function mergeStatementRows(
126
- primaryRows: FinancialStatement[],
127
- fallbackRows: FinancialStatement[],
128
- ): FinancialStatement[] {
129
- if (primaryRows.length === 0) return fallbackRows;
130
- if (fallbackRows.length === 0) return primaryRows;
131
-
132
- const fallbackByDate = new Map(fallbackRows.map((row) => [row.date, row]));
133
- const primaryDates = new Set(primaryRows.map((row) => row.date));
134
- const mergedRows = primaryRows.map((row) => ({
135
- ...fallbackByDate.get(row.date),
136
- ...row,
137
- date: row.date,
138
- }));
139
-
140
- for (const row of fallbackRows) {
141
- if (!primaryDates.has(row.date)) mergedRows.push(row);
142
- }
126
+ export function hasDeepStatementHistory(data: TickerFinancials | null | undefined): boolean {
127
+ if (!data) return false;
128
+ return data.annualStatements.length >= 5 || data.quarterlyStatements.length >= 8;
129
+ }
143
130
 
144
- return mergedRows.sort((left, right) => left.date.localeCompare(right.date));
131
+ export function hasShallowStatementHistory(data: TickerFinancials | null | undefined): boolean {
132
+ return hasStatementRows(data) && !hasDeepStatementHistory(data);
145
133
  }
146
134
 
147
135
  export function mergeMissingStatementArrays(primary: TickerFinancials, fallback: TickerFinancials): TickerFinancials {
148
136
  return {
149
137
  ...primary,
150
- annualStatements: mergeStatementRows(primary.annualStatements, fallback.annualStatements),
151
- quarterlyStatements: mergeStatementRows(primary.quarterlyStatements, fallback.quarterlyStatements),
138
+ annualStatements: mergeFinancialStatementRows(primary.annualStatements, fallback.annualStatements),
139
+ quarterlyStatements: mergeFinancialStatementRows(primary.quarterlyStatements, fallback.quarterlyStatements),
152
140
  };
153
141
  }
154
142
 
@@ -216,8 +204,8 @@ export function mergeFinancials(primary: TickerFinancials | null, fallback: Tick
216
204
  profile: mergeDefinedObject(primary.profile, fallback.profile),
217
205
  fundamentals: mergeDefinedObject(primary.fundamentals, fallback.fundamentals),
218
206
  priceHistory: normalizePriceHistory(dominant.priceHistory.length > 0 ? dominant.priceHistory : secondary.priceHistory),
219
- annualStatements: mergeStatementRows(primary.annualStatements, fallback.annualStatements),
220
- quarterlyStatements: mergeStatementRows(primary.quarterlyStatements, fallback.quarterlyStatements),
207
+ annualStatements: mergeFinancialStatementRows(primary.annualStatements, fallback.annualStatements),
208
+ quarterlyStatements: mergeFinancialStatementRows(primary.quarterlyStatements, fallback.quarterlyStatements),
221
209
  });
222
210
  }
223
211
 
@@ -1069,6 +1069,195 @@ describe("AssetDataRouter", () => {
1069
1069
  expect(yahooCalls).toBe(1);
1070
1070
  });
1071
1071
 
1072
+ test("supplements shallow preferred provider statement history", async () => {
1073
+ const cloudProvider: DataProvider = {
1074
+ ...fallbackProvider,
1075
+ id: "cloud",
1076
+ name: "Cloud",
1077
+ priority: 100,
1078
+ async getTickerFinancials() {
1079
+ return makeFinancials({
1080
+ profile: { sector: "Technology" },
1081
+ quote: makeQuote({ symbol: "LINK", price: 25 }),
1082
+ quarterlyStatements: [
1083
+ { date: "2025-03-31", operatingCashFlow: -271_000 },
1084
+ { date: "2025-06-30", operatingCashFlow: -138_000 },
1085
+ { date: "2025-09-30", operatingCashFlow: 653_000 },
1086
+ { date: "2025-12-31", operatingCashFlow: -356_000 },
1087
+ { date: "2026-03-31", operatingCashFlow: -543_000 },
1088
+ ],
1089
+ });
1090
+ },
1091
+ };
1092
+ let yahooCalls = 0;
1093
+ const yahooProvider: DataProvider = {
1094
+ ...fallbackProvider,
1095
+ id: "yahoo",
1096
+ name: "Yahoo",
1097
+ priority: 1000,
1098
+ async getTickerFinancials() {
1099
+ yahooCalls += 1;
1100
+ return makeFinancials({
1101
+ quarterlyStatements: [
1102
+ { date: "2024-03-31", operatingCashFlow: -601_000 },
1103
+ { date: "2024-06-30", operatingCashFlow: -488_000 },
1104
+ { date: "2024-09-30", operatingCashFlow: -204_000 },
1105
+ { date: "2024-12-31", operatingCashFlow: 122_000 },
1106
+ ],
1107
+ });
1108
+ },
1109
+ };
1110
+
1111
+ const router = new AssetDataRouter(yahooProvider, [cloudProvider]);
1112
+ const merged = await router.getTickerFinancials("LINK", "NASDAQ");
1113
+
1114
+ expect(yahooCalls).toBe(1);
1115
+ expect(merged.profile?.sector).toBe("Technology");
1116
+ expect(merged.quote?.price).toBe(25);
1117
+ expect(merged.quarterlyStatements.map((row) => row.date)).toEqual([
1118
+ "2024-03-31",
1119
+ "2024-06-30",
1120
+ "2024-09-30",
1121
+ "2024-12-31",
1122
+ "2025-03-31",
1123
+ "2025-06-30",
1124
+ "2025-09-30",
1125
+ "2025-12-31",
1126
+ "2026-03-31",
1127
+ ]);
1128
+ });
1129
+
1130
+ test("enriches shallow batch provider statement history through the single financial route", async () => {
1131
+ const shallowCloudRows = [
1132
+ { date: "2025-03-31", eps: 0.44 },
1133
+ { date: "2025-06-30", eps: 0.54 },
1134
+ { date: "2025-09-30", eps: 0.75 },
1135
+ { date: "2025-12-31", eps: 0.92 },
1136
+ { date: "2026-03-31", eps: 0.84 },
1137
+ ];
1138
+ let cloudBatchCalls = 0;
1139
+ let cloudSingleCalls = 0;
1140
+ const cloudProvider: DataProvider = {
1141
+ ...fallbackProvider,
1142
+ id: "cloud",
1143
+ name: "Cloud",
1144
+ priority: 100,
1145
+ async getTickerFinancials() {
1146
+ cloudSingleCalls += 1;
1147
+ return makeFinancials({
1148
+ profile: { sector: "Technology" },
1149
+ quote: makeQuote({ symbol: "AMD", price: 125 }),
1150
+ quarterlyStatements: shallowCloudRows,
1151
+ });
1152
+ },
1153
+ async getTickerFinancialsBatch(targets) {
1154
+ cloudBatchCalls += 1;
1155
+ return targets.map((target) => ({
1156
+ target,
1157
+ financials: makeFinancials({
1158
+ profile: { sector: "Technology" },
1159
+ quote: makeQuote({ symbol: target.symbol, price: 125 }),
1160
+ quarterlyStatements: shallowCloudRows,
1161
+ }),
1162
+ }));
1163
+ },
1164
+ };
1165
+ let yahooCalls = 0;
1166
+ const yahooProvider: DataProvider = {
1167
+ ...fallbackProvider,
1168
+ id: "yahoo",
1169
+ name: "Yahoo",
1170
+ priority: 1000,
1171
+ async getTickerFinancials() {
1172
+ yahooCalls += 1;
1173
+ return makeFinancials({
1174
+ quarterlyStatements: [
1175
+ { date: "2024-03-31", eps: 0.07 },
1176
+ { date: "2024-06-30", eps: 0.16 },
1177
+ { date: "2024-09-30", eps: 0.47 },
1178
+ { date: "2024-12-31", eps: 0.29 },
1179
+ ],
1180
+ });
1181
+ },
1182
+ };
1183
+
1184
+ const router = new AssetDataRouter(yahooProvider, [cloudProvider]);
1185
+ const results = await router.getTickerFinancialsBatch([{ symbol: "AMD", exchange: "NASDAQ" }], { forceRefresh: true });
1186
+ const financials = results[0]?.financials;
1187
+
1188
+ expect(cloudBatchCalls).toBe(1);
1189
+ expect(cloudSingleCalls).toBe(1);
1190
+ expect(yahooCalls).toBe(1);
1191
+ expect(financials?.profile?.sector).toBe("Technology");
1192
+ expect(financials?.quote?.symbol).toBe("AMD");
1193
+ expect(financials?.quarterlyStatements.map((row) => row.date)).toEqual([
1194
+ "2024-03-31",
1195
+ "2024-06-30",
1196
+ "2024-09-30",
1197
+ "2024-12-31",
1198
+ "2025-03-31",
1199
+ "2025-06-30",
1200
+ "2025-09-30",
1201
+ "2025-12-31",
1202
+ "2026-03-31",
1203
+ ]);
1204
+ });
1205
+
1206
+ test("refreshes shallow cached provider statement history", async () => {
1207
+ const dbPath = createTempDbPath("cached-shallow-statement-history");
1208
+ const persistence = new AppPersistence(dbPath);
1209
+ const cloudProvider: DataProvider = {
1210
+ ...fallbackProvider,
1211
+ id: "cloud",
1212
+ name: "Cloud",
1213
+ priority: 100,
1214
+ async getTickerFinancials() {
1215
+ return makeFinancials({
1216
+ profile: { sector: "Technology" },
1217
+ quote: makeQuote({ symbol: "LINK", price: 25 }),
1218
+ quarterlyStatements: [
1219
+ { date: "2025-03-31", operatingCashFlow: -271_000 },
1220
+ { date: "2025-06-30", operatingCashFlow: -138_000 },
1221
+ { date: "2025-09-30", operatingCashFlow: 653_000 },
1222
+ { date: "2025-12-31", operatingCashFlow: -356_000 },
1223
+ { date: "2026-03-31", operatingCashFlow: -543_000 },
1224
+ ],
1225
+ });
1226
+ },
1227
+ };
1228
+ let yahooCalls = 0;
1229
+ const yahooProvider: DataProvider = {
1230
+ ...fallbackProvider,
1231
+ id: "yahoo",
1232
+ name: "Yahoo",
1233
+ priority: 1000,
1234
+ async getTickerFinancials() {
1235
+ yahooCalls += 1;
1236
+ return makeFinancials({
1237
+ quarterlyStatements: [
1238
+ { date: "2024-03-31", operatingCashFlow: -601_000 },
1239
+ { date: "2024-06-30", operatingCashFlow: -488_000 },
1240
+ { date: "2024-09-30", operatingCashFlow: -204_000 },
1241
+ { date: "2024-12-31", operatingCashFlow: 122_000 },
1242
+ ],
1243
+ });
1244
+ },
1245
+ };
1246
+
1247
+ try {
1248
+ const seedRouter = new AssetDataRouter(null, [cloudProvider], persistence.resources);
1249
+ await seedRouter.getTickerFinancials("LINK", "NASDAQ");
1250
+
1251
+ const cachedRouter = new AssetDataRouter(yahooProvider, [cloudProvider], persistence.resources);
1252
+ const merged = await cachedRouter.getTickerFinancials("LINK", "NASDAQ");
1253
+
1254
+ expect(yahooCalls).toBe(1);
1255
+ expect(merged.quarterlyStatements).toHaveLength(9);
1256
+ } finally {
1257
+ persistence.close();
1258
+ }
1259
+ });
1260
+
1072
1261
  test("fills missing preferred statement fields from richer fallback rows", async () => {
1073
1262
  const cloudProvider: DataProvider = {
1074
1263
  ...fallbackProvider,
@@ -6,6 +6,7 @@ import { resolveTickerFinancialsQuoteState } from "../../market-data/quotes/reso
6
6
  import { shouldLogProviderError } from "../provider-errors";
7
7
  import {
8
8
  hasDetailedStatementRows,
9
+ hasDeepStatementHistory,
9
10
  hasStatementRows,
10
11
  mergeMissingStatementArrays,
11
12
  } from "./financials";
@@ -80,7 +81,7 @@ export class ProviderRouterPrimaryRoutes {
80
81
  );
81
82
  if (!primaryResult) {
82
83
  primaryResult = { sourceKey, value };
83
- if (hasDetailedStatementRows(value)) return primaryResult;
84
+ if (hasDetailedStatementRows(value) && hasDeepStatementHistory(value)) return primaryResult;
84
85
  continue;
85
86
  }
86
87
  if (hasStatementRows(value)) {
@@ -88,7 +89,7 @@ export class ProviderRouterPrimaryRoutes {
88
89
  sourceKey: primaryResult.sourceKey,
89
90
  value: mergeMissingStatementArrays(primaryResult.value, value),
90
91
  };
91
- if (hasDetailedStatementRows(primaryResult.value)) return primaryResult;
92
+ if (hasDetailedStatementRows(primaryResult.value) && hasDeepStatementHistory(primaryResult.value)) return primaryResult;
92
93
  }
93
94
  } catch (error) {
94
95
  if (shouldLogProviderError(error)) {
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
2
2
  import {
3
3
  SecEdgarClient,
4
4
  extractFilingContent,
5
+ parseCompanyFactsFinancialStatements,
5
6
  parseFilingDocuments,
6
7
  parseRecentFilings,
7
8
  parseTickerLookup,
@@ -119,6 +120,88 @@ describe("parseFilingDocuments", () => {
119
120
  });
120
121
  });
121
122
 
123
+ describe("parseCompanyFactsFinancialStatements", () => {
124
+ test("maps SEC company facts into deeper annual and quarterly statement rows", () => {
125
+ const fact = (tag: string, unit: string, rows: unknown[]) => ({
126
+ [tag]: {
127
+ units: {
128
+ [unit]: rows,
129
+ },
130
+ },
131
+ });
132
+ const duration = (end: string, val: number, overrides: Record<string, unknown> = {}) => ({
133
+ start: `${end.slice(0, 4)}-01-01`,
134
+ end,
135
+ val,
136
+ filed: end,
137
+ form: "10-K",
138
+ fp: "FY",
139
+ ...overrides,
140
+ });
141
+ const instant = (end: string, val: number, overrides: Record<string, unknown> = {}) => ({
142
+ end,
143
+ val,
144
+ filed: end,
145
+ form: "10-K",
146
+ fp: "FY",
147
+ ...overrides,
148
+ });
149
+
150
+ const statements = parseCompanyFactsFinancialStatements({
151
+ facts: {
152
+ "us-gaap": {
153
+ ...fact("RevenueFromContractWithCustomerExcludingAssessedTax", "USD", [
154
+ duration("2020-12-31", 100),
155
+ duration("2021-03-31", 30, { form: "10-Q", fp: "Q1", frame: "CY2021Q1" }),
156
+ duration("2021-06-30", 40, { form: "10-Q", fp: "Q2", frame: "CY2021Q2" }),
157
+ ]),
158
+ ...fact("NetCashProvidedByUsedInOperatingActivities", "USD", [
159
+ duration("2020-12-31", 25),
160
+ duration("2021-03-31", 8, { form: "10-Q", fp: "Q1", frame: "CY2021Q1" }),
161
+ ]),
162
+ ...fact("PaymentsToAcquirePropertyPlantAndEquipment", "USD", [
163
+ duration("2020-12-31", 5),
164
+ duration("2021-03-31", 3, { form: "10-Q", fp: "Q1", frame: "CY2021Q1" }),
165
+ ]),
166
+ ...fact("Assets", "USD", [
167
+ instant("2020-12-31", 500),
168
+ instant("2021-03-31", 520, { form: "10-Q", fp: "Q1", frame: "CY2021Q1I" }),
169
+ ]),
170
+ ...fact("EarningsPerShareDiluted", "USD/shares", [
171
+ duration("2020-12-31", 1.25),
172
+ duration("2021-03-31", 0.35, { form: "10-Q", fp: "Q1", frame: "CY2021Q1" }),
173
+ ]),
174
+ },
175
+ },
176
+ });
177
+
178
+ expect(statements.annualStatements).toEqual([{
179
+ date: "2020-12-31",
180
+ totalRevenue: 100,
181
+ operatingCashFlow: 25,
182
+ capitalExpenditure: -5,
183
+ freeCashFlow: 20,
184
+ totalAssets: 500,
185
+ eps: 1.25,
186
+ }]);
187
+ expect(statements.quarterlyStatements).toEqual([
188
+ {
189
+ date: "2021-03-31",
190
+ totalRevenue: 30,
191
+ operatingCashFlow: 8,
192
+ capitalExpenditure: -3,
193
+ freeCashFlow: 5,
194
+ totalAssets: 520,
195
+ eps: 0.35,
196
+ },
197
+ {
198
+ date: "2021-06-30",
199
+ totalRevenue: 40,
200
+ },
201
+ ]);
202
+ });
203
+ });
204
+
122
205
  describe("SecEdgarClient", () => {
123
206
  test("extracts readable text from filing html", () => {
124
207
  const content = extractFilingContent(`
@@ -1,4 +1,5 @@
1
1
  import type { SecFilingDocument, SecFilingItem } from "../types/data-provider";
2
+ import type { FinancialStatement } from "../types/financials";
2
3
  import { truncateWithEllipsis } from "../utils/text-wrap";
3
4
  import {
4
5
  PDF_FALLBACK_MESSAGE,
@@ -10,6 +11,7 @@ export { extractFilingContent } from "./sec-edgar/content";
10
11
 
11
12
  const LOOKUP_URL = "https://www.sec.gov/files/company_tickers_exchange.json";
12
13
  const SUBMISSIONS_URL = "https://data.sec.gov/submissions";
14
+ const COMPANY_FACTS_URL = "https://data.sec.gov/api/xbrl/companyfacts";
13
15
  const FETCH_TIMEOUT_MS = 15_000;
14
16
 
15
17
  function sanitizeIdentityPart(value: string, fallback: string): string {
@@ -52,6 +54,69 @@ type LookupEntry = {
52
54
  name?: string;
53
55
  };
54
56
 
57
+ type CompanyFactsEntry = {
58
+ start?: string;
59
+ end?: string;
60
+ val?: number;
61
+ fy?: number | null;
62
+ fp?: string | null;
63
+ form?: string;
64
+ filed?: string;
65
+ frame?: string;
66
+ };
67
+
68
+ type CompanyFactsStatementField = {
69
+ field: keyof FinancialStatement;
70
+ tags: string[];
71
+ units: string[];
72
+ periodType: "duration" | "instant";
73
+ transform?: (value: number) => number;
74
+ };
75
+
76
+ export type SecCompanyFactsStatements = {
77
+ annualStatements: FinancialStatement[];
78
+ quarterlyStatements: FinancialStatement[];
79
+ };
80
+
81
+ const COMPANY_FACTS_STATEMENT_FIELDS: CompanyFactsStatementField[] = [
82
+ {
83
+ field: "totalRevenue",
84
+ tags: ["RevenueFromContractWithCustomerExcludingAssessedTax", "Revenues", "SalesRevenueNet"],
85
+ units: ["USD"],
86
+ periodType: "duration",
87
+ },
88
+ { field: "grossProfit", tags: ["GrossProfit"], units: ["USD"], periodType: "duration" },
89
+ { field: "operatingIncome", tags: ["OperatingIncomeLoss"], units: ["USD"], periodType: "duration" },
90
+ { field: "netIncome", tags: ["NetIncomeLoss", "ProfitLoss"], units: ["USD"], periodType: "duration" },
91
+ {
92
+ field: "operatingCashFlow",
93
+ tags: [
94
+ "NetCashProvidedByUsedInOperatingActivities",
95
+ "NetCashProvidedByUsedInOperatingActivitiesContinuingOperations",
96
+ ],
97
+ units: ["USD"],
98
+ periodType: "duration",
99
+ },
100
+ {
101
+ field: "capitalExpenditure",
102
+ tags: ["PaymentsToAcquirePropertyPlantAndEquipment"],
103
+ units: ["USD"],
104
+ periodType: "duration",
105
+ transform: (value) => -Math.abs(value),
106
+ },
107
+ { field: "totalAssets", tags: ["Assets"], units: ["USD"], periodType: "instant" },
108
+ {
109
+ field: "totalEquity",
110
+ tags: [
111
+ "StockholdersEquity",
112
+ "StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest",
113
+ ],
114
+ units: ["USD"],
115
+ periodType: "instant",
116
+ },
117
+ { field: "eps", tags: ["EarningsPerShareDiluted"], units: ["USD/shares"], periodType: "duration" },
118
+ ];
119
+
55
120
  function normalize(value?: string): string {
56
121
  return (value ?? "").trim().toUpperCase();
57
122
  }
@@ -297,6 +362,160 @@ export function parseFilingDocuments(indexHtml: string, filing: SecFilingItem):
297
362
  return documents;
298
363
  }
299
364
 
365
+ function companyFactsRecord(value: unknown): Record<string, unknown> | null {
366
+ return value && typeof value === "object" && !Array.isArray(value)
367
+ ? value as Record<string, unknown>
368
+ : null;
369
+ }
370
+
371
+ function companyFactsEntries(payload: unknown, tag: string, units: string[]): CompanyFactsEntry[] {
372
+ const facts = companyFactsRecord(companyFactsRecord(companyFactsRecord(payload)?.facts)?.["us-gaap"]);
373
+ const fact = companyFactsRecord(facts?.[tag]);
374
+ const unitRecord = companyFactsRecord(fact?.units);
375
+ if (!unitRecord) return [];
376
+
377
+ for (const unit of units) {
378
+ const entries = unitRecord[unit];
379
+ if (!Array.isArray(entries)) continue;
380
+ return entries
381
+ .map((entry) => companyFactsRecord(entry))
382
+ .filter((entry): entry is Record<string, unknown> => !!entry)
383
+ .map((entry) => ({
384
+ start: typeof entry.start === "string" ? entry.start : undefined,
385
+ end: typeof entry.end === "string" ? entry.end : undefined,
386
+ val: typeof entry.val === "number" ? entry.val : undefined,
387
+ fy: typeof entry.fy === "number" ? entry.fy : null,
388
+ fp: typeof entry.fp === "string" ? entry.fp : null,
389
+ form: typeof entry.form === "string" ? entry.form : undefined,
390
+ filed: typeof entry.filed === "string" ? entry.filed : undefined,
391
+ frame: typeof entry.frame === "string" ? entry.frame : undefined,
392
+ }))
393
+ .filter((entry) => (
394
+ typeof entry.val === "number"
395
+ && Number.isFinite(entry.val)
396
+ && typeof entry.end === "string"
397
+ && /^\d{4}-\d{2}-\d{2}$/.test(entry.end)
398
+ ));
399
+ }
400
+
401
+ return [];
402
+ }
403
+
404
+ function secFormRank(form: string | undefined): number {
405
+ const normalized = normalize(form);
406
+ if (normalized === "10-K/A") return 4;
407
+ if (normalized === "10-K") return 3;
408
+ if (normalized === "10-Q/A") return 2;
409
+ if (normalized === "10-Q") return 1;
410
+ return 0;
411
+ }
412
+
413
+ function companyFactsFiledRank(value: string | undefined): number {
414
+ if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return 0;
415
+ return Date.parse(`${value}T00:00:00Z`) || 0;
416
+ }
417
+
418
+ function compareCompanyFactsEntries(left: CompanyFactsEntry, right: CompanyFactsEntry | undefined): number {
419
+ if (!right) return 1;
420
+ const filedDiff = companyFactsFiledRank(left.filed) - companyFactsFiledRank(right.filed);
421
+ if (filedDiff !== 0) return filedDiff;
422
+ const formDiff = secFormRank(left.form) - secFormRank(right.form);
423
+ if (formDiff !== 0) return formDiff;
424
+ return String(left.frame ?? "").localeCompare(String(right.frame ?? ""));
425
+ }
426
+
427
+ function isAnnualCompanyFact(entry: CompanyFactsEntry): boolean {
428
+ const form = normalize(entry.form);
429
+ return (form === "10-K" || form === "10-K/A") && normalize(entry.fp ?? undefined) === "FY";
430
+ }
431
+
432
+ function isQuarterlyCompanyFact(entry: CompanyFactsEntry, periodType: "duration" | "instant"): boolean {
433
+ if (periodType === "instant") {
434
+ return /^CY\d{4}Q[1-4]I$/.test(entry.frame ?? "")
435
+ || (
436
+ (normalize(entry.form) === "10-Q" || normalize(entry.form) === "10-Q/A")
437
+ && /^Q[1-3]$/.test(normalize(entry.fp ?? undefined))
438
+ );
439
+ }
440
+ return /^CY\d{4}Q[1-4]$/.test(entry.frame ?? "");
441
+ }
442
+
443
+ function setCompanyFactValue(
444
+ rows: Map<string, FinancialStatement>,
445
+ selectedFacts: Map<string, CompanyFactsEntry>,
446
+ date: string,
447
+ field: keyof FinancialStatement,
448
+ value: number,
449
+ entry: CompanyFactsEntry,
450
+ ): void {
451
+ const selectionKey = `${date}:${String(field)}`;
452
+ if (compareCompanyFactsEntries(entry, selectedFacts.get(selectionKey)) < 0) return;
453
+ const row = rows.get(date) ?? { date };
454
+ (row as Record<string, unknown>)[field] = value;
455
+ rows.set(date, row);
456
+ selectedFacts.set(selectionKey, entry);
457
+ }
458
+
459
+ function fillCompanyFactsStatementRows(
460
+ rows: Map<string, FinancialStatement>,
461
+ selectedFacts: Map<string, CompanyFactsEntry>,
462
+ entries: CompanyFactsEntry[],
463
+ field: CompanyFactsStatementField,
464
+ period: "annual" | "quarterly",
465
+ ): void {
466
+ for (const entry of entries) {
467
+ if (!entry.end || typeof entry.val !== "number") continue;
468
+ const matchesPeriod = period === "annual"
469
+ ? isAnnualCompanyFact(entry)
470
+ : isQuarterlyCompanyFact(entry, field.periodType);
471
+ if (!matchesPeriod) continue;
472
+ setCompanyFactValue(
473
+ rows,
474
+ selectedFacts,
475
+ entry.end,
476
+ field.field,
477
+ field.transform ? field.transform(entry.val) : entry.val,
478
+ entry,
479
+ );
480
+ }
481
+ }
482
+
483
+ function finalizeCompanyFactsStatements(rows: Map<string, FinancialStatement>): FinancialStatement[] {
484
+ const statements = Array.from(rows.values()).sort((left, right) => left.date.localeCompare(right.date));
485
+ for (const statement of statements) {
486
+ if (
487
+ typeof statement.freeCashFlow !== "number"
488
+ && typeof statement.operatingCashFlow === "number"
489
+ && typeof statement.capitalExpenditure === "number"
490
+ ) {
491
+ statement.freeCashFlow = statement.operatingCashFlow + statement.capitalExpenditure;
492
+ }
493
+ }
494
+ return statements;
495
+ }
496
+
497
+ export function parseCompanyFactsFinancialStatements(payload: unknown): SecCompanyFactsStatements {
498
+ const annualRows = new Map<string, FinancialStatement>();
499
+ const quarterlyRows = new Map<string, FinancialStatement>();
500
+ const annualSelectedFacts = new Map<string, CompanyFactsEntry>();
501
+ const quarterlySelectedFacts = new Map<string, CompanyFactsEntry>();
502
+
503
+ for (const field of COMPANY_FACTS_STATEMENT_FIELDS) {
504
+ for (const tag of field.tags) {
505
+ const entries = companyFactsEntries(payload, tag, field.units);
506
+ if (entries.length === 0) continue;
507
+ fillCompanyFactsStatementRows(annualRows, annualSelectedFacts, entries, field, "annual");
508
+ fillCompanyFactsStatementRows(quarterlyRows, quarterlySelectedFacts, entries, field, "quarterly");
509
+ break;
510
+ }
511
+ }
512
+
513
+ return {
514
+ annualStatements: finalizeCompanyFactsStatements(annualRows),
515
+ quarterlyStatements: finalizeCompanyFactsStatements(quarterlyRows),
516
+ };
517
+ }
518
+
300
519
  export class SecEdgarClient {
301
520
  private lookupPromise: Promise<Map<string, LookupEntry>> | null = null;
302
521
 
@@ -407,6 +626,18 @@ export class SecEdgarClient {
407
626
  return parseRecentFilings(payload, count);
408
627
  }
409
628
 
629
+ async getFinancialStatements(ticker: string): Promise<SecCompanyFactsStatements | null> {
630
+ const normalizedTicker = normalize(ticker);
631
+ if (!normalizedTicker) return null;
632
+
633
+ const lookup = await this.loadLookup();
634
+ const entry = lookup.get(normalizedTicker);
635
+ if (!entry) return null;
636
+
637
+ const payload = await this.fetchJson<unknown>(`${COMPANY_FACTS_URL}/CIK${entry.cik}.json`);
638
+ return parseCompanyFactsFinancialStatements(payload);
639
+ }
640
+
410
641
  async getFilingDocuments(filing: SecFilingItem): Promise<SecFilingDocument[]> {
411
642
  const { body } = await this.fetchText(filing.filingUrl);
412
643
  return parseFilingDocuments(body, filing);