gloomberb 0.1.0 → 0.3.1
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.
- package/LICENSE +25 -0
- package/README.md +68 -121
- package/bin/gloomberb +3 -0
- package/package.json +13 -4
- package/patches/@opentui%2Fcore@0.1.90.patch +356 -0
- package/src/app.tsx +1330 -0
- package/src/brokers/sync-broker-instance.test.ts +100 -0
- package/src/brokers/sync-broker-instance.ts +341 -0
- package/src/cli/commands/plugins.ts +173 -0
- package/src/cli/commands/portfolio.ts +210 -0
- package/src/cli/commands/search.ts +120 -0
- package/src/cli/commands/ticker.ts +375 -0
- package/src/cli/commands/watchlist.ts +210 -0
- package/src/cli/context.ts +62 -0
- package/src/cli/errors.ts +13 -0
- package/src/cli/helpers.ts +85 -0
- package/src/cli/index.ts +143 -0
- package/src/cli/ticker-resolution.ts +39 -0
- package/src/cli/types.ts +15 -0
- package/src/cli.test.ts +361 -0
- package/src/components/chart/chart-data.ts +239 -0
- package/src/components/chart/chart-renderer.ts +1199 -0
- package/src/components/chart/chart-types.ts +82 -0
- package/src/components/chart/chart-viewport.ts +21 -0
- package/src/components/chart/chart.test.ts +439 -0
- package/src/components/chart/comparison-chart-data.test.ts +109 -0
- package/src/components/chart/comparison-chart-data.ts +316 -0
- package/src/components/chart/comparison-chart-renderer.test.ts +84 -0
- package/src/components/chart/comparison-chart-renderer.ts +354 -0
- package/src/components/chart/comparison-stock-chart.tsx +1486 -0
- package/src/components/chart/cursor-motion.ts +48 -0
- package/src/components/chart/native/chart-rasterizer.ts +732 -0
- package/src/components/chart/native/kitty-adapter.ts +12 -0
- package/src/components/chart/native/kitty-manager.ts +142 -0
- package/src/components/chart/native/kitty-protocol.ts +110 -0
- package/src/components/chart/native/kitty-support.ts +71 -0
- package/src/components/chart/native/kitty.test.ts +620 -0
- package/src/components/chart/native/renderer-selection.ts +24 -0
- package/src/components/chart/native/surface-manager.ts +225 -0
- package/src/components/chart/native/surface-sync.ts +34 -0
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +165 -0
- package/src/components/chart/stock-chart.test.ts +107 -0
- package/src/components/chart/stock-chart.tsx +1556 -0
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +137 -0
- package/src/components/command-bar/command-bar.test.tsx +1524 -0
- package/src/components/command-bar/command-bar.tsx +4823 -0
- package/src/components/command-bar/command-registry.ts +261 -0
- package/src/components/command-bar/helpers.test.ts +224 -0
- package/src/components/command-bar/helpers.ts +371 -0
- package/src/components/command-bar/root-shortcuts.ts +158 -0
- package/src/components/command-bar/view-model.test.ts +152 -0
- package/src/components/command-bar/view-model.ts +185 -0
- package/src/components/command-bar/workflow-ops.test.ts +70 -0
- package/src/components/command-bar/workflow-ops.ts +433 -0
- package/src/components/command-bar/workflow-types.ts +127 -0
- package/src/components/detail-feed-view.test.tsx +105 -0
- package/src/components/detail-feed-view.tsx +295 -0
- package/src/components/index.ts +32 -0
- package/src/components/layout/floating-pane.tsx +49 -0
- package/src/components/layout/header.tsx +129 -0
- package/src/components/layout/pane-header.tsx +44 -0
- package/src/components/layout/pane.tsx +49 -0
- package/src/components/layout/pixel-mouse-mode.test.ts +122 -0
- package/src/components/layout/pixel-mouse.test.ts +40 -0
- package/src/components/layout/shell.test.tsx +625 -0
- package/src/components/layout/shell.tsx +1200 -0
- package/src/components/layout/status-bar.test.tsx +173 -0
- package/src/components/layout/status-bar.tsx +138 -0
- package/src/components/markdown-editor.tsx +134 -0
- package/src/components/onboarding/onboarding-wizard.test.tsx +184 -0
- package/src/components/onboarding/onboarding-wizard.tsx +1365 -0
- package/src/components/pane-settings-dialog.tsx +468 -0
- package/src/components/pane-template-wizard.test.tsx +52 -0
- package/src/components/pane-template-wizard.tsx +199 -0
- package/src/components/price-selector-dialog.tsx +195 -0
- package/src/components/spinner.tsx +2 -0
- package/src/components/tab-bar.tsx +17 -0
- package/src/components/ticker-badge-text.test.tsx +160 -0
- package/src/components/ticker-badge-text.tsx +58 -0
- package/src/components/ticker-badge.tsx +88 -0
- package/src/components/ticker-list-table.tsx +188 -0
- package/src/components/toggle-list.tsx +74 -0
- package/src/components/ui/button.tsx +77 -0
- package/src/components/ui/data-table.tsx +210 -0
- package/src/components/ui/external-link.tsx +22 -0
- package/src/components/ui/fields.tsx +187 -0
- package/src/components/ui/frame.tsx +73 -0
- package/src/components/ui/index.ts +53 -0
- package/src/components/ui/list-view.tsx +166 -0
- package/src/components/ui/loading.tsx +70 -0
- package/src/components/ui/status.tsx +83 -0
- package/src/components/ui/tabs.tsx +47 -0
- package/src/components/ui/toggle.tsx +164 -0
- package/src/components/ui/ui.test.tsx +134 -0
- package/src/data/app-persistence.ts +25 -0
- package/src/data/config-store.test.ts +310 -0
- package/src/data/config-store.ts +469 -0
- package/src/data/plugin-state-store.ts +102 -0
- package/src/data/resource-store.ts +323 -0
- package/src/data/session-store.ts +52 -0
- package/src/data/sqlite-cache.test.ts +103 -0
- package/src/data/sqlite-database.ts +62 -0
- package/src/data/sqlite-json.ts +19 -0
- package/src/data/ticker-repository.ts +45 -0
- package/src/data/ticker-store.ts +46 -0
- package/src/index.tsx +67 -0
- package/src/market-data/coordinator.test.ts +369 -0
- package/src/market-data/coordinator.ts +580 -0
- package/src/market-data/hooks.tsx +234 -0
- package/src/market-data/query-store.ts +28 -0
- package/src/market-data/request-types.test.ts +64 -0
- package/src/market-data/request-types.ts +70 -0
- package/src/market-data/result-types.ts +56 -0
- package/src/market-data/selectors.ts +166 -0
- package/src/market-data/trace.ts +7 -0
- package/src/pane-settings.test.ts +71 -0
- package/src/pane-settings.ts +73 -0
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +338 -0
- package/src/plugins/builtin/ai/gloomberb-cli.ts +70 -0
- package/src/plugins/builtin/ai/index.test.tsx +139 -0
- package/src/plugins/builtin/ai/index.tsx +86 -0
- package/src/plugins/builtin/ai/providers.ts +99 -0
- package/src/plugins/builtin/ai/runner.ts +82 -0
- package/src/plugins/builtin/ai/screener-contract.test.ts +117 -0
- package/src/plugins/builtin/ai/screener-contract.ts +174 -0
- package/src/plugins/builtin/ai/screener-pane.test.tsx +365 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +1161 -0
- package/src/plugins/builtin/ai/settings.ts +79 -0
- package/src/plugins/builtin/ai/ticker-context.ts +64 -0
- package/src/plugins/builtin/ai/utils.ts +71 -0
- package/src/plugins/builtin/ask-ai.test.tsx +258 -0
- package/src/plugins/builtin/ask-ai.tsx +9 -0
- package/src/plugins/builtin/chat-controller.test.ts +217 -0
- package/src/plugins/builtin/chat-controller.ts +340 -0
- package/src/plugins/builtin/chat.test.tsx +451 -0
- package/src/plugins/builtin/chat.tsx +620 -0
- package/src/plugins/builtin/comparison-chart.test.tsx +277 -0
- package/src/plugins/builtin/comparison-chart.tsx +169 -0
- package/src/plugins/builtin/data-providers.ts +16 -0
- package/src/plugins/builtin/debug.tsx +351 -0
- package/src/plugins/builtin/help.test.tsx +110 -0
- package/src/plugins/builtin/help.tsx +281 -0
- package/src/plugins/builtin/layout-manager.tsx +264 -0
- package/src/plugins/builtin/manual-entry.tsx +25 -0
- package/src/plugins/builtin/news.tsx +143 -0
- package/src/plugins/builtin/notes-files.ts +58 -0
- package/src/plugins/builtin/notes.tsx +396 -0
- package/src/plugins/builtin/options-availability.test.tsx +179 -0
- package/src/plugins/builtin/options-availability.ts +158 -0
- package/src/plugins/builtin/options.tsx +364 -0
- package/src/plugins/builtin/portfolio-list/header.tsx +227 -0
- package/src/plugins/builtin/portfolio-list/index.tsx +99 -0
- package/src/plugins/builtin/portfolio-list/metrics.ts +436 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +533 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +280 -0
- package/src/plugins/builtin/portfolio-list/summary.tsx +270 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +64 -0
- package/src/plugins/builtin/portfolio-list.test.ts +74 -0
- package/src/plugins/builtin/portfolio-list.test.tsx +1164 -0
- package/src/plugins/builtin/portfolio-metrics.test.ts +153 -0
- package/src/plugins/builtin/quote-monitor.test.tsx +114 -0
- package/src/plugins/builtin/sec.tsx +250 -0
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +43 -0
- package/src/plugins/builtin/ticker-detail/financials-tab.tsx +390 -0
- package/src/plugins/builtin/ticker-detail/index.tsx +80 -0
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +242 -0
- package/src/plugins/builtin/ticker-detail/pane.tsx +183 -0
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +83 -0
- package/src/plugins/builtin/ticker-detail/settings.ts +132 -0
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +193 -0
- package/src/plugins/builtin/ticker-detail.test.tsx +578 -0
- package/src/plugins/builtin/yahoo.ts +18 -0
- package/src/plugins/event-bus.ts +37 -0
- package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +35 -0
- package/src/plugins/ibkr/account-cache.test.ts +101 -0
- package/src/plugins/ibkr/account-cache.ts +137 -0
- package/src/plugins/ibkr/broker-adapter.ts +168 -0
- package/src/plugins/ibkr/config.test.ts +68 -0
- package/src/plugins/ibkr/config.ts +262 -0
- package/src/plugins/ibkr/dialogs.tsx +83 -0
- package/src/plugins/ibkr/flex.test.ts +107 -0
- package/src/plugins/ibkr/flex.ts +267 -0
- package/src/plugins/ibkr/fundamental-parser.ts +149 -0
- package/src/plugins/ibkr/gateway-helpers.ts +28 -0
- package/src/plugins/ibkr/gateway-selection.ts +51 -0
- package/src/plugins/ibkr/gateway-service.test.ts +450 -0
- package/src/plugins/ibkr/gateway-service.ts +2228 -0
- package/src/plugins/ibkr/index.test.tsx +183 -0
- package/src/plugins/ibkr/index.tsx +153 -0
- package/src/plugins/ibkr/instance-selection.test.ts +121 -0
- package/src/plugins/ibkr/instance-selection.ts +36 -0
- package/src/plugins/ibkr/price-normalization.test.ts +38 -0
- package/src/plugins/ibkr/price-normalization.ts +59 -0
- package/src/plugins/ibkr/trade-badge.tsx +27 -0
- package/src/plugins/ibkr/trade-tab.test.tsx +225 -0
- package/src/plugins/ibkr/trade-tab.tsx +1050 -0
- package/src/plugins/ibkr/trade-utils.test.ts +175 -0
- package/src/plugins/ibkr/trade-utils.ts +149 -0
- package/src/plugins/ibkr/trading-pane.tsx +438 -0
- package/src/plugins/ibkr/trading-state.test.ts +109 -0
- package/src/plugins/ibkr/trading-state.ts +339 -0
- package/src/plugins/loader.ts +71 -0
- package/src/plugins/pane-manager.test.ts +103 -0
- package/src/plugins/pane-manager.ts +974 -0
- package/src/plugins/plugin-persistence.ts +85 -0
- package/src/plugins/plugin-runtime.test.tsx +185 -0
- package/src/plugins/plugin-runtime.tsx +175 -0
- package/src/plugins/prediction-markets/cache.ts +114 -0
- package/src/plugins/prediction-markets/categories.ts +223 -0
- package/src/plugins/prediction-markets/chart.tsx +512 -0
- package/src/plugins/prediction-markets/columns.ts +133 -0
- package/src/plugins/prediction-markets/controller-data.ts +614 -0
- package/src/plugins/prediction-markets/controller.ts +725 -0
- package/src/plugins/prediction-markets/detail/book.tsx +118 -0
- package/src/plugins/prediction-markets/detail/outcomes.tsx +102 -0
- package/src/plugins/prediction-markets/detail/overview.tsx +73 -0
- package/src/plugins/prediction-markets/detail/pane.tsx +308 -0
- package/src/plugins/prediction-markets/detail/rules.tsx +16 -0
- package/src/plugins/prediction-markets/detail/shared.tsx +29 -0
- package/src/plugins/prediction-markets/detail/trades.tsx +30 -0
- package/src/plugins/prediction-markets/detail.test.tsx +47 -0
- package/src/plugins/prediction-markets/index.tsx +102 -0
- package/src/plugins/prediction-markets/keyboard.ts +64 -0
- package/src/plugins/prediction-markets/launch.test.ts +126 -0
- package/src/plugins/prediction-markets/launch.ts +240 -0
- package/src/plugins/prediction-markets/metrics.ts +311 -0
- package/src/plugins/prediction-markets/navigation.ts +90 -0
- package/src/plugins/prediction-markets/outcome-order.ts +19 -0
- package/src/plugins/prediction-markets/pane.test.tsx +425 -0
- package/src/plugins/prediction-markets/pane.tsx +294 -0
- package/src/plugins/prediction-markets/plugin.test.tsx +570 -0
- package/src/plugins/prediction-markets/rows.ts +229 -0
- package/src/plugins/prediction-markets/services/fetch.ts +97 -0
- package/src/plugins/prediction-markets/services/kalshi-adapter.ts +555 -0
- package/src/plugins/prediction-markets/services/polymarket-adapter.ts +660 -0
- package/src/plugins/prediction-markets/services/polymarket-ws.ts +108 -0
- package/src/plugins/prediction-markets/settings.ts +164 -0
- package/src/plugins/prediction-markets/status.ts +24 -0
- package/src/plugins/prediction-markets/table.tsx +84 -0
- package/src/plugins/prediction-markets/test-helpers.tsx +636 -0
- package/src/plugins/prediction-markets/types.ts +186 -0
- package/src/plugins/registry.ts +633 -0
- package/src/sources/exchange-rates.ts +18 -0
- package/src/sources/gloomberb-cloud.test.ts +199 -0
- package/src/sources/gloomberb-cloud.ts +326 -0
- package/src/sources/provider-errors.ts +19 -0
- package/src/sources/provider-router.test.ts +1476 -0
- package/src/sources/provider-router.ts +1405 -0
- package/src/sources/sec-edgar.test.ts +286 -0
- package/src/sources/sec-edgar.ts +526 -0
- package/src/sources/yahoo-finance.test.ts +14 -0
- package/src/sources/yahoo-finance.ts +1017 -0
- package/src/state/app-activity.test.ts +41 -0
- package/src/state/app-activity.ts +106 -0
- package/src/state/app-bootstrap.test.ts +471 -0
- package/src/state/app-bootstrap.ts +308 -0
- package/src/state/app-context.test.ts +406 -0
- package/src/state/app-context.test.tsx +132 -0
- package/src/state/app-context.tsx +969 -0
- package/src/state/selectors.ts +67 -0
- package/src/state/session-persistence.test.ts +128 -0
- package/src/state/session-persistence.ts +161 -0
- package/src/state/ticker-refresh-queue.test.ts +46 -0
- package/src/state/ticker-refresh-queue.ts +40 -0
- package/src/state/use-inline-tickers.ts +174 -0
- package/src/state/use-quote-streaming.test.ts +37 -0
- package/src/state/use-quote-streaming.test.tsx +70 -0
- package/src/state/use-quote-streaming.ts +80 -0
- package/src/state/use-session-snapshot.ts +84 -0
- package/src/test-support/data-provider.ts +22 -0
- package/src/theme/color-utils.ts +67 -0
- package/src/theme/colors.ts +128 -0
- package/src/theme/themes.test.ts +45 -0
- package/src/theme/themes.ts +491 -0
- package/src/types/broker.ts +105 -0
- package/src/types/config.ts +434 -0
- package/src/types/data-provider.ts +81 -0
- package/src/types/financials.ts +142 -0
- package/src/types/instrument.ts +29 -0
- package/src/types/persistence.ts +21 -0
- package/src/types/plugin.ts +354 -0
- package/src/types/ticker.ts +58 -0
- package/src/types/trading.ts +85 -0
- package/src/updater.test.ts +162 -0
- package/src/updater.ts +247 -0
- package/src/utils/api-client.ts +666 -0
- package/src/utils/ascii-chart.ts +172 -0
- package/src/utils/broker-instances.ts +47 -0
- package/src/utils/cli-output.ts +83 -0
- package/src/utils/currency-units.test.ts +49 -0
- package/src/utils/currency-units.ts +82 -0
- package/src/utils/debug-log.ts +133 -0
- package/src/utils/exchange-rate-map.ts +8 -0
- package/src/utils/format.ts +158 -0
- package/src/utils/fuzzy-search.ts +34 -0
- package/src/utils/market-status.ts +95 -0
- package/src/utils/options.ts +86 -0
- package/src/utils/price-history.ts +39 -0
- package/src/utils/quote-time.test.ts +22 -0
- package/src/utils/quote-time.ts +50 -0
- package/src/utils/sec.test.ts +55 -0
- package/src/utils/sec.ts +45 -0
- package/src/utils/ticker-list.test.ts +22 -0
- package/src/utils/ticker-list.ts +35 -0
- package/src/utils/ticker-metadata.ts +51 -0
- package/src/utils/ticker-search.test.ts +227 -0
- package/src/utils/ticker-search.ts +764 -0
- package/src/utils/ticker-tokenizer.test.ts +48 -0
- package/src/utils/ticker-tokenizer.ts +72 -0
- package/src/version.ts +1 -0
- package/install.mjs +0 -69
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
const currencyFormatters = new Map<string, Intl.NumberFormat>();
|
|
2
|
+
const numberFormatters = new Map<number, Intl.NumberFormat>();
|
|
3
|
+
|
|
4
|
+
function getCurrencyFormatter(currency: string): Intl.NumberFormat {
|
|
5
|
+
let formatter = currencyFormatters.get(currency);
|
|
6
|
+
if (!formatter) {
|
|
7
|
+
formatter = new Intl.NumberFormat("en-US", {
|
|
8
|
+
style: "currency",
|
|
9
|
+
currency,
|
|
10
|
+
minimumFractionDigits: 2,
|
|
11
|
+
maximumFractionDigits: 2,
|
|
12
|
+
});
|
|
13
|
+
currencyFormatters.set(currency, formatter);
|
|
14
|
+
}
|
|
15
|
+
return formatter;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getNumberFormatter(decimals: number): Intl.NumberFormat {
|
|
19
|
+
let formatter = numberFormatters.get(decimals);
|
|
20
|
+
if (!formatter) {
|
|
21
|
+
formatter = new Intl.NumberFormat("en-US", {
|
|
22
|
+
minimumFractionDigits: decimals,
|
|
23
|
+
maximumFractionDigits: decimals,
|
|
24
|
+
});
|
|
25
|
+
numberFormatters.set(decimals, formatter);
|
|
26
|
+
}
|
|
27
|
+
return formatter;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Format a number as currency (e.g., $1,234.56) */
|
|
31
|
+
export function formatCurrency(value: number | undefined, currency = "USD"): string {
|
|
32
|
+
if (value === undefined || value === null || Number.isNaN(value)) return "—";
|
|
33
|
+
return getCurrencyFormatter(currency).format(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Format a number as percentage (e.g., +1.23%) */
|
|
37
|
+
export function formatPercent(value: number | undefined): string {
|
|
38
|
+
if (value === undefined || value === null) return "—";
|
|
39
|
+
const sign = value > 0 ? "+" : "";
|
|
40
|
+
return `${sign}${(value * 100).toFixed(2)}%`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Format a percentage that's already in percent form (e.g., 1.23 -> +1.23%) */
|
|
44
|
+
export function formatPercentRaw(value: number | undefined): string {
|
|
45
|
+
if (value === undefined || value === null) return "—";
|
|
46
|
+
const sign = value > 0 ? "+" : "";
|
|
47
|
+
return `${sign}${value.toFixed(2)}%`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Format large numbers compactly (e.g., 1.5T, 234B, 12.3M, 5k) */
|
|
51
|
+
export function formatCompact(value: number | undefined): string {
|
|
52
|
+
if (value === undefined || value === null) return "—";
|
|
53
|
+
const abs = Math.abs(value);
|
|
54
|
+
const sign = value < 0 ? "-" : "";
|
|
55
|
+
const fmt = (n: number, decimals: number, suffix: string) => {
|
|
56
|
+
const fixed = n.toFixed(decimals);
|
|
57
|
+
// Strip unnecessary trailing zeros after decimal point
|
|
58
|
+
const trimmed = fixed.includes(".") ? fixed.replace(/\.?0+$/, "") : fixed;
|
|
59
|
+
return `${sign}${trimmed}${suffix}`;
|
|
60
|
+
};
|
|
61
|
+
if (abs >= 1e12) return fmt(abs / 1e12, 2, "T");
|
|
62
|
+
if (abs >= 1e9) return fmt(abs / 1e9, 2, "B");
|
|
63
|
+
if (abs >= 1e6) return fmt(abs / 1e6, 2, "M");
|
|
64
|
+
if (abs >= 1e3) return fmt(abs / 1e3, 1, "k");
|
|
65
|
+
return fmt(abs, 2, "");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Format a compact value with an explicit currency code (e.g., 1.5T USD) */
|
|
69
|
+
export function formatCompactCurrency(value: number | undefined, currency = "USD"): string {
|
|
70
|
+
if (value === undefined || value === null) return "—";
|
|
71
|
+
return `${formatCompact(value)} ${currency}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Format a plain number with commas */
|
|
75
|
+
export function formatNumber(value: number | undefined, decimals = 2): string {
|
|
76
|
+
if (value === undefined || value === null) return "—";
|
|
77
|
+
return getNumberFormatter(decimals).format(value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Format a price with commas, trimming unnecessary trailing zeros (e.g., 3,565.00 -> 3,565) */
|
|
81
|
+
export function formatPrice(value: number | undefined): string {
|
|
82
|
+
if (value === undefined || value === null || Number.isNaN(value)) return "—";
|
|
83
|
+
const formatted = getNumberFormatter(2).format(value);
|
|
84
|
+
return formatted.replace(/\.00$/, "").replace(/(\.\d)0$/, "$1");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Format a growth rate compactly (e.g., +12%, -5%) */
|
|
88
|
+
export function formatGrowthShort(value: number): string {
|
|
89
|
+
const pct = value * 100;
|
|
90
|
+
const sign = pct > 0 ? "+" : "";
|
|
91
|
+
return Math.abs(pct) >= 10
|
|
92
|
+
? `${sign}${Math.round(pct)}%`
|
|
93
|
+
: `${sign}${pct.toFixed(1)}%`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Pick a common unit suffix for a set of numbers */
|
|
97
|
+
export function pickUnit(values: (number | undefined)[]): { suffix: string; divisor: number } {
|
|
98
|
+
const defined = values.filter((v): v is number => v != null);
|
|
99
|
+
if (!defined.length) return { suffix: "", divisor: 1 };
|
|
100
|
+
const maxAbs = Math.max(...defined.map(Math.abs));
|
|
101
|
+
if (maxAbs >= 1e12) return { suffix: "T", divisor: 1e12 };
|
|
102
|
+
if (maxAbs >= 1e9) return { suffix: "B", divisor: 1e9 };
|
|
103
|
+
if (maxAbs >= 1e6) return { suffix: "M", divisor: 1e6 };
|
|
104
|
+
if (maxAbs >= 1e3) return { suffix: "K", divisor: 1e3 };
|
|
105
|
+
return { suffix: "", divisor: 1 };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Format a number using a pre-determined divisor (no unit suffix) */
|
|
109
|
+
export function formatWithDivisor(value: number | undefined, divisor: number): string {
|
|
110
|
+
if (value === undefined || value === null) return "—";
|
|
111
|
+
const scaled = value / divisor;
|
|
112
|
+
const abs = Math.abs(scaled);
|
|
113
|
+
const decimals = abs >= 100 ? 1 : 2;
|
|
114
|
+
return scaled.toFixed(decimals);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Pad/truncate a string to a fixed width */
|
|
118
|
+
export function padTo(str: string, width: number, align: "left" | "right" | "center" = "left"): string {
|
|
119
|
+
if (str.length > width) return str.slice(0, width);
|
|
120
|
+
if (align === "right") return str.padStart(width);
|
|
121
|
+
if (align === "center") {
|
|
122
|
+
const totalPadding = width - str.length;
|
|
123
|
+
const leftPadding = Math.floor(totalPadding / 2);
|
|
124
|
+
const rightPadding = totalPadding - leftPadding;
|
|
125
|
+
return " ".repeat(leftPadding) + str + " ".repeat(rightPadding);
|
|
126
|
+
}
|
|
127
|
+
return str.padEnd(width);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Convert a value from one currency to base currency using cached exchange rates */
|
|
131
|
+
export function convertCurrency(
|
|
132
|
+
value: number,
|
|
133
|
+
fromCurrency: string,
|
|
134
|
+
baseCurrency: string,
|
|
135
|
+
exchangeRates: Map<string, number>,
|
|
136
|
+
): number {
|
|
137
|
+
if (fromCurrency === baseCurrency) return value;
|
|
138
|
+
const fromRate = exchangeRates.get(fromCurrency);
|
|
139
|
+
const baseRate = exchangeRates.get(baseCurrency);
|
|
140
|
+
if (fromRate == null || baseRate == null || baseRate === 0) return value;
|
|
141
|
+
return (value * fromRate) / baseRate;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Format a date/timestamp as relative time (e.g., "5m ago", "2h ago") */
|
|
145
|
+
export function formatTimeAgo(date: Date | string): string {
|
|
146
|
+
const ts = typeof date === "string"
|
|
147
|
+
? new Date(date.endsWith("Z") ? date : date + "Z").getTime()
|
|
148
|
+
: date.getTime();
|
|
149
|
+
const seconds = Math.floor((Date.now() - ts) / 1000);
|
|
150
|
+
if (seconds < 60) return "just now";
|
|
151
|
+
const minutes = Math.floor(seconds / 60);
|
|
152
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
153
|
+
const hours = Math.floor(minutes / 60);
|
|
154
|
+
if (hours < 24) return `${hours}h ago`;
|
|
155
|
+
const days = Math.floor(hours / 24);
|
|
156
|
+
if (days < 7) return `${days}d ago`;
|
|
157
|
+
return new Date(ts).toLocaleDateString("en-US", { month: "numeric", day: "numeric", year: "2-digit" });
|
|
158
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Simple fuzzy matching: all query chars must appear in order in target */
|
|
2
|
+
export function fuzzyMatch(query: string, target: string): { match: boolean; score: number } {
|
|
3
|
+
const q = query.toLowerCase();
|
|
4
|
+
const t = target.toLowerCase();
|
|
5
|
+
|
|
6
|
+
if (q.length === 0) return { match: true, score: 0 };
|
|
7
|
+
if (t.startsWith(q)) return { match: true, score: 1000 - q.length }; // exact prefix match
|
|
8
|
+
if (t.includes(q)) return { match: true, score: 500 - q.length }; // substring match
|
|
9
|
+
|
|
10
|
+
let qi = 0;
|
|
11
|
+
let score = 0;
|
|
12
|
+
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
|
13
|
+
if (t[ti] === q[qi]) {
|
|
14
|
+
score += ti === 0 || t[ti - 1] === " " || t[ti - 1] === "-" ? 10 : 1;
|
|
15
|
+
qi++;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return { match: qi === q.length, score };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Filter and sort items by fuzzy match score */
|
|
23
|
+
export function fuzzyFilter<T>(
|
|
24
|
+
items: T[],
|
|
25
|
+
query: string,
|
|
26
|
+
getText: (item: T) => string,
|
|
27
|
+
): T[] {
|
|
28
|
+
if (!query) return items;
|
|
29
|
+
return items
|
|
30
|
+
.map((item) => ({ item, ...fuzzyMatch(query, getText(item)) }))
|
|
31
|
+
.filter((r) => r.match)
|
|
32
|
+
.sort((a, b) => b.score - a.score)
|
|
33
|
+
.map((r) => r.item);
|
|
34
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { MarketState, Quote } from "../types/financials";
|
|
2
|
+
import { colors, priceColor } from "../theme/colors";
|
|
3
|
+
import { formatPercentRaw } from "./format";
|
|
4
|
+
|
|
5
|
+
export interface ActiveQuoteDisplay {
|
|
6
|
+
price: number;
|
|
7
|
+
change: number;
|
|
8
|
+
changePercent: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function marketStateLabel(state: MarketState): string {
|
|
12
|
+
switch (state) {
|
|
13
|
+
case "PRE": return "PRE-MKT";
|
|
14
|
+
case "REGULAR": return "OPEN";
|
|
15
|
+
case "POST": return "AFTER-HRS";
|
|
16
|
+
case "PREPRE":
|
|
17
|
+
case "POSTPOST":
|
|
18
|
+
case "CLOSED": return "CLOSED";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function marketStateColor(state: MarketState): string {
|
|
23
|
+
switch (state) {
|
|
24
|
+
case "REGULAR": return colors.positive;
|
|
25
|
+
case "PRE":
|
|
26
|
+
case "POST": return colors.textBright;
|
|
27
|
+
case "PREPRE":
|
|
28
|
+
case "POSTPOST":
|
|
29
|
+
case "CLOSED": return colors.textDim;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Short exchange display name */
|
|
34
|
+
export function exchangeShortName(exchangeName?: string, fullExchangeName?: string): string {
|
|
35
|
+
if (!exchangeName && !fullExchangeName) return "";
|
|
36
|
+
const name = exchangeName || fullExchangeName || "";
|
|
37
|
+
// Common Yahoo Finance exchange abbreviations
|
|
38
|
+
const map: Record<string, string> = {
|
|
39
|
+
NMS: "NASDAQ", NGM: "NASDAQ", NCM: "NASDAQ", NAS: "NASDAQ",
|
|
40
|
+
NYQ: "NYSE", NYS: "NYSE",
|
|
41
|
+
PCX: "AMEX", ASE: "AMEX",
|
|
42
|
+
HKG: "HKEX",
|
|
43
|
+
TYO: "TYO",
|
|
44
|
+
LSE: "LSE",
|
|
45
|
+
ASX: "ASX",
|
|
46
|
+
SGX: "SGX",
|
|
47
|
+
KSC: "KRX", KOE: "KOSDAQ",
|
|
48
|
+
TAI: "TWSE",
|
|
49
|
+
SHH: "SSE", SHZ: "SZSE",
|
|
50
|
+
PAR: "EURONEXT", AMS: "EURONEXT", BRU: "EURONEXT",
|
|
51
|
+
GER: "XETRA",
|
|
52
|
+
OSL: "OSE",
|
|
53
|
+
BOM: "BSE", NSI: "NSE",
|
|
54
|
+
SAO: "B3",
|
|
55
|
+
JPX: "TYO",
|
|
56
|
+
};
|
|
57
|
+
return map[name] || name;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Get extended hours price info (pre-market or after-hours) for display */
|
|
61
|
+
export function getExtendedHoursInfo(quote: Quote | null | undefined): { text: string; color: string } | null {
|
|
62
|
+
if (!quote) return null;
|
|
63
|
+
if ((quote.marketState === "PRE" || quote.marketState === "PREPRE") && quote.preMarketPrice != null) {
|
|
64
|
+
const chg = quote.preMarketChangePercent ?? 0;
|
|
65
|
+
return { text: `Pre ${quote.preMarketPrice.toFixed(2)} ${formatPercentRaw(chg)}`, color: priceColor(chg) };
|
|
66
|
+
}
|
|
67
|
+
if ((quote.marketState === "POST" || quote.marketState === "POSTPOST") && quote.postMarketPrice != null) {
|
|
68
|
+
const chg = quote.postMarketChangePercent ?? 0;
|
|
69
|
+
return { text: `AH ${quote.postMarketPrice.toFixed(2)} ${formatPercentRaw(chg)}`, color: priceColor(chg) };
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getActiveQuoteDisplay(quote: Quote | null | undefined): ActiveQuoteDisplay | null {
|
|
75
|
+
if (!quote) return null;
|
|
76
|
+
if ((quote.marketState === "PRE" || quote.marketState === "PREPRE") && quote.preMarketPrice != null) {
|
|
77
|
+
return {
|
|
78
|
+
price: quote.preMarketPrice,
|
|
79
|
+
change: quote.preMarketChange ?? 0,
|
|
80
|
+
changePercent: quote.preMarketChangePercent ?? 0,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if ((quote.marketState === "POST" || quote.marketState === "POSTPOST") && quote.postMarketPrice != null) {
|
|
84
|
+
return {
|
|
85
|
+
price: quote.postMarketPrice,
|
|
86
|
+
change: quote.postMarketChange ?? 0,
|
|
87
|
+
changePercent: quote.postMarketChangePercent ?? 0,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
price: quote.price,
|
|
92
|
+
change: quote.change,
|
|
93
|
+
changePercent: quote.changePercent,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { BrokerContractRef } from "../types/instrument";
|
|
2
|
+
import type { TickerRecord } from "../types/ticker";
|
|
3
|
+
|
|
4
|
+
export const MONTH_ABBREV = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
5
|
+
|
|
6
|
+
/** Format a unix timestamp (seconds) as "Mon DD 'YY" */
|
|
7
|
+
export function formatExpDate(ts: number): string {
|
|
8
|
+
const d = new Date(ts * 1000);
|
|
9
|
+
const month = MONTH_ABBREV[d.getMonth()] || String(d.getMonth() + 1);
|
|
10
|
+
const yy = String(d.getFullYear()).slice(2);
|
|
11
|
+
return `${month} ${d.getDate()} '${yy}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse IBKR option symbol like "UBER 260821C00090000" into components.
|
|
16
|
+
* Returns null if not a recognized option symbol.
|
|
17
|
+
*/
|
|
18
|
+
export function parseOptionSymbol(symbol: string): { underlying: string; expTs: number; strike: number; side: "C" | "P" } | null {
|
|
19
|
+
const m = symbol.match(/^(\S+)\s+(\d{2})(\d{2})(\d{2})([CP])(\d{8})$/);
|
|
20
|
+
if (!m) return null;
|
|
21
|
+
const [, underlying, yy, mm, dd, side, rawStrike] = m;
|
|
22
|
+
const strike = parseInt(rawStrike!, 10) / 1000;
|
|
23
|
+
const year = 2000 + parseInt(yy!, 10);
|
|
24
|
+
const month = parseInt(mm!, 10) - 1;
|
|
25
|
+
const day = parseInt(dd!, 10);
|
|
26
|
+
const expTs = Math.floor(new Date(year, month, day).getTime() / 1000);
|
|
27
|
+
return { underlying: underlying!, expTs, strike, side: side as "C" | "P" };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResolvedOptionsTarget {
|
|
31
|
+
isOptionTicker: boolean;
|
|
32
|
+
parsedOption: ReturnType<typeof parseOptionSymbol>;
|
|
33
|
+
effectiveTicker: string;
|
|
34
|
+
effectiveExchange: string;
|
|
35
|
+
instrument: BrokerContractRef | null;
|
|
36
|
+
cacheKey: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function buildOptionsCacheKey(
|
|
40
|
+
effectiveTicker: string,
|
|
41
|
+
effectiveExchange: string,
|
|
42
|
+
instrument: BrokerContractRef | null,
|
|
43
|
+
): string {
|
|
44
|
+
return [
|
|
45
|
+
effectiveTicker.trim().toUpperCase(),
|
|
46
|
+
effectiveExchange.trim().toUpperCase(),
|
|
47
|
+
instrument?.brokerInstanceId ?? "",
|
|
48
|
+
instrument?.conId != null ? String(instrument.conId) : "",
|
|
49
|
+
].join("|");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveOptionsTarget(ticker: TickerRecord | null | undefined): ResolvedOptionsTarget | null {
|
|
53
|
+
if (!ticker) return null;
|
|
54
|
+
|
|
55
|
+
const isOptionTicker = ticker.metadata.assetCategory === "OPT";
|
|
56
|
+
const parsedOption = isOptionTicker ? parseOptionSymbol(ticker.metadata.ticker) : null;
|
|
57
|
+
const effectiveTicker = parsedOption?.underlying ?? ticker.metadata.ticker;
|
|
58
|
+
|
|
59
|
+
if (!effectiveTicker) return null;
|
|
60
|
+
|
|
61
|
+
const effectiveExchange = isOptionTicker ? "" : (ticker.metadata.exchange ?? "");
|
|
62
|
+
const instrument = ticker.metadata.broker_contracts?.[0] ?? null;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
isOptionTicker,
|
|
66
|
+
parsedOption,
|
|
67
|
+
effectiveTicker,
|
|
68
|
+
effectiveExchange,
|
|
69
|
+
instrument,
|
|
70
|
+
cacheKey: buildOptionsCacheKey(effectiveTicker, effectiveExchange, instrument),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Format IBKR option symbol like "UBER 260821C00090000" into readable form.
|
|
76
|
+
* e.g. "UBER C $90 Aug'26"
|
|
77
|
+
*/
|
|
78
|
+
export function formatOptionTicker(symbol: string): string {
|
|
79
|
+
const m = symbol.match(/^(\S+)\s+(\d{2})(\d{2})(\d{2})([CP])(\d{8})$/);
|
|
80
|
+
if (!m) return symbol;
|
|
81
|
+
const [, underlying, yy, mm, , side, rawStrike] = m;
|
|
82
|
+
const strike = parseInt(rawStrike!, 10) / 1000;
|
|
83
|
+
const month = MONTH_ABBREV[parseInt(mm!, 10) - 1] || mm;
|
|
84
|
+
const strikeStr = strike % 1 === 0 ? String(strike) : strike.toFixed(1);
|
|
85
|
+
return `${underlying} ${side === "C" ? "C" : "P"} $${strikeStr} ${month}'${yy}`;
|
|
86
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { PricePoint, TickerFinancials } from "../types/financials";
|
|
2
|
+
|
|
3
|
+
function getPointTimestamp(point: PricePoint): number {
|
|
4
|
+
return point.date instanceof Date ? point.date.getTime() : new Date(point.date).getTime();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function comparePricePointsByDate(left: PricePoint, right: PricePoint): number {
|
|
8
|
+
const leftTime = getPointTimestamp(left);
|
|
9
|
+
const rightTime = getPointTimestamp(right);
|
|
10
|
+
const leftValid = Number.isFinite(leftTime);
|
|
11
|
+
const rightValid = Number.isFinite(rightTime);
|
|
12
|
+
|
|
13
|
+
if (leftValid && rightValid) return leftTime - rightTime;
|
|
14
|
+
if (leftValid) return -1;
|
|
15
|
+
if (rightValid) return 1;
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function normalizePriceHistory(points: PricePoint[]): PricePoint[] {
|
|
20
|
+
if (points.length <= 1) return points;
|
|
21
|
+
|
|
22
|
+
let previousTime = Number.NEGATIVE_INFINITY;
|
|
23
|
+
for (const point of points) {
|
|
24
|
+
const time = getPointTimestamp(point);
|
|
25
|
+
if (!Number.isFinite(time) || time < previousTime) {
|
|
26
|
+
return [...points].sort(comparePricePointsByDate);
|
|
27
|
+
}
|
|
28
|
+
previousTime = time;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return points;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function normalizeTickerFinancialsPriceHistory(financials: TickerFinancials): TickerFinancials {
|
|
35
|
+
const priceHistory = normalizePriceHistory(financials.priceHistory ?? []);
|
|
36
|
+
return priceHistory === financials.priceHistory
|
|
37
|
+
? financials
|
|
38
|
+
: { ...financials, priceHistory };
|
|
39
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { clampQuoteTimestamp, formatQuoteAge, formatQuoteAgeWithSource, getMostRecentQuoteUpdate } from "./quote-time";
|
|
3
|
+
|
|
4
|
+
describe("quote-time", () => {
|
|
5
|
+
test("clamps future timestamps to the current render time", () => {
|
|
6
|
+
expect(clampQuoteTimestamp(12_000, 10_000)).toBe(10_000);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("formats future timestamps as zero age instead of a negative value", () => {
|
|
10
|
+
expect(formatQuoteAge(12_000, 10_000)).toBe("0s");
|
|
11
|
+
expect(formatQuoteAgeWithSource({ lastUpdated: 12_000, dataSource: "delayed" }, 10_000)).toBe("◷0s");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("tracks the latest visible quote update", () => {
|
|
15
|
+
expect(getMostRecentQuoteUpdate([
|
|
16
|
+
{ lastUpdated: 8_000 },
|
|
17
|
+
null,
|
|
18
|
+
{ lastUpdated: 15_000 },
|
|
19
|
+
{ lastUpdated: 9_000 },
|
|
20
|
+
], 10_000)).toBe(10_000);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Quote } from "../types/financials";
|
|
2
|
+
|
|
3
|
+
export function clampQuoteTimestamp(lastUpdated: number | undefined, now = Date.now()): number | null {
|
|
4
|
+
if (typeof lastUpdated !== "number" || !Number.isFinite(lastUpdated) || lastUpdated <= 0) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
return Math.min(lastUpdated, now);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function formatQuoteAge(lastUpdated: number | undefined, now = Date.now()): string {
|
|
11
|
+
const clamped = clampQuoteTimestamp(lastUpdated, now);
|
|
12
|
+
if (clamped == null) return "—";
|
|
13
|
+
|
|
14
|
+
const ageSeconds = Math.max(0, (now - clamped) / 1000);
|
|
15
|
+
if (ageSeconds < 60) return `${Math.floor(ageSeconds)}s`;
|
|
16
|
+
if (ageSeconds < 3600) return `${Math.floor(ageSeconds / 60)}m`;
|
|
17
|
+
if (ageSeconds < 86400) return `${Math.floor(ageSeconds / 3600)}h`;
|
|
18
|
+
return `${Math.floor(ageSeconds / 86400)}d`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatQuoteAgeWithSource(
|
|
22
|
+
quote: Pick<Quote, "lastUpdated" | "dataSource"> | null | undefined,
|
|
23
|
+
now = Date.now(),
|
|
24
|
+
): string {
|
|
25
|
+
if (!quote) return "—";
|
|
26
|
+
|
|
27
|
+
const age = formatQuoteAge(quote.lastUpdated, now);
|
|
28
|
+
if (age === "—") return age;
|
|
29
|
+
|
|
30
|
+
const prefix = quote.dataSource === "delayed"
|
|
31
|
+
? "◷"
|
|
32
|
+
: quote.dataSource === "yahoo"
|
|
33
|
+
? "◌"
|
|
34
|
+
: "";
|
|
35
|
+
return prefix ? `${prefix}${age}` : age;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getMostRecentQuoteUpdate(
|
|
39
|
+
quotes: Iterable<Pick<Quote, "lastUpdated"> | null | undefined>,
|
|
40
|
+
now = Date.now(),
|
|
41
|
+
): number | null {
|
|
42
|
+
let latest: number | null = null;
|
|
43
|
+
for (const quote of quotes) {
|
|
44
|
+
const timestamp = clampQuoteTimestamp(quote?.lastUpdated, now);
|
|
45
|
+
if (timestamp != null && (latest == null || timestamp > latest)) {
|
|
46
|
+
latest = timestamp;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return latest;
|
|
50
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { TickerRecord } from "../types/ticker";
|
|
3
|
+
import { isUsEquityTicker } from "./sec";
|
|
4
|
+
|
|
5
|
+
function makeTicker(overrides: Partial<TickerRecord["metadata"]>): TickerRecord {
|
|
6
|
+
return {
|
|
7
|
+
metadata: {
|
|
8
|
+
ticker: "AAPL",
|
|
9
|
+
exchange: "NASDAQ",
|
|
10
|
+
currency: "USD",
|
|
11
|
+
name: "Apple Inc.",
|
|
12
|
+
portfolios: [],
|
|
13
|
+
watchlists: [],
|
|
14
|
+
positions: [],
|
|
15
|
+
custom: {},
|
|
16
|
+
tags: [],
|
|
17
|
+
...overrides,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("isUsEquityTicker", () => {
|
|
23
|
+
test("accepts plain US stocks", () => {
|
|
24
|
+
expect(isUsEquityTicker(makeTicker({ assetCategory: "STK" }))).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("accepts SMART-routed US stocks with a primary exchange", () => {
|
|
28
|
+
expect(isUsEquityTicker(makeTicker({
|
|
29
|
+
exchange: "SMART",
|
|
30
|
+
assetCategory: "STK",
|
|
31
|
+
broker_contracts: [{
|
|
32
|
+
brokerId: "ibkr",
|
|
33
|
+
symbol: "AAPL",
|
|
34
|
+
exchange: "SMART",
|
|
35
|
+
primaryExchange: "NASDAQ",
|
|
36
|
+
secType: "STK",
|
|
37
|
+
currency: "USD",
|
|
38
|
+
}],
|
|
39
|
+
}))).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("rejects non-US listings", () => {
|
|
43
|
+
expect(isUsEquityTicker(makeTicker({
|
|
44
|
+
exchange: "HKEX",
|
|
45
|
+
currency: "HKD",
|
|
46
|
+
assetCategory: "STK",
|
|
47
|
+
}))).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("rejects non-equity instruments", () => {
|
|
51
|
+
expect(isUsEquityTicker(makeTicker({
|
|
52
|
+
assetCategory: "OPT",
|
|
53
|
+
}))).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
package/src/utils/sec.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { TickerRecord } from "../types/ticker";
|
|
2
|
+
|
|
3
|
+
const US_EQUITY_EXCHANGES = new Set([
|
|
4
|
+
"AMEX",
|
|
5
|
+
"ARCA",
|
|
6
|
+
"BATS",
|
|
7
|
+
"BYX",
|
|
8
|
+
"IEX",
|
|
9
|
+
"NASDAQ",
|
|
10
|
+
"NMS",
|
|
11
|
+
"NYSE",
|
|
12
|
+
"NYSEARCA",
|
|
13
|
+
"OTC",
|
|
14
|
+
"PINK",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function normalize(value?: string): string {
|
|
18
|
+
return (value ?? "").trim().toUpperCase();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isUsExchange(value?: string): boolean {
|
|
22
|
+
return US_EQUITY_EXCHANGES.has(normalize(value));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isEquityType(value?: string): boolean {
|
|
26
|
+
const normalized = normalize(value);
|
|
27
|
+
return normalized.length === 0 || normalized === "STK" || normalized === "EQUITY";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isUsEquityTicker(ticker: TickerRecord | null | undefined): boolean {
|
|
31
|
+
if (!ticker) return false;
|
|
32
|
+
|
|
33
|
+
const primaryContract = ticker.metadata.broker_contracts?.[0];
|
|
34
|
+
const type = primaryContract?.secType ?? ticker.metadata.assetCategory;
|
|
35
|
+
const currency = normalize(primaryContract?.currency ?? ticker.metadata.currency);
|
|
36
|
+
const exchangeCandidates = [
|
|
37
|
+
primaryContract?.primaryExchange,
|
|
38
|
+
primaryContract?.exchange,
|
|
39
|
+
ticker.metadata.exchange,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
return isEquityType(type)
|
|
43
|
+
&& currency === "USD"
|
|
44
|
+
&& exchangeCandidates.some((exchange) => isUsExchange(exchange));
|
|
45
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { formatTickerListInput, parseTickerListInput } from "./ticker-list";
|
|
3
|
+
|
|
4
|
+
describe("parseTickerListInput", () => {
|
|
5
|
+
test("normalizes, de-duplicates, and preserves order", () => {
|
|
6
|
+
expect(parseTickerListInput(" msft, aapl,\nMSFT, nvda ")).toEqual(["MSFT", "AAPL", "NVDA"]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("rejects empty ticker lists", () => {
|
|
10
|
+
expect(() => parseTickerListInput(" , \n ")).toThrow("Enter at least one ticker.");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("rejects lists beyond the maximum size", () => {
|
|
14
|
+
expect(() => parseTickerListInput("A,B,C,D,E,F,G,H,I,J,K", 10)).toThrow("You can compare up to 10 tickers.");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("formatTickerListInput", () => {
|
|
19
|
+
test("joins symbols with a stable display format", () => {
|
|
20
|
+
expect(formatTickerListInput(["AAPL", "MSFT", "NVDA"])).toBe("AAPL, MSFT, NVDA");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const MAX_TICKER_LIST_SIZE = 10;
|
|
2
|
+
|
|
3
|
+
function normalizeTickerToken(value: string): string {
|
|
4
|
+
return value.trim().toUpperCase();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function parseTickerListInput(raw: string, maxTickers = MAX_TICKER_LIST_SIZE): string[] {
|
|
8
|
+
const tokens = raw
|
|
9
|
+
.split(/[,\n]/)
|
|
10
|
+
.map(normalizeTickerToken)
|
|
11
|
+
.filter(Boolean);
|
|
12
|
+
|
|
13
|
+
const unique: string[] = [];
|
|
14
|
+
const seen = new Set<string>();
|
|
15
|
+
|
|
16
|
+
for (const token of tokens) {
|
|
17
|
+
if (seen.has(token)) continue;
|
|
18
|
+
seen.add(token);
|
|
19
|
+
unique.push(token);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (unique.length === 0) {
|
|
23
|
+
throw new Error("Enter at least one ticker.");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (unique.length > maxTickers) {
|
|
27
|
+
throw new Error(`You can compare up to ${maxTickers} tickers.`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return unique;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatTickerListInput(symbols: string[]): string {
|
|
34
|
+
return symbols.join(", ");
|
|
35
|
+
}
|