gloomberb 0.9.5 → 0.9.6
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 +49 -8
- package/package.json +2 -2
- package/src/app/global-shortcuts.test.tsx +20 -1
- package/src/app/global-shortcuts.ts +2 -0
- package/src/app/pane-runtime/index.tsx +11 -4
- package/src/app/pane-runtime/pane-settings-runtime.tsx +1 -1
- package/src/app.tsx +41 -17
- package/src/cli/commands/remote.ts +206 -0
- package/src/cli/index.ts +2 -0
- package/src/components/chart/core/keyboard.ts +10 -1
- package/src/components/chart/static/bar-chart-surface.test.tsx +4 -0
- package/src/components/chart/static/chart/surface.test.tsx +59 -4
- package/src/components/chart/static/chart/surface.tsx +4 -0
- package/src/components/chart/stock-chart/index.test.ts +7 -0
- package/src/components/command-bar/list/view.tsx +47 -0
- package/src/components/command-bar/multi-select-picker.tsx +7 -0
- package/src/components/command-bar/routing/navigation-state.ts +10 -1
- package/src/components/command-bar/surface/index.test.tsx +14 -0
- package/src/components/command-bar/theme-picker.tsx +91 -79
- package/src/components/command-bar/workflow/field-row.tsx +30 -5
- package/src/components/index.ts +7 -0
- package/src/components/layout/pane/footer/index.tsx +23 -0
- package/src/components/layout/shell/index.test.tsx +2 -0
- package/src/components/layout/shell/index.tsx +8 -3
- package/src/components/layout/shell/pane/actions.ts +21 -7
- package/src/components/metric-treemap/index.tsx +317 -0
- package/src/components/metric-treemap/layout.test.ts +77 -0
- package/src/components/metric-treemap/layout.ts +476 -0
- package/src/components/toggle-list.tsx +10 -0
- package/src/components/ui/button.tsx +12 -0
- package/src/components/ui/data-table/index.tsx +51 -0
- package/src/components/ui/fields.tsx +18 -0
- package/src/components/ui/list-view.tsx +83 -3
- package/src/components/ui/message-composer.tsx +22 -0
- package/src/components/ui/tabs.tsx +45 -0
- package/src/core/state/app/layout-reducer.ts +7 -1
- package/src/core/state/app/layout.ts +6 -0
- package/src/core/state/app/state.ts +23 -1
- package/src/core/state/app/types.ts +4 -2
- package/src/plugins/builtin/chat/channels.ts +5 -18
- package/src/plugins/builtin/chat/chat.test.tsx +1 -7
- package/src/plugins/builtin/chat/status-widget.tsx +1 -7
- package/src/plugins/builtin/cloud/plugin.tsx +4 -13
- package/src/plugins/builtin/holders/treemap.tsx +28 -297
- package/src/plugins/builtin/holders/types.ts +0 -34
- package/src/plugins/builtin/market-heatmap/data.test.ts +121 -0
- package/src/plugins/builtin/market-heatmap/data.ts +296 -0
- package/src/plugins/builtin/market-heatmap/index.tsx +320 -0
- package/src/plugins/builtin/plugin-groups.ts +2 -0
- package/src/plugins/builtin/portfolio-list/grid.tsx +197 -0
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +41 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +78 -22
- package/src/plugins/ibkr/gateway/service/index.test.ts +22 -0
- package/src/plugins/ibkr/gateway/service/index.ts +6 -9
- package/src/plugins/ibkr/gateway/service/native-loader.ts +12 -0
- package/src/remote/app-host.tsx +77 -0
- package/src/remote/client.ts +34 -0
- package/src/remote/command-bar.ts +98 -0
- package/src/remote/controller-utils.ts +111 -0
- package/src/remote/controller.test.ts +365 -0
- package/src/remote/controller.ts +434 -0
- package/src/remote/endpoint.ts +41 -0
- package/src/remote/json-patch.test.ts +31 -0
- package/src/remote/json-patch.ts +89 -0
- package/src/remote/layout-helpers.ts +62 -0
- package/src/remote/resources.ts +186 -0
- package/src/remote/revision.ts +18 -0
- package/src/remote/schema.ts +143 -0
- package/src/remote/semantic-helpers.ts +161 -0
- package/src/remote/semantic-tree.tsx +112 -0
- package/src/remote/server.test.ts +50 -0
- package/src/remote/server.ts +121 -0
- package/src/remote/types.ts +119 -0
- package/src/renderers/electrobun/bun/index.ts +55 -1
- package/src/renderers/electrobun/shared/protocol.ts +11 -1
- package/src/renderers/electrobun/view/backend-rpc.ts +28 -1
- package/src/renderers/electrobun/view/build-assets.ts +1 -0
- package/src/renderers/electrobun/view/host/box.tsx +5 -0
- package/src/renderers/electrobun/view/host/mouse.test.ts +37 -0
- package/src/renderers/electrobun/view/host/mouse.ts +2 -0
- package/src/renderers/electrobun/view/main.tsx +5 -1
- package/src/renderers/electrobun/view/native-stubs/ibkr-native-loader.ts +3 -0
- package/src/renderers/opentui/chart-surface.test.tsx +101 -0
- package/src/renderers/opentui/chart-surface.tsx +15 -20
- package/src/renderers/opentui/start.tsx +32 -0
- package/src/sources/yahoo-finance/http.ts +31 -4
- package/src/state/app/context/index.test.tsx +47 -0
- package/src/state/app/context/index.tsx +48 -18
- package/src/ui/host.tsx +1 -0
- package/src/ui/index.tsx +183 -7
- package/src/version.ts +1 -1
- package/src/plugins/builtin/holders/treemap-layout.ts +0 -231
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { useCallback, useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
buildMetricTreemapNavigationTiles,
|
|
4
|
+
findMetricTreemapNeighbor,
|
|
5
|
+
MetricTreemapSurface,
|
|
6
|
+
type MetricTreemapDirection,
|
|
7
|
+
type MetricTreemapItem,
|
|
8
|
+
} from "../../../components";
|
|
9
|
+
import { useShortcut } from "../../../react/input";
|
|
10
|
+
import type { ColumnConfig } from "../../../types/config";
|
|
11
|
+
import type { TickerFinancials } from "../../../types/financials";
|
|
12
|
+
import type { TickerRecord } from "../../../types/ticker";
|
|
13
|
+
import { useUiCapabilities } from "../../../ui";
|
|
14
|
+
import { isPlainKey } from "../../../utils/keyboard";
|
|
15
|
+
import { getColumnValue, getSortValue, type ColumnContext } from "./metrics";
|
|
16
|
+
import { getPortfolioPositionMetrics } from "./position-metrics";
|
|
17
|
+
|
|
18
|
+
const MARKET_CAP_COLUMN: ColumnConfig = { id: "market_cap", label: "Mkt Cap", width: 10, align: "right" };
|
|
19
|
+
const MARKET_VALUE_COLUMN: ColumnConfig = { id: "mkt_value", label: "Mkt Value", width: 10, align: "right" };
|
|
20
|
+
const CHANGE_PCT_COLUMN: ColumnConfig = { id: "change_pct", label: "Change", width: 8, align: "right" };
|
|
21
|
+
const DAY_PNL_COLUMN: ColumnConfig = { id: "day_pnl", label: "Day P&L", width: 10, align: "right" };
|
|
22
|
+
const VOLUME_COLUMN: ColumnConfig = { id: "volume", label: "Volume", width: 9, align: "right" };
|
|
23
|
+
const WEIGHT_COLUMN: ColumnConfig = { id: "weight", label: "Weight", width: 8, align: "right" };
|
|
24
|
+
|
|
25
|
+
function numericValue(value: number | string | null): number | null {
|
|
26
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function quoteCurrency(ticker: TickerRecord, financials: TickerFinancials | undefined): string {
|
|
30
|
+
return financials?.quote?.currency || ticker.metadata.currency || "USD";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function positionAdjustedChangePercent(
|
|
34
|
+
ticker: TickerRecord,
|
|
35
|
+
financials: TickerFinancials | undefined,
|
|
36
|
+
context: ColumnContext,
|
|
37
|
+
isPortfolioTab: boolean,
|
|
38
|
+
): number | null {
|
|
39
|
+
const changePercent = numericValue(getSortValue(CHANGE_PCT_COLUMN, ticker, financials, context));
|
|
40
|
+
if (changePercent == null || !isPortfolioTab) return changePercent;
|
|
41
|
+
const positionMetrics = getPortfolioPositionMetrics(ticker, context.activeTab, quoteCurrency(ticker, financials));
|
|
42
|
+
return positionMetrics.totalPriceUnits < 0 ? -changePercent : changePercent;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildPortfolioGridItems(
|
|
46
|
+
tickers: TickerRecord[],
|
|
47
|
+
financialsMap: Map<string, TickerFinancials>,
|
|
48
|
+
context: ColumnContext,
|
|
49
|
+
isPortfolioTab: boolean,
|
|
50
|
+
): Array<MetricTreemapItem<TickerRecord>> {
|
|
51
|
+
return tickers.map((ticker) => {
|
|
52
|
+
const symbol = ticker.metadata.ticker;
|
|
53
|
+
const financials = financialsMap.get(symbol);
|
|
54
|
+
const weightColumn = isPortfolioTab ? MARKET_VALUE_COLUMN : MARKET_CAP_COLUMN;
|
|
55
|
+
const primaryColumn = isPortfolioTab ? MARKET_VALUE_COLUMN : MARKET_CAP_COLUMN;
|
|
56
|
+
const fallbackColumn = isPortfolioTab ? MARKET_CAP_COLUMN : VOLUME_COLUMN;
|
|
57
|
+
const weight = numericValue(getSortValue(weightColumn, ticker, financials, context))
|
|
58
|
+
?? numericValue(getSortValue(fallbackColumn, ticker, financials, context))
|
|
59
|
+
?? 1;
|
|
60
|
+
const primary = getColumnValue(primaryColumn, ticker, financials, context).text;
|
|
61
|
+
const change = getColumnValue(CHANGE_PCT_COLUMN, ticker, financials, context).text;
|
|
62
|
+
const secondary = isPortfolioTab
|
|
63
|
+
? `${getColumnValue(DAY_PNL_COLUMN, ticker, financials, context).text} ${change}`
|
|
64
|
+
: change;
|
|
65
|
+
const tertiary = isPortfolioTab
|
|
66
|
+
? getColumnValue(WEIGHT_COLUMN, ticker, financials, context).text
|
|
67
|
+
: getColumnValue(VOLUME_COLUMN, ticker, financials, context).text;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
id: symbol,
|
|
71
|
+
label: symbol,
|
|
72
|
+
weight: Math.max(1, Math.abs(weight)),
|
|
73
|
+
colorValue: positionAdjustedChangePercent(ticker, financials, context, isPortfolioTab),
|
|
74
|
+
primaryText: primary,
|
|
75
|
+
secondaryText: secondary,
|
|
76
|
+
tertiaryText: tertiary,
|
|
77
|
+
data: ticker,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function PortfolioGrid({
|
|
83
|
+
sortedTickers,
|
|
84
|
+
financialsMap,
|
|
85
|
+
columnContext,
|
|
86
|
+
isPortfolioTab,
|
|
87
|
+
cursorSymbol,
|
|
88
|
+
setCursorSymbol,
|
|
89
|
+
onRowActivate,
|
|
90
|
+
onToggleViewMode,
|
|
91
|
+
focused,
|
|
92
|
+
width,
|
|
93
|
+
height,
|
|
94
|
+
}: {
|
|
95
|
+
sortedTickers: TickerRecord[];
|
|
96
|
+
financialsMap: Map<string, TickerFinancials>;
|
|
97
|
+
columnContext: ColumnContext;
|
|
98
|
+
isPortfolioTab: boolean;
|
|
99
|
+
cursorSymbol: string | null;
|
|
100
|
+
setCursorSymbol: (symbol: string) => void;
|
|
101
|
+
onRowActivate: (ticker: TickerRecord) => void;
|
|
102
|
+
onToggleViewMode: () => void;
|
|
103
|
+
focused?: boolean;
|
|
104
|
+
width: number;
|
|
105
|
+
height: number;
|
|
106
|
+
}) {
|
|
107
|
+
const { cellWidthPx = 8, cellHeightPx = 18, nativePaneChrome } = useUiCapabilities();
|
|
108
|
+
const chartWidth = Math.max(1, width - 2);
|
|
109
|
+
const cellAspect = Math.max(0.5, Math.min(4, cellHeightPx / Math.max(1, cellWidthPx)));
|
|
110
|
+
const items = useMemo(
|
|
111
|
+
() => buildPortfolioGridItems(sortedTickers, financialsMap, columnContext, isPortfolioTab),
|
|
112
|
+
[columnContext, financialsMap, isPortfolioTab, sortedTickers],
|
|
113
|
+
);
|
|
114
|
+
const navigationTiles = useMemo(
|
|
115
|
+
() => buildMetricTreemapNavigationTiles(items, chartWidth, height, cellAspect, nativePaneChrome ? "float" : "integer"),
|
|
116
|
+
[cellAspect, chartWidth, height, items, nativePaneChrome],
|
|
117
|
+
);
|
|
118
|
+
const selectedIdx = cursorSymbol
|
|
119
|
+
? sortedTickers.findIndex((ticker) => ticker.metadata.ticker === cursorSymbol)
|
|
120
|
+
: -1;
|
|
121
|
+
const activeIdx = selectedIdx >= 0 ? selectedIdx : (sortedTickers.length > 0 ? 0 : -1);
|
|
122
|
+
|
|
123
|
+
const selectIndex = useCallback((index: number) => {
|
|
124
|
+
const ticker = sortedTickers[index];
|
|
125
|
+
if (ticker) setCursorSymbol(ticker.metadata.ticker);
|
|
126
|
+
}, [setCursorSymbol, sortedTickers]);
|
|
127
|
+
|
|
128
|
+
const selectNeighbor = useCallback((direction: MetricTreemapDirection) => {
|
|
129
|
+
const target = findMetricTreemapNeighbor(navigationTiles, cursorSymbol, direction);
|
|
130
|
+
if (target) setCursorSymbol(target.item.data.metadata.ticker);
|
|
131
|
+
}, [cursorSymbol, navigationTiles, setCursorSymbol]);
|
|
132
|
+
|
|
133
|
+
useShortcut((event) => {
|
|
134
|
+
if (!focused) return;
|
|
135
|
+
if (isPlainKey(event, "s")) {
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
event.stopPropagation();
|
|
138
|
+
onToggleViewMode();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (isPlainKey(event, "j")) {
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
event.stopPropagation();
|
|
144
|
+
selectIndex(Math.min((activeIdx >= 0 ? activeIdx : 0) + 1, sortedTickers.length - 1));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (isPlainKey(event, "k")) {
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
event.stopPropagation();
|
|
150
|
+
selectIndex(Math.max((activeIdx >= 0 ? activeIdx : 0) - 1, 0));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (isPlainKey(event, "left", "h")) {
|
|
154
|
+
event.preventDefault();
|
|
155
|
+
event.stopPropagation();
|
|
156
|
+
selectNeighbor("left");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (isPlainKey(event, "right", "l")) {
|
|
160
|
+
event.preventDefault();
|
|
161
|
+
event.stopPropagation();
|
|
162
|
+
selectNeighbor("right");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (isPlainKey(event, "up")) {
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
event.stopPropagation();
|
|
168
|
+
selectNeighbor("up");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (isPlainKey(event, "down")) {
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
event.stopPropagation();
|
|
174
|
+
selectNeighbor("down");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (isPlainKey(event, "enter", "return")) {
|
|
178
|
+
const ticker = sortedTickers[activeIdx];
|
|
179
|
+
if (!ticker) return;
|
|
180
|
+
event.preventDefault();
|
|
181
|
+
event.stopPropagation();
|
|
182
|
+
onRowActivate(ticker);
|
|
183
|
+
}
|
|
184
|
+
}, { enabled: focused });
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<MetricTreemapSurface
|
|
188
|
+
items={items}
|
|
189
|
+
width={width}
|
|
190
|
+
height={height}
|
|
191
|
+
selectedId={cursorSymbol}
|
|
192
|
+
onSelect={(item) => setCursorSymbol(item.data.metadata.ticker)}
|
|
193
|
+
onActivate={(item) => onRowActivate(item.data)}
|
|
194
|
+
emptyStateTitle={isPortfolioTab ? "No portfolio positions" : "No watchlist tickers"}
|
|
195
|
+
/>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -687,6 +687,47 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
687
687
|
expect(frame).toContain("25.00%");
|
|
688
688
|
});
|
|
689
689
|
|
|
690
|
+
test("renders portfolio grid from portfolio table values and opens the selected ticker", async () => {
|
|
691
|
+
const portfolioId = "broker:ibkr-flex:DU12345";
|
|
692
|
+
const config = createPortfolioConfig(portfolioId, [createBrokerInstance("flex")]);
|
|
693
|
+
const pinned: Array<{ symbol: string; options: { floating?: boolean; paneType?: string } | undefined }> = [];
|
|
694
|
+
const runtime = createTestPluginRuntime({
|
|
695
|
+
pinTicker: (symbol, options) => {
|
|
696
|
+
pinned.push({ symbol, options });
|
|
697
|
+
},
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
testSetup = await testRender(
|
|
701
|
+
<PortfolioHarness
|
|
702
|
+
config={config}
|
|
703
|
+
collectionId={portfolioId}
|
|
704
|
+
runtime={runtime}
|
|
705
|
+
stateMutator={(state) => {
|
|
706
|
+
state.paneState[TEST_PANE_ID] = {
|
|
707
|
+
...state.paneState[TEST_PANE_ID],
|
|
708
|
+
viewMode: "grid",
|
|
709
|
+
};
|
|
710
|
+
}}
|
|
711
|
+
/>,
|
|
712
|
+
{ width: 100, height: 12 },
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
await flushFrame();
|
|
716
|
+
|
|
717
|
+
const frame = testSetup.captureCharFrame();
|
|
718
|
+
expect(frame).toContain("Grid");
|
|
719
|
+
expect(frame).toContain("AAPL");
|
|
720
|
+
expect(frame).toContain("1.3k");
|
|
721
|
+
expect(frame).toContain("+4.17%");
|
|
722
|
+
|
|
723
|
+
await act(async () => {
|
|
724
|
+
testSetup!.mockInput.pressEnter();
|
|
725
|
+
await testSetup!.renderOnce();
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
expect(pinned).toEqual([{ symbol: "AAPL", options: { floating: true, paneType: TICKER_RESEARCH_PANE_ID } }]);
|
|
729
|
+
});
|
|
730
|
+
|
|
690
731
|
test("keeps option avg cost on premium scale and multiplies quoted value by contract size", async () => {
|
|
691
732
|
const portfolioId = "broker:ibkr-flex:DU12345";
|
|
692
733
|
const optionTicker = "SPY 260619C00500000";
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
} from "../settings";
|
|
37
37
|
import { useQuoteFlashMap } from "../../../../components/quote-flash";
|
|
38
38
|
import { PortfolioTickerTable } from "../table";
|
|
39
|
+
import { PortfolioGrid } from "../grid";
|
|
39
40
|
import { useThrottledCursorSymbol } from "../use-throttled-cursor-symbol";
|
|
40
41
|
import { isManualPortfolio } from "../mutations";
|
|
41
42
|
import { QuickAddTickerInput, type QuickAddCollectionKind } from "../quick-add";
|
|
@@ -49,6 +50,8 @@ import {
|
|
|
49
50
|
import { usePortfolioPaneStreaming } from "./streaming";
|
|
50
51
|
import { usePortfolioSupplementalData } from "./supplemental";
|
|
51
52
|
|
|
53
|
+
type PortfolioViewMode = "table" | "grid";
|
|
54
|
+
|
|
52
55
|
export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
53
56
|
const { pinTicker } = usePluginTickerActions();
|
|
54
57
|
const paneInstance = usePaneInstance();
|
|
@@ -65,6 +68,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
65
68
|
const [committedCursorSymbol, setCommittedCursorSymbol] = usePaneStateValue<string | null>("cursorSymbol", null);
|
|
66
69
|
const [collectionSorts, setCollectionSorts] = usePaneStateValue<Record<string, CollectionSortPreference>>("collectionSorts", {});
|
|
67
70
|
const [cashDrawerExpanded, setCashDrawerExpanded] = usePaneStateValue<boolean>("cashDrawerExpanded", false);
|
|
71
|
+
const [viewMode, setViewMode] = usePaneStateValue<PortfolioViewMode>("viewMode", "table");
|
|
68
72
|
|
|
69
73
|
const [now, setNow] = useState(Date.now());
|
|
70
74
|
const [streamWindow, setStreamWindow] = useState({ start: 0, end: 24 });
|
|
@@ -174,7 +178,8 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
174
178
|
? (cashDrawerExpanded ? Math.min(6, Math.max(3, 2 + accountState.visibleCashBalances.length)) : 1)
|
|
175
179
|
: 0;
|
|
176
180
|
const showCollectionTabs = visibleCollections.length > 1;
|
|
177
|
-
const
|
|
181
|
+
const viewTabsHeight = 1;
|
|
182
|
+
const headerHeight = viewTabsHeight + (showCollectionTabs ? 1 : 0);
|
|
178
183
|
const drawerHeight = showCashDrawer
|
|
179
184
|
? Math.min(requestedDrawerHeight, Math.max(1, height - (headerHeight + 2)))
|
|
180
185
|
: 0;
|
|
@@ -214,6 +219,10 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
214
219
|
pinTicker(symbol, { floating: true, paneType: TICKER_RESEARCH_PANE_ID });
|
|
215
220
|
}, [pinTicker]);
|
|
216
221
|
|
|
222
|
+
const toggleViewMode = useCallback(() => {
|
|
223
|
+
setViewMode((current) => current === "table" ? "grid" : "table");
|
|
224
|
+
}, [setViewMode]);
|
|
225
|
+
|
|
217
226
|
const handleRowActivate = useCallback((ticker: TickerRecord) => {
|
|
218
227
|
flushCursorSymbol(ticker.metadata.ticker);
|
|
219
228
|
openTickerFloating(ticker.metadata.ticker);
|
|
@@ -245,6 +254,13 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
245
254
|
return true;
|
|
246
255
|
}
|
|
247
256
|
|
|
257
|
+
if (key === "s") {
|
|
258
|
+
event.preventDefault?.();
|
|
259
|
+
event.stopPropagation?.();
|
|
260
|
+
toggleViewMode();
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
|
|
248
264
|
return false;
|
|
249
265
|
}, [
|
|
250
266
|
cashDrawerExpanded,
|
|
@@ -254,6 +270,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
254
270
|
setCashDrawerExpanded,
|
|
255
271
|
showCashDrawer,
|
|
256
272
|
sortedTickers,
|
|
273
|
+
toggleViewMode,
|
|
257
274
|
]);
|
|
258
275
|
|
|
259
276
|
useEffect(() => {
|
|
@@ -281,12 +298,18 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
281
298
|
}
|
|
282
299
|
}, [cursorSymbol, setCursorSymbol, sortedTickers]);
|
|
283
300
|
|
|
301
|
+
const effectiveStreamWindow = useMemo(() => (
|
|
302
|
+
viewMode === "grid"
|
|
303
|
+
? { start: 0, end: Math.min(sortedTickers.length, 96) }
|
|
304
|
+
: streamWindow
|
|
305
|
+
), [sortedTickers.length, streamWindow, viewMode]);
|
|
306
|
+
|
|
284
307
|
usePortfolioPaneStreaming({
|
|
285
308
|
appActive,
|
|
286
309
|
focused,
|
|
287
310
|
sortedTickers,
|
|
288
311
|
cursorSymbol,
|
|
289
|
-
streamWindow,
|
|
312
|
+
streamWindow: effectiveStreamWindow,
|
|
290
313
|
isPortfolioTab,
|
|
291
314
|
financialsMap,
|
|
292
315
|
visibleWarmupRequirements,
|
|
@@ -341,7 +364,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
341
364
|
}, [activeCollectionId, config, currentPortfolio]);
|
|
342
365
|
const showQuickAdd = !!(activeCollectionId && activeCollectionEntry && quickAddCollectionKind);
|
|
343
366
|
const quickAddHeight = showQuickAdd ? 1 : 0;
|
|
344
|
-
const
|
|
367
|
+
const contentHeight = Math.max(1, height - headerHeight - drawerHeight - quickAddHeight);
|
|
345
368
|
const quickAddRow = activeCollectionId && activeCollectionEntry && quickAddCollectionKind ? (
|
|
346
369
|
<QuickAddTickerInput
|
|
347
370
|
collectionId={activeCollectionId}
|
|
@@ -357,6 +380,23 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
357
380
|
return (
|
|
358
381
|
<Box flexDirection="column" width={width} height={height}>
|
|
359
382
|
<Box flexDirection="column" height={headerHeight}>
|
|
383
|
+
<Box flexDirection="row" height={1}>
|
|
384
|
+
<Box flexShrink={1} overflow="hidden">
|
|
385
|
+
<Tabs
|
|
386
|
+
tabs={[
|
|
387
|
+
{ label: "Table", value: "table" },
|
|
388
|
+
{ label: "Grid", value: "grid" },
|
|
389
|
+
]}
|
|
390
|
+
activeValue={viewMode}
|
|
391
|
+
onSelect={(value) => setViewMode(value as PortfolioViewMode)}
|
|
392
|
+
compact
|
|
393
|
+
variant="bare"
|
|
394
|
+
focused={focused && !quickAddFocused}
|
|
395
|
+
keyboardNavigation={false}
|
|
396
|
+
/>
|
|
397
|
+
</Box>
|
|
398
|
+
</Box>
|
|
399
|
+
|
|
360
400
|
{showCollectionTabs && (
|
|
361
401
|
<Box flexDirection="row" height={1}>
|
|
362
402
|
<Box flexShrink={1} overflow="hidden">
|
|
@@ -372,25 +412,41 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
372
412
|
)}
|
|
373
413
|
</Box>
|
|
374
414
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
415
|
+
{viewMode === "table" ? (
|
|
416
|
+
<PortfolioTickerTable
|
|
417
|
+
columns={columns}
|
|
418
|
+
focused={focused && !quickAddFocused}
|
|
419
|
+
sortColumnId={activeSort.columnId}
|
|
420
|
+
sortDirection={activeSort.direction}
|
|
421
|
+
onHeaderClick={handleHeaderClick}
|
|
422
|
+
sortedTickers={sortedTickers}
|
|
423
|
+
cursorSymbol={cursorSymbol}
|
|
424
|
+
setCursorSymbol={setCursorSymbol}
|
|
425
|
+
financialsMap={financialsMap}
|
|
426
|
+
columnContext={columnContext}
|
|
427
|
+
flashSymbols={flashSymbols}
|
|
428
|
+
onRootKeyDown={handleTableKeyDown}
|
|
429
|
+
onVisibleRangeChange={handleVisibleRangeChange}
|
|
430
|
+
visibleRangeBuffer={3}
|
|
431
|
+
resetScrollKey={activeCollectionId}
|
|
432
|
+
onRowActivate={handleRowActivate}
|
|
433
|
+
rootHeight={contentHeight}
|
|
434
|
+
/>
|
|
435
|
+
) : (
|
|
436
|
+
<PortfolioGrid
|
|
437
|
+
sortedTickers={sortedTickers}
|
|
438
|
+
financialsMap={financialsMap}
|
|
439
|
+
columnContext={columnContext}
|
|
440
|
+
isPortfolioTab={isPortfolioTab}
|
|
441
|
+
cursorSymbol={cursorSymbol}
|
|
442
|
+
setCursorSymbol={(symbol) => setCursorSymbol(symbol)}
|
|
443
|
+
onRowActivate={handleRowActivate}
|
|
444
|
+
onToggleViewMode={toggleViewMode}
|
|
445
|
+
focused={focused && !quickAddFocused}
|
|
446
|
+
width={width}
|
|
447
|
+
height={contentHeight}
|
|
448
|
+
/>
|
|
449
|
+
)}
|
|
394
450
|
|
|
395
451
|
{quickAddRow}
|
|
396
452
|
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
2
5
|
import { BarSizeSetting, ConnectionState, SecType, type ContractDetails } from "@stoqey/ib";
|
|
3
6
|
import { Subject, of } from "rxjs";
|
|
4
7
|
import {
|
|
@@ -246,6 +249,25 @@ test("remote gateway snapshots are side-effect-free", async () => {
|
|
|
246
249
|
}
|
|
247
250
|
});
|
|
248
251
|
|
|
252
|
+
test("native gateway loader bundles without a runtime ./native specifier", async () => {
|
|
253
|
+
const outdir = mkdtempSync(join(tmpdir(), "gloomberb-ibkr-service-"));
|
|
254
|
+
try {
|
|
255
|
+
const result = await Bun.build({
|
|
256
|
+
entrypoints: [join(import.meta.dir, "index.ts")],
|
|
257
|
+
outdir,
|
|
258
|
+
target: "bun",
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(result.success).toBe(true);
|
|
262
|
+
const entry = readFileSync(join(outdir, "index.js"), "utf8");
|
|
263
|
+
expect(entry).toContain("src/plugins/ibkr/gateway/service/native.ts");
|
|
264
|
+
expect(entry).not.toContain('"./native"');
|
|
265
|
+
expect(entry).not.toContain("'./native'");
|
|
266
|
+
} finally {
|
|
267
|
+
rmSync(outdir, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
249
271
|
describe("tick-by-tick quote updates", () => {
|
|
250
272
|
const contract = {
|
|
251
273
|
symbol: "AAPL",
|
|
@@ -17,18 +17,18 @@ import type {
|
|
|
17
17
|
IbkrSnapshot,
|
|
18
18
|
ResolvedIbkrGatewayConnection,
|
|
19
19
|
} from "../types";
|
|
20
|
+
import {
|
|
21
|
+
loadNativeGatewayModule as loadDefaultNativeGatewayModule,
|
|
22
|
+
type NativeGatewayModule,
|
|
23
|
+
} from "./native-loader";
|
|
20
24
|
|
|
21
25
|
export type {
|
|
22
26
|
IbkrGatewayConfig,
|
|
23
27
|
ResolvedIbkrGatewayConnection,
|
|
24
28
|
} from "../types";
|
|
25
29
|
|
|
26
|
-
type NativeGatewayService = any;
|
|
27
30
|
type NativeGatewayManager = any;
|
|
28
|
-
type
|
|
29
|
-
ibkrGatewayManager: NativeGatewayManager;
|
|
30
|
-
setResolvedIbkrGatewayListener(listener: ResolvedGatewayListener | null): void;
|
|
31
|
-
};
|
|
31
|
+
type NativeGatewayService = any;
|
|
32
32
|
type Listener = () => void;
|
|
33
33
|
type ResolvedGatewayListener = (
|
|
34
34
|
instanceId: string | undefined,
|
|
@@ -47,9 +47,6 @@ let loadedNativeGatewayModule: NativeGatewayModule | null = null;
|
|
|
47
47
|
let nativeGatewayModulePromise: Promise<NativeGatewayModule> | null = null;
|
|
48
48
|
let nativeGatewayManager: NativeGatewayManager | null = null;
|
|
49
49
|
let nativeGatewayModuleLoader: () => Promise<NativeGatewayModule>;
|
|
50
|
-
const importNativeGatewayModule = new Function("specifier", "return import(specifier)") as (
|
|
51
|
-
specifier: string,
|
|
52
|
-
) => Promise<NativeGatewayModule>;
|
|
53
50
|
|
|
54
51
|
const snapshots = new Map<string, IbkrSnapshot>();
|
|
55
52
|
const listeners = new Map<string, Set<Listener>>();
|
|
@@ -60,7 +57,7 @@ function getSnapshotEntry(instanceId?: string): IbkrSnapshot {
|
|
|
60
57
|
}
|
|
61
58
|
|
|
62
59
|
function defaultNativeGatewayModuleLoader(): Promise<NativeGatewayModule> {
|
|
63
|
-
return
|
|
60
|
+
return loadDefaultNativeGatewayModule();
|
|
64
61
|
}
|
|
65
62
|
|
|
66
63
|
nativeGatewayModuleLoader = defaultNativeGatewayModuleLoader;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ResolvedIbkrGatewayConnection } from "../types";
|
|
2
|
+
|
|
3
|
+
export type NativeGatewayModule = {
|
|
4
|
+
ibkrGatewayManager: any;
|
|
5
|
+
setResolvedIbkrGatewayListener(
|
|
6
|
+
listener: ((instanceId: string | undefined, connection: ResolvedIbkrGatewayConnection) => void | Promise<void>) | null,
|
|
7
|
+
): void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function loadNativeGatewayModule(): Promise<NativeGatewayModule> {
|
|
11
|
+
return import("./native");
|
|
12
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useEffect, useMemo } from "react";
|
|
2
|
+
import type { Dispatch } from "react";
|
|
3
|
+
import type { PluginRegistry } from "../plugins/registry";
|
|
4
|
+
import type { AppAction, AppState } from "../state/app/context";
|
|
5
|
+
import type { DesktopWindowBridge } from "../types/desktop-window";
|
|
6
|
+
import { createAppRemoteController } from "./controller";
|
|
7
|
+
import { useRemoteUiRegistry } from "./semantic-tree";
|
|
8
|
+
import type { RemoteControlRequest, RemoteControlResponse } from "./types";
|
|
9
|
+
|
|
10
|
+
export type RemoteControlHandler = (request: RemoteControlRequest) => Promise<RemoteControlResponse>;
|
|
11
|
+
|
|
12
|
+
export interface RemoteControlAdapter {
|
|
13
|
+
startServer?(options: { dataDir: string; handle: RemoteControlHandler }): void | (() => void | Promise<void>);
|
|
14
|
+
registerHandler?(handler: RemoteControlHandler | null): void | (() => void);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface RemoteControlHostProps {
|
|
18
|
+
adapter?: RemoteControlAdapter;
|
|
19
|
+
dispatch: Dispatch<AppAction>;
|
|
20
|
+
getState: () => AppState;
|
|
21
|
+
pluginRegistry: PluginRegistry;
|
|
22
|
+
desktopWindowBridge?: DesktopWindowBridge;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function RemoteControlHost({
|
|
26
|
+
adapter,
|
|
27
|
+
dispatch,
|
|
28
|
+
getState,
|
|
29
|
+
pluginRegistry,
|
|
30
|
+
desktopWindowBridge,
|
|
31
|
+
}: RemoteControlHostProps) {
|
|
32
|
+
const uiRegistry = useRemoteUiRegistry();
|
|
33
|
+
const controller = useMemo(() => createAppRemoteController({
|
|
34
|
+
dispatch,
|
|
35
|
+
getState,
|
|
36
|
+
pluginRegistry,
|
|
37
|
+
uiRegistry,
|
|
38
|
+
desktopWindowBridge,
|
|
39
|
+
afterMutation: async () => {
|
|
40
|
+
await Promise.resolve();
|
|
41
|
+
await new Promise((resolve) => {
|
|
42
|
+
let settled = false;
|
|
43
|
+
const finish = () => {
|
|
44
|
+
if (settled) return;
|
|
45
|
+
settled = true;
|
|
46
|
+
resolve(undefined);
|
|
47
|
+
};
|
|
48
|
+
if (typeof requestAnimationFrame === "function") {
|
|
49
|
+
requestAnimationFrame(finish);
|
|
50
|
+
}
|
|
51
|
+
setTimeout(finish, 50);
|
|
52
|
+
});
|
|
53
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
54
|
+
},
|
|
55
|
+
}), [desktopWindowBridge, dispatch, getState, pluginRegistry, uiRegistry]);
|
|
56
|
+
const dataDir = getState().config.dataDir;
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!adapter?.startServer) return;
|
|
60
|
+
const cleanup = adapter.startServer({ dataDir, handle: controller.handle });
|
|
61
|
+
if (!cleanup) return;
|
|
62
|
+
return () => {
|
|
63
|
+
void cleanup();
|
|
64
|
+
};
|
|
65
|
+
}, [adapter, controller, dataDir]);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!adapter?.registerHandler) return;
|
|
69
|
+
const cleanup = adapter.registerHandler(controller.handle);
|
|
70
|
+
if (!cleanup) return;
|
|
71
|
+
return () => {
|
|
72
|
+
cleanup();
|
|
73
|
+
};
|
|
74
|
+
}, [adapter, controller]);
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { RemoteAppKind, RemoteControlRequest, RemoteControlResponse } from "./types";
|
|
2
|
+
import { readRemoteEndpoint } from "./endpoint";
|
|
3
|
+
|
|
4
|
+
export interface SendRemoteControlRequestOptions {
|
|
5
|
+
dataDir: string;
|
|
6
|
+
appKind?: RemoteAppKind;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function sendRemoteControlRequest(
|
|
10
|
+
request: RemoteControlRequest,
|
|
11
|
+
options: SendRemoteControlRequestOptions,
|
|
12
|
+
): Promise<RemoteControlResponse> {
|
|
13
|
+
const endpoint = await readRemoteEndpoint(options.dataDir, options.appKind);
|
|
14
|
+
const response = await fetch(`http://127.0.0.1:${endpoint.port}/rpc`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: {
|
|
17
|
+
authorization: `Bearer ${endpoint.token}`,
|
|
18
|
+
"content-type": "application/json",
|
|
19
|
+
},
|
|
20
|
+
body: JSON.stringify(request),
|
|
21
|
+
});
|
|
22
|
+
const payload = await response.json() as RemoteControlResponse;
|
|
23
|
+
if (!response.ok && payload.ok !== false) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
error: {
|
|
27
|
+
code: "remote_http_error",
|
|
28
|
+
message: `Remote control request failed with HTTP ${response.status}.`,
|
|
29
|
+
details: payload,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return payload;
|
|
34
|
+
}
|