gloomberb 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/app.tsx +184 -69
- package/src/cli/commands/ticker.ts +45 -15
- package/src/cli/helpers.ts +4 -2
- package/src/cli/registry.test.ts +42 -35
- package/src/cli.test.ts +4 -2
- package/src/components/chart/chart-control-hint.tsx +39 -0
- package/src/components/chart/chart-controller.test.ts +110 -0
- package/src/components/chart/chart-controller.ts +364 -2
- package/src/components/chart/chart-data.ts +177 -29
- package/src/components/chart/chart-indicator-selector.tsx +53 -0
- package/src/components/chart/chart-pane-settings.ts +20 -0
- package/src/components/chart/chart-renderer.ts +220 -30
- package/src/components/chart/chart-resolution.test.ts +67 -0
- package/src/components/chart/chart-resolution.ts +74 -4
- package/src/components/chart/chart-scroll.test.ts +69 -0
- package/src/components/chart/chart-scroll.ts +104 -0
- package/src/components/chart/chart-types.ts +12 -0
- package/src/components/chart/chart-viewport.test.ts +5 -1
- package/src/components/chart/chart-viewport.ts +1 -1
- package/src/components/chart/chart.test.ts +249 -10
- package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
- package/src/components/chart/comparison-chart-renderer.ts +40 -3
- package/src/components/chart/comparison-stock-chart.tsx +218 -123
- package/src/components/chart/indicators/bands.test.ts +50 -0
- package/src/components/chart/indicators/bands.ts +29 -0
- package/src/components/chart/indicators/moving-averages.test.ts +54 -0
- package/src/components/chart/indicators/moving-averages.ts +42 -0
- package/src/components/chart/indicators/options.test.ts +32 -0
- package/src/components/chart/indicators/options.ts +107 -0
- package/src/components/chart/indicators/oscillators.test.ts +79 -0
- package/src/components/chart/indicators/oscillators.ts +92 -0
- package/src/components/chart/indicators/types.ts +36 -0
- package/src/components/chart/native/chart-rasterizer.ts +51 -0
- package/src/components/chart/native/kitty.test.ts +68 -0
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
- package/src/components/chart/stock-chart.test.ts +239 -0
- package/src/components/chart/stock-chart.tsx +2257 -372
- package/src/components/command-bar/command-bar.test.tsx +337 -5
- package/src/components/command-bar/command-bar.tsx +296 -120
- package/src/components/command-bar/helpers.ts +1 -1
- package/src/components/command-bar/root-shortcuts.ts +33 -4
- package/src/components/command-bar/view-model.test.ts +3 -3
- package/src/components/command-bar/view-model.ts +4 -4
- package/src/components/command-bar/workflow-ops.ts +10 -0
- package/src/components/layout/floating-pane.tsx +3 -1
- package/src/components/layout/header.tsx +5 -4
- package/src/components/layout/pane-sizing.ts +6 -0
- package/src/components/layout/pane.tsx +5 -1
- package/src/components/layout/shell.test.tsx +263 -32
- package/src/components/layout/shell.tsx +10 -6
- package/src/components/layout/status-bar.test.tsx +16 -7
- package/src/components/layout/status-bar.tsx +4 -1
- package/src/components/markdown-editor.tsx +1 -0
- package/src/components/pane-settings-dialog.tsx +13 -152
- package/src/components/pane-template-wizard.tsx +1 -0
- package/src/components/price-selector-dialog.tsx +12 -7
- package/src/components/ticker-badge-text.test.tsx +50 -0
- package/src/components/ticker-badge-text.tsx +17 -2
- package/src/components/ticker-badge.tsx +2 -2
- package/src/components/ticker-list-table.tsx +62 -2
- package/src/components/toggle-list.tsx +15 -2
- package/src/components/ui/data-table.tsx +82 -5
- package/src/components/ui/external-link.tsx +48 -11
- package/src/components/ui/fields.tsx +2 -1
- package/src/components/ui/index.ts +8 -1
- package/src/components/ui/multi-select-chips.tsx +69 -0
- package/src/components/ui/multi-select-dialog.tsx +267 -0
- package/src/components/ui/multi-select.ts +78 -0
- package/src/components/ui/ui.test.tsx +343 -1
- package/src/data/plugin-state-store.ts +30 -19
- package/src/data/resource-store.ts +133 -103
- package/src/data/session-store.ts +19 -12
- package/src/data/sqlite-cache.test.ts +37 -2
- package/src/data/sqlite-database.ts +8 -2
- package/src/data/sqlite-retry.test.ts +63 -0
- package/src/data/sqlite-retry.ts +66 -0
- package/src/data/ticker-store.ts +29 -18
- package/src/index.tsx +6 -1
- package/src/market-data/coordinator.test.ts +114 -5
- package/src/market-data/coordinator.ts +70 -1
- package/src/market-data/hooks.test.tsx +73 -2
- package/src/market-data/hooks.tsx +16 -4
- package/src/notifications/app-notifier.test.ts +177 -0
- package/src/notifications/app-notifier.ts +241 -0
- package/src/pane-settings.test.ts +2 -0
- package/src/plugins/builtin/ai/index.test.tsx +1 -1
- package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
- package/src/plugins/builtin/ai/ticker-context.ts +3 -2
- package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
- package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
- package/src/plugins/builtin/alerts/index.test.tsx +357 -0
- package/src/plugins/builtin/alerts/index.tsx +519 -0
- package/src/plugins/builtin/alerts/types.ts +14 -0
- package/src/plugins/builtin/analytics/index.test.tsx +300 -0
- package/src/plugins/builtin/analytics/index.tsx +775 -0
- package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
- package/src/plugins/builtin/analytics/metrics.ts +155 -0
- package/src/plugins/builtin/ask-ai.test.tsx +2 -0
- package/src/plugins/builtin/chat-controller.test.ts +92 -18
- package/src/plugins/builtin/chat-controller.ts +40 -11
- package/src/plugins/builtin/chat.test.tsx +715 -4
- package/src/plugins/builtin/chat.tsx +420 -119
- package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
- package/src/plugins/builtin/comparison-chart.tsx +1 -2
- package/src/plugins/builtin/correlation/compute.test.ts +147 -0
- package/src/plugins/builtin/correlation/compute.ts +115 -0
- package/src/plugins/builtin/correlation/index.tsx +385 -0
- package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
- package/src/plugins/builtin/correlation/settings.test.ts +34 -0
- package/src/plugins/builtin/correlation/settings.ts +95 -0
- package/src/plugins/builtin/debug.tsx +36 -50
- package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
- package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
- package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
- package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
- package/src/plugins/builtin/earnings/index.tsx +337 -0
- package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
- package/src/plugins/builtin/econ/calendar-source.ts +121 -0
- package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
- package/src/plugins/builtin/econ/fred-client.ts +79 -0
- package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
- package/src/plugins/builtin/econ/index.tsx +999 -0
- package/src/plugins/builtin/econ/types.ts +13 -0
- package/src/plugins/builtin/fred-settings.ts +10 -0
- package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
- package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
- package/src/plugins/builtin/help.test.tsx +1 -5
- package/src/plugins/builtin/help.tsx +1 -16
- package/src/plugins/builtin/insider/index.tsx +417 -0
- package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
- package/src/plugins/builtin/insider/insider-data.ts +49 -0
- package/src/plugins/builtin/layout-manager.tsx +33 -16
- package/src/plugins/builtin/market-movers/index.tsx +573 -0
- package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
- package/src/plugins/builtin/market-movers/screener.ts +105 -0
- package/src/plugins/builtin/options.tsx +4 -3
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
- package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
- package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
- package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
- package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
- package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
- package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
- package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
- package/src/plugins/builtin/sec.tsx +114 -7
- package/src/plugins/builtin/sectors/index.tsx +359 -0
- package/src/plugins/builtin/sectors/sector-data.ts +18 -0
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
- package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
- package/src/plugins/builtin/world-indices/index.tsx +410 -0
- package/src/plugins/builtin/world-indices/indices.ts +52 -0
- package/src/plugins/builtin/yield-curve/index.tsx +242 -0
- package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
- package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
- package/src/plugins/catalog.ts +22 -0
- package/src/plugins/gridlock-notification.ts +15 -0
- package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
- package/src/plugins/ibkr/gateway-service.test.ts +22 -0
- package/src/plugins/ibkr/gateway-service.ts +6 -0
- package/src/plugins/ibkr/index.test.tsx +1 -1
- package/src/plugins/ibkr/index.tsx +8 -2
- package/src/plugins/ibkr/trade-tab.tsx +23 -5
- package/src/plugins/ibkr/trade-utils.test.ts +1 -1
- package/src/plugins/ibkr/trade-utils.ts +7 -6
- package/src/plugins/ibkr/trading-pane.tsx +8 -7
- package/src/plugins/plugin-runtime.test.tsx +2 -0
- package/src/plugins/plugin-runtime.tsx +10 -0
- package/src/plugins/prediction-markets/controller-data.ts +129 -14
- package/src/plugins/prediction-markets/controller.ts +1 -1
- package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
- package/src/plugins/prediction-markets/index.tsx +5 -1
- package/src/plugins/prediction-markets/pane.test.tsx +78 -0
- package/src/plugins/prediction-markets/pane.tsx +7 -3
- package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
- package/src/plugins/prediction-markets/services/fetch.ts +17 -7
- package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
- package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
- package/src/plugins/prediction-markets/test-helpers.tsx +22 -5
- package/src/plugins/registry.ts +45 -13
- package/src/sources/provider-router.test.ts +3 -2
- package/src/sources/sec-edgar.ts +13 -1
- package/src/sources/yahoo-finance.ts +75 -1
- package/src/state/app-context-selector.test.tsx +17 -1
- package/src/state/app-context.test.tsx +24 -0
- package/src/state/app-context.tsx +29 -6
- package/src/theme/colors.ts +5 -0
- package/src/theme/themes.ts +25 -0
- package/src/types/config.test.ts +107 -0
- package/src/types/config.ts +30 -1
- package/src/types/data-provider.ts +13 -0
- package/src/types/plugin.ts +35 -1
- package/src/updater.test.ts +2 -0
- package/src/utils/api-client.ts +12 -0
- package/src/utils/format.test.ts +11 -1
- package/src/utils/format.ts +86 -8
- package/src/utils/inline-content-tokenizer.test.ts +22 -0
- package/src/utils/inline-content-tokenizer.ts +76 -0
- package/src/utils/link-tokenizer.test.ts +27 -0
- package/src/utils/link-tokenizer.ts +93 -0
- package/src/utils/market-format.test.ts +109 -0
- package/src/utils/market-format.ts +299 -0
- package/src/utils/market-status.ts +3 -2
- package/src/utils/selection-clipboard.test.ts +43 -0
- package/src/utils/selection-clipboard.ts +88 -0
- package/src/utils/terminal-input-reset.test.ts +50 -0
- package/src/utils/terminal-input-reset.ts +24 -0
- package/src/utils/throttled-fetch.test.ts +145 -0
- package/src/utils/throttled-fetch.ts +212 -0
- package/src/utils/ticker-tokenizer.test.ts +31 -0
- package/src/utils/ticker-tokenizer.ts +7 -42
- package/src/version.ts +1 -1
package/src/types/config.ts
CHANGED
|
@@ -179,7 +179,7 @@ export const DEFAULT_LAYOUT: LayoutConfig = {
|
|
|
179
179
|
hideTabs: false,
|
|
180
180
|
lockedTabId: "overview",
|
|
181
181
|
chartRangePreset: "5Y",
|
|
182
|
-
chartResolution: "
|
|
182
|
+
chartResolution: "auto",
|
|
183
183
|
},
|
|
184
184
|
binding: { kind: "follow", sourceInstanceId: "portfolio-list:main" },
|
|
185
185
|
},
|
|
@@ -317,6 +317,35 @@ export function isFixedTickerPane(instance: PaneInstanceConfig): boolean {
|
|
|
317
317
|
return isTickerPaneInstance(instance) && instance.binding?.kind === "fixed";
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
export function findPrimaryPaneInstance(layout: LayoutConfig, paneId: string): PaneInstanceConfig | undefined {
|
|
321
|
+
const instances = layout.instances.filter((instance) => instance.paneId === paneId);
|
|
322
|
+
if (instances.length === 0) return undefined;
|
|
323
|
+
if (!isTickerPaneId(paneId)) return instances[0];
|
|
324
|
+
return instances.find((instance) => instance.instanceId === `${paneId}:main` && instance.binding?.kind !== "fixed")
|
|
325
|
+
?? instances.find((instance) => instance.binding?.kind !== "fixed");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function resolvePaneInstance(layout: LayoutConfig, paneIdOrInstanceId: string): PaneInstanceConfig | undefined {
|
|
329
|
+
return findPaneInstance(layout, paneIdOrInstanceId) ?? findPrimaryPaneInstance(layout, paneIdOrInstanceId);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function resolveFollowBindingInstance(
|
|
333
|
+
layout: LayoutConfig,
|
|
334
|
+
paneIdOrInstanceId: string | null | undefined,
|
|
335
|
+
matcher: (instance: PaneInstanceConfig) => boolean,
|
|
336
|
+
seen = new Set<string>(),
|
|
337
|
+
): PaneInstanceConfig | undefined {
|
|
338
|
+
if (!paneIdOrInstanceId) return undefined;
|
|
339
|
+
const instance = resolvePaneInstance(layout, paneIdOrInstanceId);
|
|
340
|
+
if (!instance || seen.has(instance.instanceId)) return undefined;
|
|
341
|
+
seen.add(instance.instanceId);
|
|
342
|
+
if (matcher(instance)) return instance;
|
|
343
|
+
if (instance.binding?.kind === "follow") {
|
|
344
|
+
return resolveFollowBindingInstance(layout, instance.binding.sourceInstanceId, matcher, seen);
|
|
345
|
+
}
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
320
349
|
export function createPaneInstance(
|
|
321
350
|
paneId: string,
|
|
322
351
|
options: Partial<PaneInstanceConfig> = {},
|
|
@@ -26,6 +26,18 @@ export interface SecFilingItem {
|
|
|
26
26
|
primaryDocumentUrl?: string;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
export interface EarningsEvent {
|
|
30
|
+
symbol: string;
|
|
31
|
+
name: string;
|
|
32
|
+
earningsDate: Date;
|
|
33
|
+
epsEstimate: number | null;
|
|
34
|
+
epsActual: number | null;
|
|
35
|
+
revenueEstimate: number | null;
|
|
36
|
+
revenueActual: number | null;
|
|
37
|
+
surprise: number | null;
|
|
38
|
+
timing: "BMO" | "AMC" | "TNS" | "";
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
export interface MarketDataRequestContext {
|
|
30
42
|
brokerId?: string;
|
|
31
43
|
brokerInstanceId?: string;
|
|
@@ -68,6 +80,7 @@ export interface DataProvider {
|
|
|
68
80
|
search(query: string, context?: SearchRequestContext): Promise<InstrumentSearchResult[]>;
|
|
69
81
|
getNews(ticker: string, count?: number, exchange?: string, context?: MarketDataRequestContext): Promise<NewsItem[]>;
|
|
70
82
|
getSecFilings?(ticker: string, count?: number, exchange?: string, context?: MarketDataRequestContext): Promise<SecFilingItem[]>;
|
|
83
|
+
getEarningsCalendar?(symbols: string[], context?: MarketDataRequestContext): Promise<EarningsEvent[]>;
|
|
71
84
|
getSecFilingContent?(filing: SecFilingItem): Promise<string | null>;
|
|
72
85
|
/** Fetch article summary/description by URL (lazy-loaded on selection) */
|
|
73
86
|
getArticleSummary(url: string): Promise<string | null>;
|
package/src/types/plugin.ts
CHANGED
|
@@ -169,6 +169,19 @@ export interface WizardStep {
|
|
|
169
169
|
body?: string[];
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
export interface CommandShortcutArgContext {
|
|
173
|
+
activeTicker: string | null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface CommandShortcutArgDef {
|
|
177
|
+
placeholder?: string;
|
|
178
|
+
kind?: "text" | "ticker" | "ticker-list";
|
|
179
|
+
parse?: (
|
|
180
|
+
arg: string,
|
|
181
|
+
context: CommandShortcutArgContext,
|
|
182
|
+
) => Record<string, string>;
|
|
183
|
+
}
|
|
184
|
+
|
|
172
185
|
export interface CliHelpColumn {
|
|
173
186
|
header: string;
|
|
174
187
|
align?: "left" | "right" | "center";
|
|
@@ -243,6 +256,7 @@ export interface CommandDef {
|
|
|
243
256
|
label: string;
|
|
244
257
|
keywords: string[];
|
|
245
258
|
shortcut?: string;
|
|
259
|
+
shortcutArg?: CommandShortcutArgDef;
|
|
246
260
|
execute: (values?: Record<string, string>) => void | Promise<void>;
|
|
247
261
|
category: "navigation" | "data" | "portfolio" | "config";
|
|
248
262
|
description?: string;
|
|
@@ -355,6 +369,25 @@ export interface PluginPaneSettingsState {
|
|
|
355
369
|
delete(paneId: string, key: string): Promise<void>;
|
|
356
370
|
}
|
|
357
371
|
|
|
372
|
+
export type AppNotificationType = "info" | "success" | "error";
|
|
373
|
+
export type AppDesktopNotificationMode = "never" | "when-inactive" | "always";
|
|
374
|
+
|
|
375
|
+
export interface AppNotificationRequest {
|
|
376
|
+
title?: string;
|
|
377
|
+
body: string;
|
|
378
|
+
subtitle?: string;
|
|
379
|
+
duration?: number;
|
|
380
|
+
type?: AppNotificationType;
|
|
381
|
+
toast?: boolean;
|
|
382
|
+
desktop?: AppDesktopNotificationMode;
|
|
383
|
+
persistent?: boolean;
|
|
384
|
+
sound?: string;
|
|
385
|
+
action?: {
|
|
386
|
+
label: string;
|
|
387
|
+
onClick: () => void;
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
358
391
|
export interface GloomPluginContext {
|
|
359
392
|
registerPane(pane: PaneDef): void;
|
|
360
393
|
registerPaneTemplate(template: PaneTemplateDef): void;
|
|
@@ -392,6 +425,7 @@ export interface GloomPluginContext {
|
|
|
392
425
|
hidePane(paneId: string): void;
|
|
393
426
|
focusPane(paneId: string): void;
|
|
394
427
|
pinTicker(symbol: string, options?: { floating?: boolean; paneType?: string }): void;
|
|
428
|
+
navigateTicker(symbol: string): void;
|
|
395
429
|
openPaneSettings(paneId?: string): void;
|
|
396
430
|
|
|
397
431
|
on<K extends keyof PluginEvents>(event: K, handler: (payload: PluginEvents[K]) => void): () => void;
|
|
@@ -399,7 +433,7 @@ export interface GloomPluginContext {
|
|
|
399
433
|
|
|
400
434
|
showWidget(widgetId: string): void;
|
|
401
435
|
hideWidget(widgetId: string): void;
|
|
402
|
-
|
|
436
|
+
notify(notification: AppNotificationRequest): void;
|
|
403
437
|
}
|
|
404
438
|
|
|
405
439
|
export interface GloomPlugin {
|
package/src/updater.test.ts
CHANGED
|
@@ -272,6 +272,8 @@ describe("performUpdate", () => {
|
|
|
272
272
|
});
|
|
273
273
|
|
|
274
274
|
expect(readFileSync(execPath)).toEqual(nextBinary);
|
|
275
|
+
expect(progress[0]).toEqual({ phase: "downloading", percent: 0 });
|
|
276
|
+
expect(progress).toContainEqual({ phase: "downloading", percent: 100 });
|
|
275
277
|
expect(progress.at(-1)).toEqual({ phase: "done" });
|
|
276
278
|
} finally {
|
|
277
279
|
Object.defineProperty(process, "execPath", { value: originalExecPath, configurable: true });
|
package/src/utils/api-client.ts
CHANGED
|
@@ -647,6 +647,18 @@ class GloomApiClient {
|
|
|
647
647
|
};
|
|
648
648
|
}
|
|
649
649
|
|
|
650
|
+
dispose(): void {
|
|
651
|
+
cloudApiLog.info("dispose api client", {
|
|
652
|
+
quoteTargets: this.quoteTargets.size,
|
|
653
|
+
channelTargets: this.channelListeners.size,
|
|
654
|
+
});
|
|
655
|
+
this.channelListeners.clear();
|
|
656
|
+
this.quoteListeners.clear();
|
|
657
|
+
this.quoteTargets.clear();
|
|
658
|
+
this.reconnectDelayMs = 1000;
|
|
659
|
+
this.teardownSocket();
|
|
660
|
+
}
|
|
661
|
+
|
|
650
662
|
async searchInstruments(query: string, limit = 10): Promise<InstrumentSearchResult[]> {
|
|
651
663
|
const params = new URLSearchParams({
|
|
652
664
|
q: query,
|
package/src/utils/format.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { formatTimeAgo } from "./format";
|
|
2
|
+
import { displayWidth, formatTimeAgo, padTo } from "./format";
|
|
3
3
|
|
|
4
4
|
describe("formatTimeAgo", () => {
|
|
5
5
|
test("handles UTC ISO timestamps with explicit offsets", () => {
|
|
@@ -12,3 +12,13 @@ describe("formatTimeAgo", () => {
|
|
|
12
12
|
expect(formatTimeAgo(fiveMinutesAgo)).toBe("5m ago");
|
|
13
13
|
});
|
|
14
14
|
});
|
|
15
|
+
|
|
16
|
+
describe("padTo", () => {
|
|
17
|
+
test("pads and truncates by display width instead of UTF-16 length", () => {
|
|
18
|
+
expect(displayWidth("🇺🇸")).toBe(2);
|
|
19
|
+
expect(padTo("🇺🇸", 2)).toBe("🇺🇸");
|
|
20
|
+
expect(padTo("🇺🇸", 3)).toBe("🇺🇸 ");
|
|
21
|
+
expect(padTo("🇺🇸", 1)).toBe(" ");
|
|
22
|
+
expect(padTo("🇺🇸 CPI", 6)).toBe("🇺🇸 CPI");
|
|
23
|
+
});
|
|
24
|
+
});
|
package/src/utils/format.ts
CHANGED
|
@@ -116,17 +116,95 @@ export function formatWithDivisor(value: number | undefined, divisor: number): s
|
|
|
116
116
|
return scaled.toFixed(decimals);
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
const COMBINING_MARK_RE = /\p{Mark}/u;
|
|
120
|
+
const EMOJI_PRESENTATION_RE = /\p{Emoji_Presentation}/u;
|
|
121
|
+
const EXTENDED_PICTOGRAPHIC_RE = /\p{Extended_Pictographic}/u;
|
|
122
|
+
const REGIONAL_INDICATOR_RE = /\p{Regional_Indicator}/u;
|
|
123
|
+
|
|
124
|
+
function segmentGraphemes(value: string): string[] {
|
|
125
|
+
const Segmenter = (Intl as any).Segmenter;
|
|
126
|
+
if (typeof Segmenter === "function") {
|
|
127
|
+
return Array.from(
|
|
128
|
+
new Segmenter(undefined, { granularity: "grapheme" }).segment(value),
|
|
129
|
+
(entry: any) => entry.segment as string,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return Array.from(value);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isFullwidthCodePoint(codePoint: number): boolean {
|
|
136
|
+
return codePoint >= 0x1100 && (
|
|
137
|
+
codePoint <= 0x115f ||
|
|
138
|
+
codePoint === 0x2329 ||
|
|
139
|
+
codePoint === 0x232a ||
|
|
140
|
+
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
|
|
141
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
|
142
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
|
143
|
+
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
|
|
144
|
+
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
|
145
|
+
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
|
146
|
+
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
|
147
|
+
(codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
|
|
148
|
+
(codePoint >= 0x20000 && codePoint <= 0x3fffd)
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function graphemeWidth(segment: string): number {
|
|
153
|
+
if (
|
|
154
|
+
REGIONAL_INDICATOR_RE.test(segment) ||
|
|
155
|
+
EMOJI_PRESENTATION_RE.test(segment) ||
|
|
156
|
+
EXTENDED_PICTOGRAPHIC_RE.test(segment)
|
|
157
|
+
) {
|
|
158
|
+
return 2;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let width = 0;
|
|
162
|
+
for (const char of Array.from(segment)) {
|
|
163
|
+
const codePoint = char.codePointAt(0) ?? 0;
|
|
164
|
+
if (
|
|
165
|
+
codePoint === 0 ||
|
|
166
|
+
codePoint < 32 ||
|
|
167
|
+
(codePoint >= 0x7f && codePoint < 0xa0) ||
|
|
168
|
+
codePoint === 0x200d ||
|
|
169
|
+
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
|
170
|
+
COMBINING_MARK_RE.test(char)
|
|
171
|
+
) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
width += isFullwidthCodePoint(codePoint) ? 2 : 1;
|
|
175
|
+
}
|
|
176
|
+
return width;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function displayWidth(value: string): number {
|
|
180
|
+
return segmentGraphemes(value).reduce((total, segment) => total + graphemeWidth(segment), 0);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function truncateToWidth(value: string, width: number): string {
|
|
184
|
+
if (width <= 0) return "";
|
|
185
|
+
let output = "";
|
|
186
|
+
let used = 0;
|
|
187
|
+
for (const segment of segmentGraphemes(value)) {
|
|
188
|
+
const nextWidth = graphemeWidth(segment);
|
|
189
|
+
if (used + nextWidth > width) break;
|
|
190
|
+
output += segment;
|
|
191
|
+
used += nextWidth;
|
|
192
|
+
}
|
|
193
|
+
return output;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Pad/truncate a string to a fixed display width */
|
|
120
197
|
export function padTo(str: string, width: number, align: "left" | "right" | "center" = "left"): string {
|
|
121
|
-
|
|
122
|
-
|
|
198
|
+
const clipped = displayWidth(str) > width ? truncateToWidth(str, width) : str;
|
|
199
|
+
const clippedWidth = displayWidth(clipped);
|
|
200
|
+
const padding = Math.max(0, width - clippedWidth);
|
|
201
|
+
if (align === "right") return " ".repeat(padding) + clipped;
|
|
123
202
|
if (align === "center") {
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
return " ".repeat(leftPadding) + str + " ".repeat(rightPadding);
|
|
203
|
+
const leftPadding = Math.floor(padding / 2);
|
|
204
|
+
const rightPadding = padding - leftPadding;
|
|
205
|
+
return " ".repeat(leftPadding) + clipped + " ".repeat(rightPadding);
|
|
128
206
|
}
|
|
129
|
-
return
|
|
207
|
+
return clipped + " ".repeat(padding);
|
|
130
208
|
}
|
|
131
209
|
|
|
132
210
|
/** Convert a value from one currency to base currency using cached exchange rates */
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { tokenizeInlineContent } from "./inline-content-tokenizer";
|
|
3
|
+
|
|
4
|
+
describe("tokenizeInlineContent", () => {
|
|
5
|
+
test("returns mixed text, link, and ticker tokens in one pass", () => {
|
|
6
|
+
expect(tokenizeInlineContent("Read https://example.com then watch $NVDA")).toEqual([
|
|
7
|
+
{ kind: "text", value: "Read " },
|
|
8
|
+
{ kind: "link", value: "https://example.com", url: "https://example.com" },
|
|
9
|
+
{ kind: "text", value: " then watch " },
|
|
10
|
+
{ kind: "ticker", value: "$NVDA", symbol: "NVDA" },
|
|
11
|
+
]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("does not split ticker-like text out of links", () => {
|
|
15
|
+
expect(tokenizeInlineContent("Read https://example.com/$TSLA before $NVDA")).toEqual([
|
|
16
|
+
{ kind: "text", value: "Read " },
|
|
17
|
+
{ kind: "link", value: "https://example.com/$TSLA", url: "https://example.com/$TSLA" },
|
|
18
|
+
{ kind: "text", value: " before " },
|
|
19
|
+
{ kind: "ticker", value: "$NVDA", symbol: "NVDA" },
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { tokenizeInlineLinks } from "./link-tokenizer";
|
|
2
|
+
|
|
3
|
+
export interface InlineContentTextToken {
|
|
4
|
+
kind: "text";
|
|
5
|
+
value: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface InlineContentLinkToken {
|
|
9
|
+
kind: "link";
|
|
10
|
+
value: string;
|
|
11
|
+
url: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface InlineContentTickerToken {
|
|
15
|
+
kind: "ticker";
|
|
16
|
+
value: string;
|
|
17
|
+
symbol: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type InlineContentToken =
|
|
21
|
+
| InlineContentTextToken
|
|
22
|
+
| InlineContentLinkToken
|
|
23
|
+
| InlineContentTickerToken;
|
|
24
|
+
|
|
25
|
+
const TICKER_TOKEN_RE = /\$[A-Z][A-Z0-9.-]{0,9}/g;
|
|
26
|
+
const SYMBOL_CHAR_RE = /[A-Za-z0-9.-]/;
|
|
27
|
+
|
|
28
|
+
function trimTrailingTickerPunctuation(value: string): string {
|
|
29
|
+
let trimmed = value;
|
|
30
|
+
while (trimmed.length > 2 && /[.-]$/.test(trimmed)) {
|
|
31
|
+
trimmed = trimmed.slice(0, -1);
|
|
32
|
+
}
|
|
33
|
+
return trimmed;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isValidTickerBoundary(text: string, start: number, end: number): boolean {
|
|
37
|
+
const prev = start > 0 ? text[start - 1] : "";
|
|
38
|
+
const next = end < text.length ? text[end] : "";
|
|
39
|
+
return !SYMBOL_CHAR_RE.test(prev) && !SYMBOL_CHAR_RE.test(next);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function tokenizeInlineContent(text: string): InlineContentToken[] {
|
|
43
|
+
const tokens: InlineContentToken[] = [];
|
|
44
|
+
|
|
45
|
+
for (const segment of tokenizeInlineLinks(text)) {
|
|
46
|
+
if (segment.kind === "link") {
|
|
47
|
+
tokens.push(segment);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let cursor = 0;
|
|
52
|
+
let match: RegExpExecArray | null;
|
|
53
|
+
|
|
54
|
+
TICKER_TOKEN_RE.lastIndex = 0;
|
|
55
|
+
while ((match = TICKER_TOKEN_RE.exec(segment.value)) !== null) {
|
|
56
|
+
const rawValue = match[0];
|
|
57
|
+
const start = match.index;
|
|
58
|
+
const rawEnd = start + rawValue.length;
|
|
59
|
+
const value = trimTrailingTickerPunctuation(rawValue);
|
|
60
|
+
const end = start + value.length;
|
|
61
|
+
if (!isValidTickerBoundary(segment.value, start, rawEnd)) continue;
|
|
62
|
+
|
|
63
|
+
if (start > cursor) {
|
|
64
|
+
tokens.push({ kind: "text", value: segment.value.slice(cursor, start) });
|
|
65
|
+
}
|
|
66
|
+
tokens.push({ kind: "ticker", value, symbol: value.slice(1) });
|
|
67
|
+
cursor = end;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (cursor < segment.value.length) {
|
|
71
|
+
tokens.push({ kind: "text", value: segment.value.slice(cursor) });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return tokens;
|
|
76
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { tokenizeInlineLinks } from "./link-tokenizer";
|
|
3
|
+
|
|
4
|
+
describe("tokenizeInlineLinks", () => {
|
|
5
|
+
test("extracts http links and preserves surrounding text", () => {
|
|
6
|
+
expect(tokenizeInlineLinks("Read https://example.com/story now")).toEqual([
|
|
7
|
+
{ kind: "text", value: "Read " },
|
|
8
|
+
{ kind: "link", value: "https://example.com/story", url: "https://example.com/story" },
|
|
9
|
+
{ kind: "text", value: " now" },
|
|
10
|
+
]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("drops trailing punctuation from the link token", () => {
|
|
14
|
+
expect(tokenizeInlineLinks("See https://example.com/story.)")).toEqual([
|
|
15
|
+
{ kind: "text", value: "See " },
|
|
16
|
+
{ kind: "link", value: "https://example.com/story", url: "https://example.com/story" },
|
|
17
|
+
{ kind: "text", value: ".)" },
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("normalizes www links for opening", () => {
|
|
22
|
+
expect(tokenizeInlineLinks("Visit www.example.com/test")).toEqual([
|
|
23
|
+
{ kind: "text", value: "Visit " },
|
|
24
|
+
{ kind: "link", value: "www.example.com/test", url: "https://www.example.com/test" },
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface InlineLinkTextToken {
|
|
2
|
+
kind: "text";
|
|
3
|
+
value: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface InlineLinkUrlToken {
|
|
7
|
+
kind: "link";
|
|
8
|
+
value: string;
|
|
9
|
+
url: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type InlineLinkToken = InlineLinkTextToken | InlineLinkUrlToken;
|
|
13
|
+
|
|
14
|
+
const LINK_TOKEN_RE = /\b(?:https?:\/\/|www\.)[^\s<>"']+/gi;
|
|
15
|
+
const ALWAYS_TRIM_RE = /[.,!?;:]/;
|
|
16
|
+
const BRACKET_PAIRS: Record<string, string> = {
|
|
17
|
+
")": "(",
|
|
18
|
+
"]": "[",
|
|
19
|
+
"}": "{",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function countChar(value: string, char: string): number {
|
|
23
|
+
let total = 0;
|
|
24
|
+
for (const current of value) {
|
|
25
|
+
if (current === char) total += 1;
|
|
26
|
+
}
|
|
27
|
+
return total;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function trimTrailingLinkPunctuation(rawValue: string): { value: string; trailing: string } {
|
|
31
|
+
let value = rawValue;
|
|
32
|
+
|
|
33
|
+
while (value.length > 0) {
|
|
34
|
+
const lastChar = value[value.length - 1]!;
|
|
35
|
+
if (ALWAYS_TRIM_RE.test(lastChar)) {
|
|
36
|
+
value = value.slice(0, -1);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const openingChar = BRACKET_PAIRS[lastChar];
|
|
41
|
+
if (!openingChar) break;
|
|
42
|
+
|
|
43
|
+
if (countChar(value, lastChar) > countChar(value, openingChar)) {
|
|
44
|
+
value = value.slice(0, -1);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { value, trailing: rawValue.slice(value.length) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeLinkUrl(value: string): string {
|
|
55
|
+
return /^https?:\/\//i.test(value) ? value : `https://${value}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function tokenizeInlineLinks(text: string): InlineLinkToken[] {
|
|
59
|
+
const tokens: InlineLinkToken[] = [];
|
|
60
|
+
let cursor = 0;
|
|
61
|
+
let match: RegExpExecArray | null;
|
|
62
|
+
|
|
63
|
+
LINK_TOKEN_RE.lastIndex = 0;
|
|
64
|
+
while ((match = LINK_TOKEN_RE.exec(text)) !== null) {
|
|
65
|
+
const rawValue = match[0];
|
|
66
|
+
const start = match.index;
|
|
67
|
+
const end = start + rawValue.length;
|
|
68
|
+
|
|
69
|
+
if (start > cursor) {
|
|
70
|
+
tokens.push({ kind: "text", value: text.slice(cursor, start) });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const trimmed = trimTrailingLinkPunctuation(rawValue);
|
|
74
|
+
if (trimmed.value) {
|
|
75
|
+
tokens.push({
|
|
76
|
+
kind: "link",
|
|
77
|
+
value: trimmed.value,
|
|
78
|
+
url: normalizeLinkUrl(trimmed.value),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (trimmed.trailing) {
|
|
82
|
+
tokens.push({ kind: "text", value: trimmed.trailing });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
cursor = end;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (cursor < text.length) {
|
|
89
|
+
tokens.push({ kind: "text", value: text.slice(cursor) });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return tokens;
|
|
93
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
formatMarketCost,
|
|
4
|
+
formatMarketCostWithCurrency,
|
|
5
|
+
formatCompactMarketPriceWithCurrency,
|
|
6
|
+
formatMarketPrice,
|
|
7
|
+
formatMarketPriceWithCurrency,
|
|
8
|
+
formatMarketQuantity,
|
|
9
|
+
formatSignedMarketPrice,
|
|
10
|
+
resolveAssetDisplayKind,
|
|
11
|
+
} from "./market-format";
|
|
12
|
+
|
|
13
|
+
describe("resolveAssetDisplayKind", () => {
|
|
14
|
+
test("prefers explicit cash balances", () => {
|
|
15
|
+
expect(resolveAssetDisplayKind({ isCashBalance: true, assetCategory: "STK" })).toBe("cash");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("maps common broker security types", () => {
|
|
19
|
+
expect(resolveAssetDisplayKind({ assetCategory: "CRYPTO" })).toBe("crypto");
|
|
20
|
+
expect(resolveAssetDisplayKind({ assetCategory: "STK" })).toBe("equity");
|
|
21
|
+
expect(resolveAssetDisplayKind({ contractSecType: "OPT" })).toBe("contract");
|
|
22
|
+
expect(resolveAssetDisplayKind({ assetCategory: "FOREX" })).toBe("cash");
|
|
23
|
+
expect(resolveAssetDisplayKind({ assetCategory: "CURRENCY" })).toBe("cash");
|
|
24
|
+
expect(resolveAssetDisplayKind({ assetCategory: "CCY" })).toBe("cash");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("falls back to contract formatting when only a multiplier is present", () => {
|
|
28
|
+
expect(resolveAssetDisplayKind({ multiplier: 100 })).toBe("contract");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("formatMarketQuantity", () => {
|
|
33
|
+
test("preserves additional FX precision while trimming trailing zeroes", () => {
|
|
34
|
+
expect(formatMarketQuantity(-351957.025, { isCashBalance: true })).toBe("-351,957.025");
|
|
35
|
+
expect(formatMarketQuantity(-303029.144938754, { isCashBalance: true })).toBe("-303,029.144939");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("shows whole equities without decimals and fractional equities with up to four decimals", () => {
|
|
39
|
+
expect(formatMarketQuantity(10, { assetCategory: "STK" })).toBe("10");
|
|
40
|
+
expect(formatMarketQuantity(10.125, { assetCategory: "STK" })).toBe("10.125");
|
|
41
|
+
expect(formatMarketQuantity(10.123456, { assetCategory: "STK" })).toBe("10.1235");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("uses higher precision for crypto quantities", () => {
|
|
45
|
+
expect(formatMarketQuantity(0.123456789, { assetCategory: "CRYPTO" })).toBe("0.12345679");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("formatMarketPrice", () => {
|
|
50
|
+
test("uses semantic precision for equities, cash, and crypto", () => {
|
|
51
|
+
expect(formatMarketPrice(190.2, { assetCategory: "STK" })).toBe("190.2");
|
|
52
|
+
expect(formatMarketPrice(259.7499, { assetCategory: "STK" })).toBe("259.75");
|
|
53
|
+
expect(formatMarketPrice(1.084567, { isCashBalance: true })).toBe("1.084567");
|
|
54
|
+
expect(formatMarketPrice(1.17364, { assetCategory: "CURRENCY" })).toBe("1.17364");
|
|
55
|
+
expect(formatMarketPrice(0.000123456789, { assetCategory: "CRYPTO" })).toBe("0.00012346");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("can adapt chart precision to the visible price range", () => {
|
|
59
|
+
expect(formatMarketPrice(1.167815, { assetCategory: "CURRENCY", priceRange: 0.12 })).toBe("1.17");
|
|
60
|
+
expect(formatMarketPrice(1.167815, { assetCategory: "CURRENCY", priceRange: 0.0024 })).toBe("1.1678");
|
|
61
|
+
expect(formatMarketPrice(259.7499, {
|
|
62
|
+
assetCategory: "STK",
|
|
63
|
+
minimumFractionDigits: 2,
|
|
64
|
+
precisionOffset: 1,
|
|
65
|
+
priceRange: 80,
|
|
66
|
+
})).toBe("259.75");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("can lock price displays to a specific number of decimals", () => {
|
|
70
|
+
expect(formatMarketPrice(18, { assetCategory: "STK", fixedFractionDigits: 1 })).toBe("18.0");
|
|
71
|
+
expect(formatMarketPriceWithCurrency(18, "USD", { assetCategory: "STK", fixedFractionDigits: 2 })).toBe("$18.00");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("fits within the supplied width before falling back to truncation elsewhere", () => {
|
|
75
|
+
expect(formatMarketPrice(1.084567, { isCashBalance: true, maxWidth: 6 })).toBe("1.0846");
|
|
76
|
+
expect(formatMarketQuantity(123456.789, { assetCategory: "CRYPTO", maxWidth: 7 })).toBe("123,457");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("formats signed price changes without double signs", () => {
|
|
80
|
+
expect(formatSignedMarketPrice(0.123456, { isCashBalance: true })).toBe("+0.123456");
|
|
81
|
+
expect(formatSignedMarketPrice(-0.123456, { isCashBalance: true })).toBe("-0.123456");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("formatMarketCost", () => {
|
|
86
|
+
test("keeps equity cost displays conservative while preserving FX and crypto precision", () => {
|
|
87
|
+
expect(formatMarketCost(119.3687, { assetCategory: "STK" })).toBe("119.37");
|
|
88
|
+
expect(formatMarketCost(1.084567, { isCashBalance: true })).toBe("1.084567");
|
|
89
|
+
expect(formatMarketCost(113905.720075, { assetCategory: "CRYPTO" })).toBe("113,905.720075");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("formatMarketPriceWithCurrency", () => {
|
|
94
|
+
test("renders price-like values with a symbol and variable decimals", () => {
|
|
95
|
+
expect(formatMarketPriceWithCurrency(190.25, "USD", { assetCategory: "STK" })).toBe("$190.25");
|
|
96
|
+
expect(formatMarketPriceWithCurrency(1.084567, "EUR", { isCashBalance: true })).toBe("€1.084567");
|
|
97
|
+
expect(formatMarketPriceWithCurrency(1.17364, "USD", { assetCategory: "CURRENCY" })).toBe("$1.17364");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("preserves chart-style compact notation for large values", () => {
|
|
101
|
+
expect(formatCompactMarketPriceWithCurrency(21_970, "JPY")).toBe("¥22.0K");
|
|
102
|
+
expect(formatCompactMarketPriceWithCurrency(12_340, "HKD")).toBe("HK$12.3K");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("formats position cost values with tighter equity precision", () => {
|
|
106
|
+
expect(formatMarketCostWithCurrency(119.3687, "HKD", { assetCategory: "STK" })).toBe("HK$119.37");
|
|
107
|
+
expect(formatMarketCostWithCurrency(50.9507, "USD", { assetCategory: "OPT", multiplier: 100 })).toBe("$50.9507");
|
|
108
|
+
});
|
|
109
|
+
});
|