gloomberb 0.9.5 → 0.9.7
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/pane-settings.test.ts +1 -0
- 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/index.test.ts +52 -1
- package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +15 -1
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +149 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +67 -26
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +54 -4
- package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
- 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/types/config.ts +1 -0
- 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
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { createDefaultConfig } from "../../../types/config";
|
|
3
|
+
import type { Quote, TickerFinancials } from "../../../types/financials";
|
|
3
4
|
import type { BrokerAccount } from "../../../types/trading";
|
|
4
5
|
import type { TickerRecord } from "../../../types/ticker";
|
|
5
6
|
import type { PortfolioSummaryTotals } from "./metrics";
|
|
6
7
|
import { buildPortfolioSummarySegments } from "./summary";
|
|
7
8
|
import { portfolioListPlugin, shouldToggleCashMarginDrawer } from ".";
|
|
8
|
-
import { selectStreamTickers } from "./pane/data";
|
|
9
|
+
import { needsVisibleQuoteWatchdogRefresh, selectStreamTickers } from "./pane/data";
|
|
10
|
+
import { buildPortfolioPaneSettingsDef, getPortfolioPaneSettings } from "./settings";
|
|
9
11
|
|
|
10
12
|
function ticker(symbol: string): TickerRecord {
|
|
11
13
|
return {
|
|
@@ -132,6 +134,43 @@ describe("selectStreamTickers", () => {
|
|
|
132
134
|
});
|
|
133
135
|
});
|
|
134
136
|
|
|
137
|
+
describe("visible quote refresh predicates", () => {
|
|
138
|
+
function quote(overrides: Partial<Quote> = {}): Quote {
|
|
139
|
+
return {
|
|
140
|
+
symbol: "AAPL",
|
|
141
|
+
price: 125,
|
|
142
|
+
currency: "USD",
|
|
143
|
+
change: 5,
|
|
144
|
+
changePercent: 4.17,
|
|
145
|
+
lastUpdated: 1_700_000_000_000,
|
|
146
|
+
...overrides,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function financials(quoteValue: Quote | undefined): TickerFinancials {
|
|
151
|
+
return {
|
|
152
|
+
annualStatements: [],
|
|
153
|
+
quarterlyStatements: [],
|
|
154
|
+
priceHistory: [],
|
|
155
|
+
quote: quoteValue,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
test("refreshes visible quotes when the local stream timestamp is too old", () => {
|
|
160
|
+
const now = 1_700_000_120_000;
|
|
161
|
+
expect(needsVisibleQuoteWatchdogRefresh(
|
|
162
|
+
financials(quote({ lastUpdated: now, receivedAt: now - 61_000 })),
|
|
163
|
+
now,
|
|
164
|
+
60_000,
|
|
165
|
+
)).toBe(true);
|
|
166
|
+
expect(needsVisibleQuoteWatchdogRefresh(
|
|
167
|
+
financials(quote({ lastUpdated: now, receivedAt: now - 10_000 })),
|
|
168
|
+
now,
|
|
169
|
+
60_000,
|
|
170
|
+
)).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
135
174
|
describe("portfolio list pane templates", () => {
|
|
136
175
|
test("create panes with default all-collection settings", async () => {
|
|
137
176
|
const config = createDefaultConfig("/tmp/gloomberb-portfolio-list-template");
|
|
@@ -154,3 +193,15 @@ describe("portfolio list pane templates", () => {
|
|
|
154
193
|
expect(portfolioInstance).toEqual({ params: { collectionId: "main" } });
|
|
155
194
|
});
|
|
156
195
|
});
|
|
196
|
+
|
|
197
|
+
describe("portfolio list pane settings", () => {
|
|
198
|
+
test("exposes view mode only for portfolio collections", () => {
|
|
199
|
+
const config = createDefaultConfig("/tmp/gloomberb-portfolio-list-settings");
|
|
200
|
+
const settings = getPortfolioPaneSettings({ viewMode: "grid" });
|
|
201
|
+
const portfolioFields = buildPortfolioPaneSettingsDef(config, settings, "main").fields.map((field) => field.key);
|
|
202
|
+
const watchlistFields = buildPortfolioPaneSettingsDef(config, settings, "watchlist").fields.map((field) => field.key);
|
|
203
|
+
|
|
204
|
+
expect(portfolioFields).toContain("viewMode");
|
|
205
|
+
expect(watchlistFields).not.toContain("viewMode");
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -5,11 +5,15 @@ import type { TickerFinancials } from "../../../../types/financials";
|
|
|
5
5
|
import type { TickerRecord } from "../../../../types/ticker";
|
|
6
6
|
import type { CollectionSortPreference } from "../../../../state/app/context";
|
|
7
7
|
import { isQuoteStaleForCurrentSession } from "../../../../market-data/quotes/freshness";
|
|
8
|
+
import { resolveQuoteAgeTimestamp } from "../../../../market-data/quotes/time";
|
|
8
9
|
import { compareSortValues } from "../../../../utils/sort-values";
|
|
9
10
|
import { getSortValue, type ColumnContext } from "../metrics";
|
|
10
11
|
import type { ResolvedPortfolioAccountState } from "../summary";
|
|
11
12
|
|
|
12
|
-
export const
|
|
13
|
+
export const VISIBLE_QUOTE_REFRESH_COOLDOWN_MS = 60_000;
|
|
14
|
+
export const VISIBLE_QUOTE_STREAM_MAX_AGE_MS = 60_000;
|
|
15
|
+
export const VISIBLE_QUOTE_STREAM_WATCHDOG_MS = 30_000;
|
|
16
|
+
export const VISIBLE_SNAPSHOT_REFRESH_COOLDOWN_MS = 5 * 60_000;
|
|
13
17
|
export const VISIBLE_FINANCIAL_WARMUP_DELAY_MS = 350;
|
|
14
18
|
export const VISIBLE_SNAPSHOT_WARMUP_BATCH_LIMIT = 3;
|
|
15
19
|
|
|
@@ -40,6 +44,16 @@ export function needsVisibleQuoteWarmup(financials: TickerFinancials | undefined
|
|
|
40
44
|
return !financials?.quote || isQuoteStaleForCurrentSession(financials.quote, now);
|
|
41
45
|
}
|
|
42
46
|
|
|
47
|
+
export function needsVisibleQuoteWatchdogRefresh(
|
|
48
|
+
financials: TickerFinancials | undefined,
|
|
49
|
+
now = Date.now(),
|
|
50
|
+
maxAgeMs = VISIBLE_QUOTE_STREAM_MAX_AGE_MS,
|
|
51
|
+
): boolean {
|
|
52
|
+
if (needsVisibleQuoteWarmup(financials, now)) return true;
|
|
53
|
+
const quoteTimestamp = resolveQuoteAgeTimestamp(financials?.quote, now);
|
|
54
|
+
return quoteTimestamp == null || now - quoteTimestamp >= maxAgeMs;
|
|
55
|
+
}
|
|
56
|
+
|
|
43
57
|
export function needsVisibleSnapshotWarmup(
|
|
44
58
|
ticker: TickerRecord,
|
|
45
59
|
financials: TickerFinancials | undefined,
|
|
@@ -687,6 +687,73 @@ 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 instance = config.layout.instances.find((entry) => entry.instanceId === TEST_PANE_ID);
|
|
694
|
+
if (instance) {
|
|
695
|
+
instance.settings = {
|
|
696
|
+
...(instance.settings ?? {}),
|
|
697
|
+
viewMode: "grid",
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
const pinned: Array<{ symbol: string; options: { floating?: boolean; paneType?: string } | undefined }> = [];
|
|
701
|
+
const runtime = createTestPluginRuntime({
|
|
702
|
+
pinTicker: (symbol, options) => {
|
|
703
|
+
pinned.push({ symbol, options });
|
|
704
|
+
},
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
testSetup = await testRender(
|
|
708
|
+
<PortfolioHarness
|
|
709
|
+
config={config}
|
|
710
|
+
collectionId={portfolioId}
|
|
711
|
+
runtime={runtime}
|
|
712
|
+
/>,
|
|
713
|
+
{ width: 100, height: 12 },
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
await flushFrame();
|
|
717
|
+
|
|
718
|
+
const frame = testSetup.captureCharFrame();
|
|
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
|
+
|
|
731
|
+
test("keeps watchlists in table view when grid is saved on the pane", async () => {
|
|
732
|
+
const config = createManualCollectionConfig("watchlist");
|
|
733
|
+
const instance = config.layout.instances.find((entry) => entry.instanceId === TEST_PANE_ID);
|
|
734
|
+
if (instance) {
|
|
735
|
+
instance.settings = {
|
|
736
|
+
...(instance.settings ?? {}),
|
|
737
|
+
viewMode: "grid",
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
testSetup = await testRender(
|
|
742
|
+
<PortfolioHarness
|
|
743
|
+
config={config}
|
|
744
|
+
collectionId="watchlist"
|
|
745
|
+
ticker={makeTicker({ portfolios: [], watchlists: ["watchlist"], positions: [] })}
|
|
746
|
+
/>,
|
|
747
|
+
{ width: 100, height: 12 },
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
await flushFrame();
|
|
751
|
+
|
|
752
|
+
const frame = testSetup.captureCharFrame();
|
|
753
|
+
expect(frame).toContain("TICKER");
|
|
754
|
+
expect(frame).toContain("AAPL");
|
|
755
|
+
});
|
|
756
|
+
|
|
690
757
|
test("keeps option avg cost on premium scale and multiplies quoted value by contract size", async () => {
|
|
691
758
|
const portfolioId = "broker:ibkr-flex:DU12345";
|
|
692
759
|
const optionTicker = "SPY 260619C00500000";
|
|
@@ -924,6 +991,88 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
924
991
|
expect(frame).toContain("22.0");
|
|
925
992
|
});
|
|
926
993
|
|
|
994
|
+
test("force-refreshes stale quote data for visible rows", async () => {
|
|
995
|
+
const config = createPortfolioConfigWithColumns(
|
|
996
|
+
"broker:ibkr-flex:DU12345",
|
|
997
|
+
["ticker", "price", "day_pnl"],
|
|
998
|
+
[createBrokerInstance("flex")],
|
|
999
|
+
);
|
|
1000
|
+
const staleQuote = makeQuote({
|
|
1001
|
+
price: 120,
|
|
1002
|
+
change: 0,
|
|
1003
|
+
changePercent: 0,
|
|
1004
|
+
marketState: "PRE",
|
|
1005
|
+
lastUpdated: Date.now() - 24 * 60 * 60 * 1000,
|
|
1006
|
+
});
|
|
1007
|
+
const batchOptions: Array<{ forceRefresh?: boolean } | undefined> = [];
|
|
1008
|
+
const provider: DataProvider = {
|
|
1009
|
+
id: "test-provider",
|
|
1010
|
+
name: "Test Provider",
|
|
1011
|
+
async getTickerFinancials() {
|
|
1012
|
+
return {
|
|
1013
|
+
annualStatements: [],
|
|
1014
|
+
quarterlyStatements: [],
|
|
1015
|
+
priceHistory: [],
|
|
1016
|
+
quote: staleQuote,
|
|
1017
|
+
};
|
|
1018
|
+
},
|
|
1019
|
+
async getQuotesBatch(targets, options) {
|
|
1020
|
+
batchOptions.push(options);
|
|
1021
|
+
return targets.map((target) => ({
|
|
1022
|
+
target,
|
|
1023
|
+
quote: makeQuote({
|
|
1024
|
+
symbol: target.symbol,
|
|
1025
|
+
price: 126,
|
|
1026
|
+
change: 6,
|
|
1027
|
+
changePercent: 5,
|
|
1028
|
+
marketState: "PRE",
|
|
1029
|
+
preMarketPrice: 126,
|
|
1030
|
+
preMarketChange: 6,
|
|
1031
|
+
preMarketChangePercent: 5,
|
|
1032
|
+
}),
|
|
1033
|
+
}));
|
|
1034
|
+
},
|
|
1035
|
+
async getQuote(symbol) {
|
|
1036
|
+
return makeQuote({ symbol, price: 126 });
|
|
1037
|
+
},
|
|
1038
|
+
async getExchangeRate() {
|
|
1039
|
+
return 1;
|
|
1040
|
+
},
|
|
1041
|
+
async search() {
|
|
1042
|
+
return [];
|
|
1043
|
+
},
|
|
1044
|
+
async getArticleSummary() {
|
|
1045
|
+
return null;
|
|
1046
|
+
},
|
|
1047
|
+
async getPriceHistory() {
|
|
1048
|
+
return [];
|
|
1049
|
+
},
|
|
1050
|
+
subscribeQuotes() {
|
|
1051
|
+
return () => {};
|
|
1052
|
+
},
|
|
1053
|
+
};
|
|
1054
|
+
sharedCoordinator = new MarketDataCoordinator(provider);
|
|
1055
|
+
setSharedMarketDataCoordinator(sharedCoordinator);
|
|
1056
|
+
|
|
1057
|
+
testSetup = await testRender(
|
|
1058
|
+
<PortfolioHarness
|
|
1059
|
+
config={config}
|
|
1060
|
+
collectionId="broker:ibkr-flex:DU12345"
|
|
1061
|
+
quote={staleQuote}
|
|
1062
|
+
/>,
|
|
1063
|
+
{ width: 100, height: 12 },
|
|
1064
|
+
);
|
|
1065
|
+
|
|
1066
|
+
await flushFrame();
|
|
1067
|
+
await act(async () => {
|
|
1068
|
+
await new Promise((resolve) => setTimeout(resolve, 380));
|
|
1069
|
+
});
|
|
1070
|
+
await flushFrame();
|
|
1071
|
+
|
|
1072
|
+
expect(batchOptions.some((options) => options?.forceRefresh === true)).toBe(true);
|
|
1073
|
+
expect(testSetup.captureCharFrame()).toContain("126");
|
|
1074
|
+
});
|
|
1075
|
+
|
|
927
1076
|
test("shows cached market cap on reopen for broker-linked rows", async () => {
|
|
928
1077
|
const dbPath = createTempDbPath("cached-reopen-market-cap");
|
|
929
1078
|
const persistence = new AppPersistence(dbPath);
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
useAppSelector,
|
|
14
14
|
usePaneCollection,
|
|
15
15
|
usePaneInstance,
|
|
16
|
+
usePaneSettingValue,
|
|
16
17
|
usePaneStateValue,
|
|
17
18
|
type CollectionSortPreference,
|
|
18
19
|
} from "../../../../state/app/context";
|
|
@@ -33,9 +34,11 @@ import {
|
|
|
33
34
|
resolveActiveCollectionId,
|
|
34
35
|
resolveScopedCollectionEntries,
|
|
35
36
|
resolveVisibleColumns,
|
|
37
|
+
type PortfolioViewMode,
|
|
36
38
|
} from "../settings";
|
|
37
39
|
import { useQuoteFlashMap } from "../../../../components/quote-flash";
|
|
38
40
|
import { PortfolioTickerTable } from "../table";
|
|
41
|
+
import { PortfolioGrid } from "../grid";
|
|
39
42
|
import { useThrottledCursorSymbol } from "../use-throttled-cursor-symbol";
|
|
40
43
|
import { isManualPortfolio } from "../mutations";
|
|
41
44
|
import { QuickAddTickerInput, type QuickAddCollectionKind } from "../quick-add";
|
|
@@ -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 [, setViewMode] = usePaneSettingValue<PortfolioViewMode>("viewMode", "table");
|
|
68
72
|
|
|
69
73
|
const [now, setNow] = useState(Date.now());
|
|
70
74
|
const [streamWindow, setStreamWindow] = useState({ start: 0, end: 24 });
|
|
@@ -97,6 +101,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
97
101
|
? config.portfolios.find((portfolio) => portfolio.id === activeCollectionId) ?? null
|
|
98
102
|
: null
|
|
99
103
|
), [activeCollectionId, config.portfolios, isPortfolioTab]);
|
|
104
|
+
const viewMode: PortfolioViewMode = isPortfolioTab ? paneSettings.viewMode : "table";
|
|
100
105
|
|
|
101
106
|
const tickers = useMemo(
|
|
102
107
|
() => getCollectionTickersFromConfig(config, tickersBySymbol, activeCollectionId),
|
|
@@ -214,6 +219,11 @@ 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
|
+
if (!isPortfolioTab) return;
|
|
224
|
+
setViewMode((current) => current === "table" ? "grid" : "table");
|
|
225
|
+
}, [isPortfolioTab, setViewMode]);
|
|
226
|
+
|
|
217
227
|
const handleRowActivate = useCallback((ticker: TickerRecord) => {
|
|
218
228
|
flushCursorSymbol(ticker.metadata.ticker);
|
|
219
229
|
openTickerFloating(ticker.metadata.ticker);
|
|
@@ -245,15 +255,24 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
245
255
|
return true;
|
|
246
256
|
}
|
|
247
257
|
|
|
258
|
+
if (key === "s" && isPortfolioTab) {
|
|
259
|
+
event.preventDefault?.();
|
|
260
|
+
event.stopPropagation?.();
|
|
261
|
+
toggleViewMode();
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
248
265
|
return false;
|
|
249
266
|
}, [
|
|
250
267
|
cashDrawerExpanded,
|
|
251
268
|
focused,
|
|
269
|
+
isPortfolioTab,
|
|
252
270
|
openTickerFloating,
|
|
253
271
|
safeSelectedIdx,
|
|
254
272
|
setCashDrawerExpanded,
|
|
255
273
|
showCashDrawer,
|
|
256
274
|
sortedTickers,
|
|
275
|
+
toggleViewMode,
|
|
257
276
|
]);
|
|
258
277
|
|
|
259
278
|
useEffect(() => {
|
|
@@ -281,12 +300,18 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
281
300
|
}
|
|
282
301
|
}, [cursorSymbol, setCursorSymbol, sortedTickers]);
|
|
283
302
|
|
|
303
|
+
const effectiveStreamWindow = useMemo(() => (
|
|
304
|
+
viewMode === "grid"
|
|
305
|
+
? { start: 0, end: Math.min(sortedTickers.length, 96) }
|
|
306
|
+
: streamWindow
|
|
307
|
+
), [sortedTickers.length, streamWindow, viewMode]);
|
|
308
|
+
|
|
284
309
|
usePortfolioPaneStreaming({
|
|
285
310
|
appActive,
|
|
286
311
|
focused,
|
|
287
312
|
sortedTickers,
|
|
288
313
|
cursorSymbol,
|
|
289
|
-
streamWindow,
|
|
314
|
+
streamWindow: effectiveStreamWindow,
|
|
290
315
|
isPortfolioTab,
|
|
291
316
|
financialsMap,
|
|
292
317
|
visibleWarmupRequirements,
|
|
@@ -341,7 +366,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
341
366
|
}, [activeCollectionId, config, currentPortfolio]);
|
|
342
367
|
const showQuickAdd = !!(activeCollectionId && activeCollectionEntry && quickAddCollectionKind);
|
|
343
368
|
const quickAddHeight = showQuickAdd ? 1 : 0;
|
|
344
|
-
const
|
|
369
|
+
const contentHeight = Math.max(1, height - headerHeight - drawerHeight - quickAddHeight);
|
|
345
370
|
const quickAddRow = activeCollectionId && activeCollectionEntry && quickAddCollectionKind ? (
|
|
346
371
|
<QuickAddTickerInput
|
|
347
372
|
collectionId={activeCollectionId}
|
|
@@ -356,8 +381,8 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
356
381
|
|
|
357
382
|
return (
|
|
358
383
|
<Box flexDirection="column" width={width} height={height}>
|
|
359
|
-
|
|
360
|
-
|
|
384
|
+
{showCollectionTabs && (
|
|
385
|
+
<Box flexDirection="column" height={headerHeight}>
|
|
361
386
|
<Box flexDirection="row" height={1}>
|
|
362
387
|
<Box flexShrink={1} overflow="hidden">
|
|
363
388
|
<Tabs
|
|
@@ -369,28 +394,44 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
369
394
|
/>
|
|
370
395
|
</Box>
|
|
371
396
|
</Box>
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
397
|
+
</Box>
|
|
398
|
+
)}
|
|
399
|
+
|
|
400
|
+
{viewMode === "table" ? (
|
|
401
|
+
<PortfolioTickerTable
|
|
402
|
+
columns={columns}
|
|
403
|
+
focused={focused && !quickAddFocused}
|
|
404
|
+
sortColumnId={activeSort.columnId}
|
|
405
|
+
sortDirection={activeSort.direction}
|
|
406
|
+
onHeaderClick={handleHeaderClick}
|
|
407
|
+
sortedTickers={sortedTickers}
|
|
408
|
+
cursorSymbol={cursorSymbol}
|
|
409
|
+
setCursorSymbol={setCursorSymbol}
|
|
410
|
+
financialsMap={financialsMap}
|
|
411
|
+
columnContext={columnContext}
|
|
412
|
+
flashSymbols={flashSymbols}
|
|
413
|
+
onRootKeyDown={handleTableKeyDown}
|
|
414
|
+
onVisibleRangeChange={handleVisibleRangeChange}
|
|
415
|
+
visibleRangeBuffer={3}
|
|
416
|
+
resetScrollKey={activeCollectionId}
|
|
417
|
+
onRowActivate={handleRowActivate}
|
|
418
|
+
rootHeight={contentHeight}
|
|
419
|
+
/>
|
|
420
|
+
) : (
|
|
421
|
+
<PortfolioGrid
|
|
422
|
+
sortedTickers={sortedTickers}
|
|
423
|
+
financialsMap={financialsMap}
|
|
424
|
+
columnContext={columnContext}
|
|
425
|
+
isPortfolioTab={isPortfolioTab}
|
|
426
|
+
cursorSymbol={cursorSymbol}
|
|
427
|
+
setCursorSymbol={(symbol) => setCursorSymbol(symbol)}
|
|
428
|
+
onRowActivate={handleRowActivate}
|
|
429
|
+
onToggleViewMode={toggleViewMode}
|
|
430
|
+
focused={focused && !quickAddFocused}
|
|
431
|
+
width={width}
|
|
432
|
+
height={contentHeight}
|
|
433
|
+
/>
|
|
434
|
+
)}
|
|
394
435
|
|
|
395
436
|
{quickAddRow}
|
|
396
437
|
|