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,70 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { act, useState } from "react";
|
|
3
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
4
|
+
import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../market-data/coordinator";
|
|
5
|
+
import { useQuoteStreaming } from "./use-quote-streaming";
|
|
6
|
+
|
|
7
|
+
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
8
|
+
let bumpHarness: (() => void) | null = null;
|
|
9
|
+
|
|
10
|
+
function QuoteStreamingHarness() {
|
|
11
|
+
const [tick, setTick] = useState(0);
|
|
12
|
+
bumpHarness = () => setTick((current) => current + 1);
|
|
13
|
+
|
|
14
|
+
useQuoteStreaming([{
|
|
15
|
+
symbol: "AAPL",
|
|
16
|
+
exchange: "NASDAQ",
|
|
17
|
+
}]);
|
|
18
|
+
|
|
19
|
+
return <text>{String(tick)}</text>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
if (testSetup) {
|
|
24
|
+
await act(async () => {
|
|
25
|
+
testSetup!.renderer.destroy();
|
|
26
|
+
});
|
|
27
|
+
testSetup = undefined;
|
|
28
|
+
}
|
|
29
|
+
bumpHarness = null;
|
|
30
|
+
setSharedMarketDataCoordinator(null);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("useQuoteStreaming", () => {
|
|
34
|
+
test("does not resubscribe when the component rerenders with the same targets", async () => {
|
|
35
|
+
let subscribeCalls = 0;
|
|
36
|
+
let unsubscribeCalls = 0;
|
|
37
|
+
const coordinator = {
|
|
38
|
+
subscribeQuotes: () => {
|
|
39
|
+
subscribeCalls += 1;
|
|
40
|
+
return () => {
|
|
41
|
+
unsubscribeCalls += 1;
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
setSharedMarketDataCoordinator(coordinator as unknown as MarketDataCoordinator);
|
|
46
|
+
|
|
47
|
+
testSetup = await testRender(<QuoteStreamingHarness />, {
|
|
48
|
+
width: 20,
|
|
49
|
+
height: 1,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await act(async () => {
|
|
53
|
+
await testSetup!.renderOnce();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(subscribeCalls).toBe(1);
|
|
57
|
+
expect(unsubscribeCalls).toBe(0);
|
|
58
|
+
|
|
59
|
+
await act(async () => {
|
|
60
|
+
bumpHarness?.();
|
|
61
|
+
await Promise.resolve();
|
|
62
|
+
});
|
|
63
|
+
await act(async () => {
|
|
64
|
+
await testSetup!.renderOnce();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(subscribeCalls).toBe(1);
|
|
68
|
+
expect(unsubscribeCalls).toBe(0);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useAppActive } from "./app-activity";
|
|
3
|
+
import type { QuoteSubscriptionTarget } from "../types/data-provider";
|
|
4
|
+
import { debugLog } from "../utils/debug-log";
|
|
5
|
+
import { getSharedMarketDataCoordinator } from "../market-data/coordinator";
|
|
6
|
+
|
|
7
|
+
const quoteStreamLog = debugLog.createLogger("quote-stream");
|
|
8
|
+
|
|
9
|
+
function normalizeTarget(target: QuoteSubscriptionTarget): QuoteSubscriptionTarget | null {
|
|
10
|
+
const symbol = target.symbol.trim().toUpperCase();
|
|
11
|
+
if (!symbol) return null;
|
|
12
|
+
return {
|
|
13
|
+
...target,
|
|
14
|
+
symbol,
|
|
15
|
+
exchange: target.exchange?.trim().toUpperCase() ?? "",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildQuoteStreamSubscriptionKey(target: QuoteSubscriptionTarget): string {
|
|
20
|
+
const contractKey = target.context?.instrument?.conId
|
|
21
|
+
?? target.context?.instrument?.localSymbol
|
|
22
|
+
?? target.context?.instrument?.symbol
|
|
23
|
+
?? "";
|
|
24
|
+
return [
|
|
25
|
+
target.symbol,
|
|
26
|
+
target.exchange ?? "",
|
|
27
|
+
target.context?.brokerId ?? "",
|
|
28
|
+
target.context?.brokerInstanceId ?? "",
|
|
29
|
+
contractKey,
|
|
30
|
+
target.route ?? "auto",
|
|
31
|
+
].join("|");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useQuoteStreaming(targets: QuoteSubscriptionTarget[]): void {
|
|
35
|
+
const appActive = useAppActive();
|
|
36
|
+
const coordinator = getSharedMarketDataCoordinator();
|
|
37
|
+
|
|
38
|
+
const normalizedEntries = new Map<string, QuoteSubscriptionTarget>();
|
|
39
|
+
for (const target of targets) {
|
|
40
|
+
const normalized = normalizeTarget(target);
|
|
41
|
+
if (!normalized) continue;
|
|
42
|
+
const key = buildQuoteStreamSubscriptionKey(normalized);
|
|
43
|
+
normalizedEntries.set(key, normalized);
|
|
44
|
+
}
|
|
45
|
+
const sortedEntries = [...normalizedEntries.entries()].sort(([left], [right]) => left.localeCompare(right));
|
|
46
|
+
const normalizedTargets = sortedEntries.map(([, target]) => target);
|
|
47
|
+
const subscriptionKey = sortedEntries.map(([key]) => key).join("|");
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!appActive) {
|
|
51
|
+
if (normalizedTargets.length > 0) {
|
|
52
|
+
quoteStreamLog.info("skipping subscription while inactive", { targets: subscriptionKey });
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!coordinator || normalizedTargets.length === 0) return;
|
|
57
|
+
quoteStreamLog.info("subscribe", {
|
|
58
|
+
providerId: "market-data",
|
|
59
|
+
count: normalizedTargets.length,
|
|
60
|
+
targets: subscriptionKey,
|
|
61
|
+
});
|
|
62
|
+
const unsubscribe = coordinator.subscribeQuotes(normalizedTargets.map((target) => ({
|
|
63
|
+
instrument: {
|
|
64
|
+
symbol: target.symbol,
|
|
65
|
+
exchange: target.exchange,
|
|
66
|
+
brokerId: target.context?.brokerId,
|
|
67
|
+
brokerInstanceId: target.context?.brokerInstanceId,
|
|
68
|
+
instrument: target.context?.instrument ?? null,
|
|
69
|
+
},
|
|
70
|
+
})));
|
|
71
|
+
return () => {
|
|
72
|
+
quoteStreamLog.info("unsubscribe", {
|
|
73
|
+
providerId: "market-data",
|
|
74
|
+
count: normalizedTargets.length,
|
|
75
|
+
targets: subscriptionKey,
|
|
76
|
+
});
|
|
77
|
+
unsubscribe?.();
|
|
78
|
+
};
|
|
79
|
+
}, [appActive, coordinator, subscriptionKey]);
|
|
80
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import type { SessionStore } from "../data/session-store";
|
|
3
|
+
import {
|
|
4
|
+
buildAppSessionSnapshot,
|
|
5
|
+
type AppSessionSnapshot,
|
|
6
|
+
} from "./session-persistence";
|
|
7
|
+
import type { AppState } from "./app-context";
|
|
8
|
+
|
|
9
|
+
export function usePersistSessionSnapshot(
|
|
10
|
+
sessionStore: SessionStore | undefined,
|
|
11
|
+
state: AppState,
|
|
12
|
+
sessionId: string,
|
|
13
|
+
schemaVersion: number,
|
|
14
|
+
): void {
|
|
15
|
+
const latestRef = useRef({
|
|
16
|
+
sessionStore,
|
|
17
|
+
state,
|
|
18
|
+
sessionId,
|
|
19
|
+
schemaVersion,
|
|
20
|
+
});
|
|
21
|
+
latestRef.current = {
|
|
22
|
+
sessionStore,
|
|
23
|
+
state,
|
|
24
|
+
sessionId,
|
|
25
|
+
schemaVersion,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const saveSnapshotRef = useRef<() => void>(() => {});
|
|
29
|
+
saveSnapshotRef.current = () => {
|
|
30
|
+
const {
|
|
31
|
+
sessionStore: currentStore,
|
|
32
|
+
state: currentState,
|
|
33
|
+
sessionId: currentSessionId,
|
|
34
|
+
schemaVersion: currentSchemaVersion,
|
|
35
|
+
} = latestRef.current;
|
|
36
|
+
|
|
37
|
+
if (!currentStore) return;
|
|
38
|
+
if (!currentState.initialized && currentState.tickers.size === 0) return;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
currentStore.set(currentSessionId, buildAppSessionSnapshot({
|
|
42
|
+
config: currentState.config,
|
|
43
|
+
paneState: currentState.paneState,
|
|
44
|
+
focusedPaneId: currentState.focusedPaneId,
|
|
45
|
+
activePanel: currentState.activePanel,
|
|
46
|
+
statusBarVisible: currentState.statusBarVisible,
|
|
47
|
+
recentTickers: currentState.recentTickers,
|
|
48
|
+
tickers: currentState.tickers,
|
|
49
|
+
exchangeRates: currentState.exchangeRates,
|
|
50
|
+
}) satisfies AppSessionSnapshot, currentSchemaVersion);
|
|
51
|
+
} catch {
|
|
52
|
+
// Snapshot persistence is best-effort during teardown.
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!sessionStore) return;
|
|
58
|
+
if (!state.initialized && state.tickers.size === 0) return;
|
|
59
|
+
const timer = setTimeout(() => {
|
|
60
|
+
saveSnapshotRef.current();
|
|
61
|
+
}, 250);
|
|
62
|
+
return () => {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
};
|
|
65
|
+
}, [
|
|
66
|
+
sessionStore,
|
|
67
|
+
sessionId,
|
|
68
|
+
schemaVersion,
|
|
69
|
+
state.initialized,
|
|
70
|
+
state.tickers,
|
|
71
|
+
state.config,
|
|
72
|
+
state.paneState,
|
|
73
|
+
state.focusedPaneId,
|
|
74
|
+
state.activePanel,
|
|
75
|
+
state.statusBarVisible,
|
|
76
|
+
state.recentTickers,
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
return () => {
|
|
81
|
+
saveSnapshotRef.current();
|
|
82
|
+
};
|
|
83
|
+
}, []);
|
|
84
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { DataProvider } from "../types/data-provider";
|
|
2
|
+
import type { InstrumentSearchResult } from "../types/instrument";
|
|
3
|
+
import type { Quote, TickerFinancials } from "../types/financials";
|
|
4
|
+
|
|
5
|
+
function unused<T>(name: string): Promise<T> {
|
|
6
|
+
return Promise.reject(new Error(`${name} is unused in this test`));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createTestDataProvider(overrides: Partial<DataProvider> = {}): DataProvider {
|
|
10
|
+
return {
|
|
11
|
+
id: "test-provider",
|
|
12
|
+
name: "Test Provider",
|
|
13
|
+
getTickerFinancials: async () => unused<TickerFinancials>("getTickerFinancials"),
|
|
14
|
+
getQuote: async () => unused<Quote>("getQuote"),
|
|
15
|
+
getExchangeRate: async () => 1,
|
|
16
|
+
search: async () => [] satisfies InstrumentSearchResult[],
|
|
17
|
+
getNews: async () => [],
|
|
18
|
+
getArticleSummary: async () => null,
|
|
19
|
+
getPriceHistory: async () => [],
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export function blendHex(a: string, b: string, ratio: number): string {
|
|
2
|
+
const parse = (hex: string) => {
|
|
3
|
+
const h = hex.replace("#", "");
|
|
4
|
+
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)] as const;
|
|
5
|
+
};
|
|
6
|
+
const [ar, ag, ab] = parse(a);
|
|
7
|
+
const [br, bg, bb] = parse(b);
|
|
8
|
+
const mix = (x: number, y: number) => Math.round(x + (y - x) * ratio).toString(16).padStart(2, "0");
|
|
9
|
+
return `#${mix(ar, br)}${mix(ag, bg)}${mix(ab, bb)}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function relativeLuminance(hex: string): number {
|
|
13
|
+
const h = hex.replace("#", "");
|
|
14
|
+
const toLinear = (value: string) => {
|
|
15
|
+
const normalized = parseInt(value, 16) / 255;
|
|
16
|
+
return normalized <= 0.03928
|
|
17
|
+
? normalized / 12.92
|
|
18
|
+
: Math.pow((normalized + 0.055) / 1.055, 2.4);
|
|
19
|
+
};
|
|
20
|
+
const r = toLinear(h.slice(0, 2));
|
|
21
|
+
const g = toLinear(h.slice(2, 4));
|
|
22
|
+
const b = toLinear(h.slice(4, 6));
|
|
23
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function contrastRatio(a: string, b: string): number {
|
|
27
|
+
const lighter = Math.max(relativeLuminance(a), relativeLuminance(b));
|
|
28
|
+
const darker = Math.min(relativeLuminance(a), relativeLuminance(b));
|
|
29
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const CONTRAST_BLEND_STEPS = [0, 0.08, 0.14, 0.2, 0.28, 0.36, 0.48, 0.62, 0.78, 1] as const;
|
|
33
|
+
|
|
34
|
+
export function blendForContrast(base: string, against: string, fallback: string, minContrast: number): string {
|
|
35
|
+
let candidate = base;
|
|
36
|
+
|
|
37
|
+
for (const ratio of CONTRAST_BLEND_STEPS) {
|
|
38
|
+
candidate = ratio === 0 ? base : blendHex(base, fallback, ratio);
|
|
39
|
+
if (contrastRatio(candidate, against) >= minContrast) {
|
|
40
|
+
return candidate;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return candidate;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function blendForContrastOnSurfaces(
|
|
48
|
+
base: string,
|
|
49
|
+
surfaces: readonly string[],
|
|
50
|
+
fallback: string,
|
|
51
|
+
minContrast: number,
|
|
52
|
+
): string {
|
|
53
|
+
let candidate = base;
|
|
54
|
+
|
|
55
|
+
for (const ratio of CONTRAST_BLEND_STEPS) {
|
|
56
|
+
candidate = ratio === 0 ? base : blendHex(base, fallback, ratio);
|
|
57
|
+
if (surfaces.every((surface) => contrastRatio(candidate, surface) >= minContrast)) {
|
|
58
|
+
return candidate;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return candidate;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function higherContrast(a: string, b: string, against: string): string {
|
|
66
|
+
return contrastRatio(a, against) >= contrastRatio(b, against) ? a : b;
|
|
67
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { blendForContrast, blendHex, higherContrast } from "./color-utils";
|
|
2
|
+
import { getTheme, DEFAULT_THEME, type Theme } from "./themes";
|
|
3
|
+
|
|
4
|
+
// Mutable colors object — properties are updated in-place when the theme changes.
|
|
5
|
+
// React components re-read these on each render triggered by the SET_THEME action.
|
|
6
|
+
export const colors: Omit<Theme, "name" | "description"> = { ...getTheme(DEFAULT_THEME) };
|
|
7
|
+
|
|
8
|
+
let currentThemeId = DEFAULT_THEME;
|
|
9
|
+
|
|
10
|
+
export function getCurrentThemeId(): string {
|
|
11
|
+
return currentThemeId;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function applyTheme(id: string): void {
|
|
15
|
+
const theme = getTheme(id);
|
|
16
|
+
currentThemeId = id;
|
|
17
|
+
// Mutate in-place so every existing import sees the new values
|
|
18
|
+
Object.assign(colors, theme);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ColorKey = keyof typeof colors;
|
|
22
|
+
|
|
23
|
+
export { blendHex } from "./color-utils";
|
|
24
|
+
|
|
25
|
+
const COMPARISON_SERIES_COLORS = [
|
|
26
|
+
"#5bc0eb",
|
|
27
|
+
"#f6ae2d",
|
|
28
|
+
"#f26419",
|
|
29
|
+
"#6f2dbd",
|
|
30
|
+
"#00a896",
|
|
31
|
+
"#ef476f",
|
|
32
|
+
"#118ab2",
|
|
33
|
+
"#8ac926",
|
|
34
|
+
"#ff7f51",
|
|
35
|
+
"#4361ee",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
export function getComparisonSeriesColor(index: number): string {
|
|
39
|
+
return COMPARISON_SERIES_COLORS[((index % COMPARISON_SERIES_COLORS.length) + COMPARISON_SERIES_COLORS.length) % COMPARISON_SERIES_COLORS.length]!;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Returns a hover background color derived from bg and selected */
|
|
43
|
+
export function hoverBg(): string {
|
|
44
|
+
return blendHex(colors.bg, colors.selected, 0.5);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function commandBarBg(): string {
|
|
48
|
+
const base = higherContrast(colors.commandBg, colors.panel, colors.bg);
|
|
49
|
+
const accent = higherContrast(colors.textBright, colors.borderFocused, base);
|
|
50
|
+
return blendForContrast(base, colors.bg, accent, 1.45);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function commandBarSelectedBg(): string {
|
|
54
|
+
const base = commandBarBg();
|
|
55
|
+
const accent = higherContrast(colors.selectedText, colors.textBright, colors.selected);
|
|
56
|
+
return blendForContrast(colors.selected, base, accent, 1.6);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function commandBarHoverBg(): string {
|
|
60
|
+
return blendHex(commandBarBg(), commandBarSelectedBg(), 0.45);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function commandBarText(): string {
|
|
64
|
+
const base = commandBarBg();
|
|
65
|
+
const fallback = higherContrast(colors.textBright, "#f2f2f2", base);
|
|
66
|
+
return blendForContrast(colors.text, base, fallback, 5.4);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function commandBarHeadingText(): string {
|
|
70
|
+
const base = commandBarBg();
|
|
71
|
+
return blendForContrast("#8f959e", base, "#b7bec8", 3.6);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function commandBarSubtleText(): string {
|
|
75
|
+
const base = commandBarBg();
|
|
76
|
+
const fallback = higherContrast(commandBarText(), "#d8d8d8", base);
|
|
77
|
+
return blendForContrast(colors.textDim, base, fallback, 4.1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function commandBarSelectedText(): string {
|
|
81
|
+
const base = commandBarSelectedBg();
|
|
82
|
+
const preferred = higherContrast(colors.selectedText, colors.text, base);
|
|
83
|
+
const fallback = higherContrast(colors.textBright, "#f2f2f2", base);
|
|
84
|
+
return blendForContrast(preferred, base, fallback, 4.5);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Background for docked pane bodies */
|
|
88
|
+
export function paneBg(focused: boolean): string {
|
|
89
|
+
if (focused) return colors.bg;
|
|
90
|
+
return colors.panel;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Background for floating pane bodies — elevated above docked panes */
|
|
94
|
+
export function floatingPaneBg(focused: boolean): string {
|
|
95
|
+
if (focused) return blendHex(colors.bg, colors.border, 0.12);
|
|
96
|
+
return blendHex(colors.panel, colors.border, 0.35);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Background for pane title bars */
|
|
100
|
+
export function paneTitleBg(focused: boolean): string {
|
|
101
|
+
if (focused) return blendHex(colors.bg, colors.borderFocused, 0.15);
|
|
102
|
+
return blendHex(colors.panel, colors.border, 0.25);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Background for floating pane title bars */
|
|
106
|
+
export function floatingPaneTitleBg(focused: boolean): string {
|
|
107
|
+
if (focused) return blendHex(colors.bg, colors.borderFocused, 0.18);
|
|
108
|
+
return blendHex(colors.panel, colors.border, 0.4);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Title text color for panes */
|
|
112
|
+
export function paneTitleText(focused: boolean, floating = false): string {
|
|
113
|
+
const background = floating ? floatingPaneTitleBg(focused) : paneTitleBg(focused);
|
|
114
|
+
const preferred = focused
|
|
115
|
+
? higherContrast(colors.textBright, colors.headerText, background)
|
|
116
|
+
: colors.textDim;
|
|
117
|
+
const fallback = focused
|
|
118
|
+
? higherContrast(colors.text, "#f2f2f2", background)
|
|
119
|
+
: higherContrast(colors.text, colors.textBright, background);
|
|
120
|
+
return blendForContrast(preferred, background, fallback, focused ? 5.2 : 4.5);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Returns green for positive, red for negative, neutral for zero */
|
|
124
|
+
export function priceColor(value: number): string {
|
|
125
|
+
if (value > 0) return colors.positive;
|
|
126
|
+
if (value < 0) return colors.negative;
|
|
127
|
+
return colors.neutral;
|
|
128
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { afterEach, describe, test } from "bun:test";
|
|
2
|
+
import { applyTheme, floatingPaneTitleBg, paneTitleBg, paneTitleText } from "./colors";
|
|
3
|
+
import { contrastRatio } from "./color-utils";
|
|
4
|
+
import { DEFAULT_THEME, themes } from "./themes";
|
|
5
|
+
|
|
6
|
+
const BODY_TEXT_MIN = 4.5;
|
|
7
|
+
const SUBTLE_TEXT_MIN = 3.6;
|
|
8
|
+
const CHROME_TEXT_MIN = 4.5;
|
|
9
|
+
|
|
10
|
+
function assertMinContrast(themeId: string, label: string, fg: string, bg: string, min: number): void {
|
|
11
|
+
const ratio = contrastRatio(fg, bg);
|
|
12
|
+
if (ratio < min) {
|
|
13
|
+
throw new Error(`${themeId} ${label} contrast ${ratio.toFixed(2)} is below ${min}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
applyTheme(DEFAULT_THEME);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("theme contrast", () => {
|
|
22
|
+
test("keeps shared text roles readable on body surfaces", () => {
|
|
23
|
+
for (const [id, theme] of Object.entries(themes)) {
|
|
24
|
+
for (const [surfaceLabel, surface] of [["bg", theme.bg], ["panel", theme.panel]] as const) {
|
|
25
|
+
assertMinContrast(id, `text/${surfaceLabel}`, theme.text, surface, BODY_TEXT_MIN);
|
|
26
|
+
assertMinContrast(id, `textDim/${surfaceLabel}`, theme.textDim, surface, BODY_TEXT_MIN);
|
|
27
|
+
assertMinContrast(id, `textMuted/${surfaceLabel}`, theme.textMuted, surface, SUBTLE_TEXT_MIN);
|
|
28
|
+
assertMinContrast(id, `positive/${surfaceLabel}`, theme.positive, surface, SUBTLE_TEXT_MIN);
|
|
29
|
+
assertMinContrast(id, `negative/${surfaceLabel}`, theme.negative, surface, SUBTLE_TEXT_MIN);
|
|
30
|
+
assertMinContrast(id, `neutral/${surfaceLabel}`, theme.neutral, surface, SUBTLE_TEXT_MIN);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
assertMinContrast(id, "headerText/header", theme.headerText, theme.header, BODY_TEXT_MIN);
|
|
34
|
+
assertMinContrast(id, "selectedText/selected", theme.selectedText, theme.selected, BODY_TEXT_MIN);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("keeps unfocused pane chrome readable", () => {
|
|
39
|
+
for (const id of Object.keys(themes)) {
|
|
40
|
+
applyTheme(id);
|
|
41
|
+
assertMinContrast(id, "paneTitleText/unfocused", paneTitleText(false), paneTitleBg(false), CHROME_TEXT_MIN);
|
|
42
|
+
assertMinContrast(id, "floatingPaneTitleText/unfocused", paneTitleText(false, true), floatingPaneTitleBg(false), CHROME_TEXT_MIN);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|