gloomberb 0.9.6 → 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/package.json +1 -1
- package/src/pane-settings.test.ts +1 -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 +115 -7
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +13 -28
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +54 -4
- package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
- package/src/types/config.ts +1 -0
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ describe("pane settings helpers", () => {
|
|
|
10
10
|
|
|
11
11
|
expect(portfolioPane?.settings).toMatchObject({
|
|
12
12
|
collectionScope: "all",
|
|
13
|
+
viewMode: "table",
|
|
13
14
|
});
|
|
14
15
|
expect(portfolioPane?.settings?.columnIds).toEqual(DEFAULT_PORTFOLIO_COLUMN_IDS);
|
|
15
16
|
expect(detailPane?.settings).toEqual({
|
|
@@ -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,
|
|
@@ -690,6 +690,13 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
690
690
|
test("renders portfolio grid from portfolio table values and opens the selected ticker", async () => {
|
|
691
691
|
const portfolioId = "broker:ibkr-flex:DU12345";
|
|
692
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
|
+
}
|
|
693
700
|
const pinned: Array<{ symbol: string; options: { floating?: boolean; paneType?: string } | undefined }> = [];
|
|
694
701
|
const runtime = createTestPluginRuntime({
|
|
695
702
|
pinTicker: (symbol, options) => {
|
|
@@ -702,12 +709,6 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
702
709
|
config={config}
|
|
703
710
|
collectionId={portfolioId}
|
|
704
711
|
runtime={runtime}
|
|
705
|
-
stateMutator={(state) => {
|
|
706
|
-
state.paneState[TEST_PANE_ID] = {
|
|
707
|
-
...state.paneState[TEST_PANE_ID],
|
|
708
|
-
viewMode: "grid",
|
|
709
|
-
};
|
|
710
|
-
}}
|
|
711
712
|
/>,
|
|
712
713
|
{ width: 100, height: 12 },
|
|
713
714
|
);
|
|
@@ -715,7 +716,6 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
715
716
|
await flushFrame();
|
|
716
717
|
|
|
717
718
|
const frame = testSetup.captureCharFrame();
|
|
718
|
-
expect(frame).toContain("Grid");
|
|
719
719
|
expect(frame).toContain("AAPL");
|
|
720
720
|
expect(frame).toContain("1.3k");
|
|
721
721
|
expect(frame).toContain("+4.17%");
|
|
@@ -728,6 +728,32 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
728
728
|
expect(pinned).toEqual([{ symbol: "AAPL", options: { floating: true, paneType: TICKER_RESEARCH_PANE_ID } }]);
|
|
729
729
|
});
|
|
730
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
|
+
|
|
731
757
|
test("keeps option avg cost on premium scale and multiplies quoted value by contract size", async () => {
|
|
732
758
|
const portfolioId = "broker:ibkr-flex:DU12345";
|
|
733
759
|
const optionTicker = "SPY 260619C00500000";
|
|
@@ -965,6 +991,88 @@ describe("PortfolioListPane cash and margin UI", () => {
|
|
|
965
991
|
expect(frame).toContain("22.0");
|
|
966
992
|
});
|
|
967
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
|
+
|
|
968
1076
|
test("shows cached market cap on reopen for broker-linked rows", async () => {
|
|
969
1077
|
const dbPath = createTempDbPath("cached-reopen-market-cap");
|
|
970
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,6 +34,7 @@ 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";
|
|
@@ -50,8 +52,6 @@ import {
|
|
|
50
52
|
import { usePortfolioPaneStreaming } from "./streaming";
|
|
51
53
|
import { usePortfolioSupplementalData } from "./supplemental";
|
|
52
54
|
|
|
53
|
-
type PortfolioViewMode = "table" | "grid";
|
|
54
|
-
|
|
55
55
|
export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
56
56
|
const { pinTicker } = usePluginTickerActions();
|
|
57
57
|
const paneInstance = usePaneInstance();
|
|
@@ -68,7 +68,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
68
68
|
const [committedCursorSymbol, setCommittedCursorSymbol] = usePaneStateValue<string | null>("cursorSymbol", null);
|
|
69
69
|
const [collectionSorts, setCollectionSorts] = usePaneStateValue<Record<string, CollectionSortPreference>>("collectionSorts", {});
|
|
70
70
|
const [cashDrawerExpanded, setCashDrawerExpanded] = usePaneStateValue<boolean>("cashDrawerExpanded", false);
|
|
71
|
-
const [
|
|
71
|
+
const [, setViewMode] = usePaneSettingValue<PortfolioViewMode>("viewMode", "table");
|
|
72
72
|
|
|
73
73
|
const [now, setNow] = useState(Date.now());
|
|
74
74
|
const [streamWindow, setStreamWindow] = useState({ start: 0, end: 24 });
|
|
@@ -101,6 +101,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
101
101
|
? config.portfolios.find((portfolio) => portfolio.id === activeCollectionId) ?? null
|
|
102
102
|
: null
|
|
103
103
|
), [activeCollectionId, config.portfolios, isPortfolioTab]);
|
|
104
|
+
const viewMode: PortfolioViewMode = isPortfolioTab ? paneSettings.viewMode : "table";
|
|
104
105
|
|
|
105
106
|
const tickers = useMemo(
|
|
106
107
|
() => getCollectionTickersFromConfig(config, tickersBySymbol, activeCollectionId),
|
|
@@ -178,8 +179,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
178
179
|
? (cashDrawerExpanded ? Math.min(6, Math.max(3, 2 + accountState.visibleCashBalances.length)) : 1)
|
|
179
180
|
: 0;
|
|
180
181
|
const showCollectionTabs = visibleCollections.length > 1;
|
|
181
|
-
const
|
|
182
|
-
const headerHeight = viewTabsHeight + (showCollectionTabs ? 1 : 0);
|
|
182
|
+
const headerHeight = showCollectionTabs ? 1 : 0;
|
|
183
183
|
const drawerHeight = showCashDrawer
|
|
184
184
|
? Math.min(requestedDrawerHeight, Math.max(1, height - (headerHeight + 2)))
|
|
185
185
|
: 0;
|
|
@@ -220,8 +220,9 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
220
220
|
}, [pinTicker]);
|
|
221
221
|
|
|
222
222
|
const toggleViewMode = useCallback(() => {
|
|
223
|
+
if (!isPortfolioTab) return;
|
|
223
224
|
setViewMode((current) => current === "table" ? "grid" : "table");
|
|
224
|
-
}, [setViewMode]);
|
|
225
|
+
}, [isPortfolioTab, setViewMode]);
|
|
225
226
|
|
|
226
227
|
const handleRowActivate = useCallback((ticker: TickerRecord) => {
|
|
227
228
|
flushCursorSymbol(ticker.metadata.ticker);
|
|
@@ -254,7 +255,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
254
255
|
return true;
|
|
255
256
|
}
|
|
256
257
|
|
|
257
|
-
if (key === "s") {
|
|
258
|
+
if (key === "s" && isPortfolioTab) {
|
|
258
259
|
event.preventDefault?.();
|
|
259
260
|
event.stopPropagation?.();
|
|
260
261
|
toggleViewMode();
|
|
@@ -265,6 +266,7 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
265
266
|
}, [
|
|
266
267
|
cashDrawerExpanded,
|
|
267
268
|
focused,
|
|
269
|
+
isPortfolioTab,
|
|
268
270
|
openTickerFloating,
|
|
269
271
|
safeSelectedIdx,
|
|
270
272
|
setCashDrawerExpanded,
|
|
@@ -379,25 +381,8 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
379
381
|
|
|
380
382
|
return (
|
|
381
383
|
<Box flexDirection="column" width={width} height={height}>
|
|
382
|
-
|
|
383
|
-
<Box flexDirection="
|
|
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
|
-
|
|
400
|
-
{showCollectionTabs && (
|
|
384
|
+
{showCollectionTabs && (
|
|
385
|
+
<Box flexDirection="column" height={headerHeight}>
|
|
401
386
|
<Box flexDirection="row" height={1}>
|
|
402
387
|
<Box flexShrink={1} overflow="hidden">
|
|
403
388
|
<Tabs
|
|
@@ -409,8 +394,8 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
|
|
|
409
394
|
/>
|
|
410
395
|
</Box>
|
|
411
396
|
</Box>
|
|
412
|
-
|
|
413
|
-
|
|
397
|
+
</Box>
|
|
398
|
+
)}
|
|
414
399
|
|
|
415
400
|
{viewMode === "table" ? (
|
|
416
401
|
<PortfolioTickerTable
|
|
@@ -5,10 +5,13 @@ import { useQuoteStreaming } from "../../../../state/hooks/quote-streaming";
|
|
|
5
5
|
import type { TickerFinancials } from "../../../../types/financials";
|
|
6
6
|
import type { TickerRecord } from "../../../../types/ticker";
|
|
7
7
|
import {
|
|
8
|
-
VISIBLE_FINANCIAL_REFRESH_COOLDOWN_MS,
|
|
9
8
|
VISIBLE_FINANCIAL_WARMUP_DELAY_MS,
|
|
9
|
+
VISIBLE_QUOTE_REFRESH_COOLDOWN_MS,
|
|
10
|
+
VISIBLE_QUOTE_STREAM_WATCHDOG_MS,
|
|
10
11
|
VISIBLE_SNAPSHOT_WARMUP_BATCH_LIMIT,
|
|
12
|
+
VISIBLE_SNAPSHOT_REFRESH_COOLDOWN_MS,
|
|
11
13
|
needsVisibleQuoteWarmup,
|
|
14
|
+
needsVisibleQuoteWatchdogRefresh,
|
|
12
15
|
needsVisibleSnapshotWarmup,
|
|
13
16
|
selectStreamTickers,
|
|
14
17
|
visibleWarmupKey,
|
|
@@ -90,7 +93,7 @@ export function usePortfolioPaneStreaming({
|
|
|
90
93
|
if (
|
|
91
94
|
needsVisibleQuoteWarmup(financials, nowTimestamp)
|
|
92
95
|
&& !warmupInFlightRef.current.has(quoteKey)
|
|
93
|
-
&& nowTimestamp - (warmupAttemptRef.current.get(quoteKey) ?? 0) >=
|
|
96
|
+
&& nowTimestamp - (warmupAttemptRef.current.get(quoteKey) ?? 0) >= VISIBLE_QUOTE_REFRESH_COOLDOWN_MS
|
|
94
97
|
) {
|
|
95
98
|
quoteQueue.push(ticker);
|
|
96
99
|
continue;
|
|
@@ -100,7 +103,7 @@ export function usePortfolioPaneStreaming({
|
|
|
100
103
|
if (
|
|
101
104
|
needsVisibleSnapshotWarmup(ticker, financials, visibleWarmupRequirements)
|
|
102
105
|
&& !warmupInFlightRef.current.has(snapshotKey)
|
|
103
|
-
&& nowTimestamp - (warmupAttemptRef.current.get(snapshotKey) ?? 0) >=
|
|
106
|
+
&& nowTimestamp - (warmupAttemptRef.current.get(snapshotKey) ?? 0) >= VISIBLE_SNAPSHOT_REFRESH_COOLDOWN_MS
|
|
104
107
|
) {
|
|
105
108
|
snapshotQueue.push(ticker);
|
|
106
109
|
}
|
|
@@ -130,7 +133,7 @@ export function usePortfolioPaneStreaming({
|
|
|
130
133
|
try {
|
|
131
134
|
await Promise.allSettled([
|
|
132
135
|
quoteEntries.length > 0
|
|
133
|
-
? sharedCoordinator.loadQuotesBatch(quoteEntries.map((entry) => entry.instrument))
|
|
136
|
+
? sharedCoordinator.loadQuotesBatch(quoteEntries.map((entry) => entry.instrument), { forceRefresh: true })
|
|
134
137
|
: Promise.resolve(),
|
|
135
138
|
snapshotEntries.length > 0
|
|
136
139
|
? sharedCoordinator.loadSnapshotsBatch(snapshotEntries.map((entry) => entry.instrument))
|
|
@@ -155,5 +158,52 @@ export function usePortfolioPaneStreaming({
|
|
|
155
158
|
};
|
|
156
159
|
}, [appActive, financialsMap, focused, sharedCoordinator, visibleFinancialTickers, visibleWarmupRequirements]);
|
|
157
160
|
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (!appActive) return;
|
|
163
|
+
if (!focused) return;
|
|
164
|
+
if (!sharedCoordinator) return;
|
|
165
|
+
|
|
166
|
+
let cancelled = false;
|
|
167
|
+
const runWatchdog = async (): Promise<void> => {
|
|
168
|
+
const nowTimestamp = Date.now();
|
|
169
|
+
const quoteEntries = visibleFinancialTickers.flatMap((ticker) => {
|
|
170
|
+
const financials = financialsMap.get(ticker.metadata.ticker);
|
|
171
|
+
const key = visibleWarmupKey("quote", ticker);
|
|
172
|
+
if (
|
|
173
|
+
!needsVisibleQuoteWatchdogRefresh(financials, nowTimestamp)
|
|
174
|
+
|| warmupInFlightRef.current.has(key)
|
|
175
|
+
|| nowTimestamp - (warmupAttemptRef.current.get(key) ?? 0) < VISIBLE_QUOTE_REFRESH_COOLDOWN_MS
|
|
176
|
+
) {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker);
|
|
180
|
+
if (!instrument) return [];
|
|
181
|
+
warmupInFlightRef.current.add(key);
|
|
182
|
+
warmupAttemptRef.current.set(key, nowTimestamp);
|
|
183
|
+
return [{ key, instrument }];
|
|
184
|
+
});
|
|
185
|
+
if (quoteEntries.length === 0) return;
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await sharedCoordinator.loadQuotesBatch(quoteEntries.map((entry) => entry.instrument), { forceRefresh: true });
|
|
189
|
+
} catch {
|
|
190
|
+
// Best-effort watchdog for visible rows only.
|
|
191
|
+
} finally {
|
|
192
|
+
for (const entry of quoteEntries) {
|
|
193
|
+
warmupInFlightRef.current.delete(entry.key);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const intervalId = setInterval(() => {
|
|
199
|
+
if (!cancelled && mountedRef.current) void runWatchdog();
|
|
200
|
+
}, VISIBLE_QUOTE_STREAM_WATCHDOG_MS);
|
|
201
|
+
|
|
202
|
+
return () => {
|
|
203
|
+
cancelled = true;
|
|
204
|
+
clearInterval(intervalId);
|
|
205
|
+
};
|
|
206
|
+
}, [appActive, financialsMap, focused, sharedCoordinator, visibleFinancialTickers]);
|
|
207
|
+
|
|
158
208
|
useQuoteStreaming(streamTargets);
|
|
159
209
|
}
|
|
@@ -3,11 +3,13 @@ import { DEFAULT_COLUMNS, DEFAULT_PORTFOLIO_COLUMN_IDS, type AppConfig, type Col
|
|
|
3
3
|
import { PRICE_SPARKLINE_COLUMN_ID, PRICE_SPARKLINE_PERIOD_LABEL } from "../../../components/price-sparkline/view";
|
|
4
4
|
|
|
5
5
|
type CollectionScope = "all" | "portfolios" | "watchlists" | "custom";
|
|
6
|
+
export type PortfolioViewMode = "table" | "grid";
|
|
6
7
|
|
|
7
8
|
export interface PortfolioPaneSettings {
|
|
8
9
|
columnIds: string[];
|
|
9
10
|
collectionScope: CollectionScope;
|
|
10
11
|
visibleCollectionIds: string[];
|
|
12
|
+
viewMode: PortfolioViewMode;
|
|
11
13
|
hideHeader: boolean;
|
|
12
14
|
hideCash: boolean;
|
|
13
15
|
}
|
|
@@ -95,11 +97,27 @@ const COLLECTION_SCOPE_OPTIONS: PaneSettingOption[] = [
|
|
|
95
97
|
description: "Choose exactly which collections this pane should show.",
|
|
96
98
|
},
|
|
97
99
|
];
|
|
100
|
+
const VIEW_MODE_OPTIONS: PaneSettingOption[] = [
|
|
101
|
+
{
|
|
102
|
+
value: "table",
|
|
103
|
+
label: "Table",
|
|
104
|
+
description: "Show positions in the portfolio table.",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
value: "grid",
|
|
108
|
+
label: "Grid",
|
|
109
|
+
description: "Show positions in the portfolio grid.",
|
|
110
|
+
},
|
|
111
|
+
];
|
|
98
112
|
|
|
99
113
|
function isCollectionScope(value: unknown): value is CollectionScope {
|
|
100
114
|
return value === "all" || value === "portfolios" || value === "watchlists" || value === "custom";
|
|
101
115
|
}
|
|
102
116
|
|
|
117
|
+
function isPortfolioViewMode(value: unknown): value is PortfolioViewMode {
|
|
118
|
+
return value === "table" || value === "grid";
|
|
119
|
+
}
|
|
120
|
+
|
|
103
121
|
function filterCollectionEntries(entries: CollectionEntry[], settings: PortfolioPaneSettings): CollectionEntry[] {
|
|
104
122
|
switch (settings.collectionScope) {
|
|
105
123
|
case "portfolios":
|
|
@@ -142,6 +160,7 @@ export function getPortfolioPaneSettings(settings: Record<string, unknown> | und
|
|
|
142
160
|
columnIds: columnIds.length > 0 ? columnIds : DEFAULT_PORTFOLIO_COLUMN_IDS,
|
|
143
161
|
collectionScope: isCollectionScope(settings?.collectionScope) ? settings.collectionScope : "all",
|
|
144
162
|
visibleCollectionIds,
|
|
163
|
+
viewMode: isPortfolioViewMode(settings?.viewMode) ? settings.viewMode : "table",
|
|
145
164
|
hideHeader: settings?.hideHeader === true,
|
|
146
165
|
hideCash: settings?.hideCash === true,
|
|
147
166
|
};
|
|
@@ -218,9 +237,15 @@ export function resolveVisibleColumns(columnIds: string[], isPortfolioTab: boole
|
|
|
218
237
|
.filter((column) => isPortfolioTab || !PORTFOLIO_ONLY_COLUMN_IDS.has(column.id));
|
|
219
238
|
}
|
|
220
239
|
|
|
221
|
-
export function buildPortfolioPaneSettingsDef(
|
|
240
|
+
export function buildPortfolioPaneSettingsDef(
|
|
241
|
+
config: AppConfig,
|
|
242
|
+
settings: PortfolioPaneSettings,
|
|
243
|
+
activeCollectionId?: string | null,
|
|
244
|
+
): PaneSettingsDef {
|
|
222
245
|
const collectionEntries = getCollectionEntries(config);
|
|
223
246
|
const allCollectionOptions = resolveCollectionOptions(collectionEntries);
|
|
247
|
+
const activeCollectionIsPortfolio = !!activeCollectionId
|
|
248
|
+
&& config.portfolios.some((portfolio) => portfolio.id === activeCollectionId);
|
|
224
249
|
|
|
225
250
|
const fields: PaneSettingsDef["fields"] = [
|
|
226
251
|
{
|
|
@@ -241,6 +266,15 @@ export function buildPortfolioPaneSettingsDef(config: AppConfig, settings: Portf
|
|
|
241
266
|
},
|
|
242
267
|
];
|
|
243
268
|
|
|
269
|
+
if (activeCollectionIsPortfolio) {
|
|
270
|
+
fields.push({
|
|
271
|
+
key: "viewMode",
|
|
272
|
+
label: "View",
|
|
273
|
+
type: "select",
|
|
274
|
+
options: VIEW_MODE_OPTIONS,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
244
278
|
if (settings.collectionScope === "custom") {
|
|
245
279
|
fields.push({
|
|
246
280
|
key: "visibleCollectionIds",
|
|
@@ -266,6 +300,7 @@ export function buildPortfolioPaneSettingsDef(config: AppConfig, settings: Portf
|
|
|
266
300
|
...settings,
|
|
267
301
|
columnIds: [...settings.columnIds],
|
|
268
302
|
visibleCollectionIds: [...settings.visibleCollectionIds],
|
|
303
|
+
viewMode: settings.viewMode,
|
|
269
304
|
},
|
|
270
305
|
fields,
|
|
271
306
|
};
|
package/src/types/config.ts
CHANGED
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.9.
|
|
1
|
+
export const VERSION = "0.9.7";
|