gloomberb 0.15.0 → 0.15.2
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/bin/gloomberb +9 -1
- package/package.json +1 -1
- package/src/api-client/central-bank-rates.ts +3 -0
- package/src/api-client/types.ts +1 -1
- package/src/app/pane-runtime/index.tsx +7 -5
- package/src/app/runtime/broker-import.test.tsx +89 -0
- package/src/app/runtime/broker-import.ts +29 -8
- package/src/brokers/sync-broker-instance.ts +67 -0
- package/src/cli/commands/overview.ts +6 -2
- package/src/cli/native-smoke.ts +2 -0
- package/src/components/chart/composite/composite-chart.tsx +21 -1
- package/src/components/chart/composite/format.ts +71 -8
- package/src/components/chart/composite/fx-precision.test.ts +21 -3
- package/src/components/chart/composite/scene.ts +13 -1
- package/src/components/chart/composite/types.ts +4 -0
- package/src/components/layout/market-summary.ts +4 -2
- package/src/components/ticker/badge/format.test.ts +3 -3
- package/src/components/ticker/badge/format.ts +11 -9
- package/src/components/ticker/badge/text.test.tsx +6 -6
- package/src/components/ui/index.ts +2 -1
- package/src/components/ui/multi-select/dialog.tsx +46 -4
- package/src/components/ui/number-prompt-dialog.tsx +71 -0
- package/src/core/state/app/state.ts +8 -2
- package/src/market-data/coordinator/events.ts +5 -0
- package/src/market-data/coordinator/index.ts +44 -10
- package/src/market-data/market/format.test.ts +54 -0
- package/src/market-data/market/format.ts +164 -5
- package/src/market-data/query-store.test.ts +87 -0
- package/src/market-data/query-store.ts +66 -9
- package/src/plugins/builtin/alerts/format.ts +4 -3
- package/src/plugins/builtin/analytics/display.ts +3 -3
- package/src/plugins/builtin/analytics/index.test.tsx +22 -22
- package/src/plugins/builtin/analytics/pane-model.test.ts +10 -10
- package/src/plugins/builtin/analytics/pane-model.ts +8 -8
- package/src/plugins/builtin/analytics/portfolio-identity.test.tsx +6 -6
- package/src/plugins/builtin/analytics/risk-client.ts +6 -8
- package/src/plugins/builtin/analytics/risk-model.test.ts +21 -2
- package/src/plugins/builtin/analytics/risk-model.ts +8 -0
- package/src/plugins/builtin/analytics/view.tsx +2 -2
- package/src/plugins/builtin/backtest/engine.test.ts +0 -1
- package/src/plugins/builtin/backtest/engine.ts +1 -4
- package/src/plugins/builtin/backtest/pane.tsx +1 -1
- package/src/plugins/builtin/buildout/detail/company.tsx +3 -3
- package/src/plugins/builtin/central-bank-rates/client.ts +2 -1
- package/src/plugins/builtin/central-bank-rates/headless.ts +2 -2
- package/src/plugins/builtin/central-bank-rates/model.test.ts +16 -1
- package/src/plugins/builtin/central-bank-rates/model.ts +4 -4
- package/src/plugins/builtin/central-bank-rates/pane.tsx +2 -2
- package/src/plugins/builtin/chart-composer/pane.tsx +50 -3
- package/src/plugins/builtin/chart-composer/presets.test.ts +17 -0
- package/src/plugins/builtin/chart-composer/presets.ts +48 -0
- package/src/plugins/builtin/chart-composer/settings.ts +42 -11
- package/src/plugins/builtin/chat/chat.test.tsx +2 -2
- package/src/plugins/builtin/cloud/askg/pane.tsx +1 -1
- package/src/plugins/builtin/cloud/thesis/board-pane.test.tsx +25 -0
- package/src/plugins/builtin/cloud/thesis/board-pane.tsx +3 -2
- package/src/plugins/builtin/cloud/thesis/store.ts +2 -2
- package/src/plugins/builtin/congress-trades/detail.tsx +1 -1
- package/src/plugins/builtin/congress-trades/table.tsx +1 -1
- package/src/plugins/builtin/cot/client.ts +2 -1
- package/src/plugins/builtin/cot/model.test.ts +9 -0
- package/src/plugins/builtin/cot/model.ts +30 -1
- package/src/plugins/builtin/cot/pane.tsx +2 -4
- package/src/plugins/builtin/credit-conditions/client.ts +2 -1
- package/src/plugins/builtin/crypto-board/client.ts +2 -1
- package/src/plugins/builtin/crypto-board/model.test.ts +9 -0
- package/src/plugins/builtin/crypto-board/model.ts +11 -3
- package/src/plugins/builtin/custom-view/live-quotes.test.ts +6 -1
- package/src/plugins/builtin/custom-view/live-quotes.ts +26 -0
- package/src/plugins/builtin/custom-view/pane.tsx +18 -7
- package/src/plugins/builtin/custom-view/view-spec.test.ts +24 -6
- package/src/plugins/builtin/custom-view/view-spec.ts +53 -21
- package/src/plugins/builtin/debt-maturities/client.ts +16 -3
- package/src/plugins/builtin/debt-maturities/debt-maturities.test.ts +8 -0
- package/src/plugins/builtin/debt-maturities/headless.ts +3 -2
- package/src/plugins/builtin/debt-maturities/model.ts +2 -1
- package/src/plugins/builtin/debt-maturities/pane.tsx +0 -8
- package/src/plugins/builtin/earnings-calls/pane.test.tsx +14 -0
- package/src/plugins/builtin/earnings-calls/pane.tsx +3 -1
- package/src/plugins/builtin/econ/fred-client.test.ts +2 -0
- package/src/plugins/builtin/econ/fred-series-map.ts +0 -1
- package/src/plugins/builtin/econ/index.tsx +4 -2
- package/src/plugins/builtin/econ-statistics/pane.tsx +4 -2
- package/src/plugins/builtin/econ-statistics/stats.ts +29 -24
- package/src/plugins/builtin/econ-statistics/view.test.ts +16 -0
- package/src/plugins/builtin/equity-screener/headless.test.ts +22 -0
- package/src/plugins/builtin/equity-screener/headless.ts +18 -10
- package/src/plugins/builtin/estimate-revisions/client.ts +3 -1
- package/src/plugins/builtin/estimate-revisions/model.test.ts +7 -0
- package/src/plugins/builtin/estimate-revisions/model.ts +0 -5
- package/src/plugins/builtin/futures/table.tsx +4 -3
- package/src/plugins/builtin/futures-curve/client.ts +2 -1
- package/src/plugins/builtin/futures-curve/model.test.ts +6 -4
- package/src/plugins/builtin/futures-curve/model.ts +18 -14
- package/src/plugins/builtin/futures-curve/pane.tsx +2 -2
- package/src/plugins/builtin/fx-matrix/index.tsx +21 -4
- package/src/plugins/builtin/fx-matrix/live-legs.ts +7 -0
- package/src/plugins/builtin/fx-matrix/pairs.test.ts +5 -0
- package/src/plugins/builtin/fx-matrix/pairs.ts +9 -3
- package/src/plugins/builtin/insider/index.tsx +15 -12
- package/src/plugins/builtin/insider/pane.test.tsx +24 -0
- package/src/plugins/builtin/market-movers/headless.ts +2 -2
- package/src/plugins/builtin/market-movers/model.ts +23 -10
- package/src/plugins/builtin/market-movers/screener.test.ts +5 -1
- package/src/plugins/builtin/market-movers/screener.ts +3 -0
- package/src/plugins/builtin/market-movers/table.ts +2 -2
- package/src/plugins/builtin/market-valuation/client.test.ts +3 -0
- package/src/plugins/builtin/market-valuation/client.ts +6 -1
- package/src/plugins/builtin/money-markets/client.ts +2 -1
- package/src/plugins/builtin/money-markets/pane.tsx +0 -1
- package/src/plugins/builtin/options/expiry-selection.test.tsx +1 -1
- package/src/plugins/builtin/options/footer.test.ts +2 -2
- package/src/plugins/builtin/options/footer.ts +2 -2
- package/src/plugins/builtin/options/market-reference.test.ts +2 -2
- package/src/plugins/builtin/options/market-reference.ts +14 -2
- package/src/plugins/builtin/options/table.ts +5 -8
- package/src/plugins/builtin/options/view.test.tsx +2 -2
- package/src/plugins/builtin/options/view.tsx +1 -1
- package/src/plugins/builtin/options-calculator/pane.test.tsx +6 -6
- package/src/plugins/builtin/options-calculator/pane.tsx +23 -8
- package/src/plugins/builtin/options-calculator/surface.test.ts +16 -1
- package/src/plugins/builtin/options-calculator/surface.ts +13 -6
- package/src/plugins/builtin/portfolio-list/column-values.ts +17 -9
- package/src/plugins/builtin/portfolio-list/index.test.ts +5 -5
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +8 -8
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +12 -12
- package/src/plugins/builtin/portfolio-list/position-cost-integrity.test.ts +2 -2
- package/src/plugins/builtin/portfolio-list/summary/index.tsx +7 -6
- package/src/plugins/builtin/rate-path/client.ts +2 -1
- package/src/plugins/builtin/rate-path/headless.ts +1 -1
- package/src/plugins/builtin/rate-path/pane.tsx +0 -1
- package/src/plugins/builtin/realized-vol/client.test.ts +7 -0
- package/src/plugins/builtin/realized-vol/headless.ts +1 -1
- package/src/plugins/builtin/realized-vol/model.test.ts +16 -4
- package/src/plugins/builtin/realized-vol/model.ts +24 -9
- package/src/plugins/builtin/relative-rotation/client.ts +2 -1
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +3 -10
- package/src/plugins/builtin/risk-factors/data.test.ts +7 -0
- package/src/plugins/builtin/risk-factors/data.ts +8 -1
- package/src/plugins/builtin/risk-factors/pane.test.tsx +3 -0
- package/src/plugins/builtin/shared/screener-live-quotes.test.ts +3 -3
- package/src/plugins/builtin/shared/screener-live-quotes.ts +16 -1
- package/src/plugins/builtin/short-volume/client.ts +2 -1
- package/src/plugins/builtin/short-volume/pane.tsx +0 -1
- package/src/plugins/builtin/thirteenf/headless.ts +3 -3
- package/src/plugins/builtin/thirteenf/model.ts +14 -4
- package/src/plugins/builtin/thirteenf/overlap-pane.tsx +12 -5
- package/src/plugins/builtin/thirteenf/overlap.test.ts +13 -1
- package/src/plugins/builtin/thirteenf/overlap.ts +31 -3
- package/src/plugins/builtin/thirteenf/pane.test.tsx +2 -1
- package/src/plugins/builtin/thirteenf/pane.tsx +12 -9
- package/src/plugins/builtin/thirteenf/table.ts +9 -3
- package/src/plugins/builtin/ticker-detail/financials/model.ts +0 -12
- package/src/plugins/builtin/ticker-detail/headless-schema.ts +4 -1
- package/src/plugins/builtin/ticker-detail/index.test.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/overview/components.tsx +9 -10
- package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +6 -9
- package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +3 -2
- package/src/plugins/builtin/time-sales/model.test.ts +17 -1
- package/src/plugins/builtin/time-sales/model.ts +14 -3
- package/src/plugins/builtin/time-sales/pane.tsx +8 -4
- package/src/plugins/builtin/treasury-auctions/pane.tsx +2 -2
- package/src/plugins/builtin/vol-surface/client.ts +4 -1
- package/src/plugins/builtin/vol-surface/model.test.ts +4 -2
- package/src/plugins/builtin/vol-surface/model.ts +14 -2
- package/src/plugins/builtin/vol-surface/pane.tsx +9 -2
- package/src/plugins/builtin/volatility/client.test.ts +13 -8
- package/src/plugins/builtin/volatility/client.ts +3 -2
- package/src/plugins/builtin/volatility/headless.test.ts +2 -2
- package/src/plugins/builtin/volatility/model.test.ts +4 -0
- package/src/plugins/builtin/volatility/model.ts +6 -7
- package/src/renderers/electrobun/bun/desktop/backend-requests.ts +7 -2
- package/src/renderers/electrobun/bun/desktop/workspace/index.test.ts +33 -1
- package/src/renderers/electrobun/bun/desktop/workspace/index.ts +34 -1
- package/src/renderers/electrobun/bun/index.ts +2 -1
- package/src/renderers/opentui/render-timing-smoke.ts +20 -0
- package/src/sources/provider-router/profile-enrichment.test.ts +0 -3
- package/src/sources/yahoo-finance.test.ts +5 -1
- package/src/sources/yahoo-finance.ts +2 -1
- package/src/time-series/forward-valuation.ts +6 -0
- package/src/time-series/resolve.ts +5 -1
- package/src/time-series/valuation-currency.test.ts +2 -0
- package/src/time-series/valuation-currency.ts +3 -1
- package/src/utils/exchange-rate-snapshot.test.ts +12 -1
- package/src/utils/exchange-rate-snapshot.ts +3 -2
- package/src/utils/format.ts +55 -20
- package/src/utils/fx-market-hours.test.ts +28 -0
- package/src/utils/fx-market-hours.ts +42 -0
package/bin/gloomberb
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Bun applies patchedDependencies only to the root project, so a global
|
|
4
|
+
// `bun install -g gloomberb` runs react-reconciler without
|
|
5
|
+
// patches/react-reconciler@0.33.0.patch. Its dev build then records a
|
|
6
|
+
// performance.measure for every component render, which Bun keeps forever.
|
|
7
|
+
// The reconciler reads console.timeStamp once at load to decide, so hide it
|
|
8
|
+
// before React is imported.
|
|
9
|
+
delete console.timeStamp;
|
|
10
|
+
|
|
11
|
+
await import("../src/cli/entry.ts");
|
package/package.json
CHANGED
|
@@ -26,6 +26,9 @@ export interface CentralBankRow {
|
|
|
26
26
|
status: "available" | "stale" | "unavailable"
|
|
27
27
|
unavailableReason: "source-unavailable" | "metadata-mismatch" | "metadata-unavailable" | "no-observations" | "no-policy-rate" | "no-unified-rate" | null
|
|
28
28
|
notes: string[]
|
|
29
|
+
/** When the bank's own site last showed the same level as a lagging BIS observation. */
|
|
30
|
+
confirmedAt?: string | null
|
|
31
|
+
confirmationSourceUrl?: string | null
|
|
29
32
|
nextMeeting: { date: string; sourceUrl: string; verifiedAt: string } | null
|
|
30
33
|
}
|
|
31
34
|
|
package/src/api-client/types.ts
CHANGED
|
@@ -172,11 +172,14 @@ export function useAppPaneRuntime({
|
|
|
172
172
|
options?: PaneTemplateInstanceConfig,
|
|
173
173
|
) => {
|
|
174
174
|
const { width, height } = pluginRegistry.getTermSizeFn();
|
|
175
|
+
// Templates can await ticker resolution before placing their pane, so the
|
|
176
|
+
// layout from the render that started the request may be out of date.
|
|
177
|
+
const { config: { layout }, focusedPaneId } = stateRef.current;
|
|
175
178
|
const relativeTo = options?.relativeToPaneId
|
|
176
|
-
? resolvePaneTarget(options.relativeToPaneId)
|
|
177
|
-
: (
|
|
179
|
+
? resolvePaneTarget(options.relativeToPaneId, layout)
|
|
180
|
+
: (focusedPaneId && isPaneInLayout(layout, focusedPaneId) ? focusedPaneId : null);
|
|
178
181
|
const relativePosition = options?.relativePosition ?? "right";
|
|
179
|
-
let nextLayout =
|
|
182
|
+
let nextLayout = layout;
|
|
180
183
|
const dockedPaneIds = getDockedPaneIds(nextLayout);
|
|
181
184
|
|
|
182
185
|
if (options?.placement === "floating" || (options?.placement !== "docked" && paneDef.defaultMode === "floating")) {
|
|
@@ -204,8 +207,7 @@ export function useAppPaneRuntime({
|
|
|
204
207
|
persistLayout,
|
|
205
208
|
pluginRegistry,
|
|
206
209
|
resolvePaneTarget,
|
|
207
|
-
|
|
208
|
-
state.focusedPaneId,
|
|
210
|
+
stateRef,
|
|
209
211
|
]);
|
|
210
212
|
|
|
211
213
|
const showPane = useCallback((paneId: string) => {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { testRender } from "../../renderers/opentui/test-utils";
|
|
4
|
+
import { setConfigStoreHost, type ConfigStoreHost } from "../../data/config/store";
|
|
5
|
+
import { addPaneToLayout, getDockedPaneIds, isPaneInLayout } from "../../plugins/pane-manager";
|
|
6
|
+
import { appReducer, createInitialState, type AppAction, type AppState } from "../../state/app/context";
|
|
7
|
+
import { createDefaultConfig, createPaneInstance, type AppConfig, type BrokerInstanceConfig } from "../../types/config";
|
|
8
|
+
import type { BrokerAdapter } from "../../types/broker";
|
|
9
|
+
import type { TickerRecord } from "../../types/ticker";
|
|
10
|
+
import { useBrokerImportRuntime } from "./broker-import";
|
|
11
|
+
|
|
12
|
+
const saved: AppConfig[] = [];
|
|
13
|
+
setConfigStoreHost({
|
|
14
|
+
saveConfig: async (config: AppConfig) => { saved.push(config); },
|
|
15
|
+
} as unknown as ConfigStoreHost);
|
|
16
|
+
|
|
17
|
+
let rendered: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
saved.length = 0;
|
|
20
|
+
if (rendered) await act(async () => { rendered!.renderer.destroy(); });
|
|
21
|
+
rendered = undefined;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// The startup broker sync runs while the app is already usable, and a broker
|
|
25
|
+
// such as IBKR Flex takes seconds to answer. It used to commit the whole config
|
|
26
|
+
// it had copied before asking, which closed any pane opened in the meantime.
|
|
27
|
+
test("the startup broker sync keeps a pane opened while the broker was answering", async () => {
|
|
28
|
+
const instance: BrokerInstanceConfig = { id: "demo-broker", brokerType: "demo", label: "Demo", config: {}, enabled: true };
|
|
29
|
+
const config = { ...createDefaultConfig("/tmp/gloomberb-broker-import-test"), brokerInstances: [instance] };
|
|
30
|
+
let answer!: () => void;
|
|
31
|
+
const answered = new Promise<void>((resolve) => { answer = resolve; });
|
|
32
|
+
const broker = {
|
|
33
|
+
id: "demo",
|
|
34
|
+
name: "Demo",
|
|
35
|
+
configSchema: [],
|
|
36
|
+
validate: async () => true,
|
|
37
|
+
listAccounts: async () => [{ accountId: "ACC-1", name: "Primary", currency: "USD" }],
|
|
38
|
+
importPositions: async () => { await answered; return []; },
|
|
39
|
+
} as unknown as BrokerAdapter;
|
|
40
|
+
const tickers = new Map<string, TickerRecord>();
|
|
41
|
+
const tickerRepository = {
|
|
42
|
+
loadAllTickers: async () => [...tickers.values()],
|
|
43
|
+
loadTicker: async (symbol: string) => tickers.get(symbol) ?? null,
|
|
44
|
+
saveTicker: async (ticker: TickerRecord) => { tickers.set(ticker.metadata.ticker, ticker); },
|
|
45
|
+
} as any;
|
|
46
|
+
|
|
47
|
+
// AppProvider's dispatch reduces into the ref synchronously.
|
|
48
|
+
const stateRef: { current: AppState } = { current: createInitialState(config) };
|
|
49
|
+
const dispatch = (action: AppAction) => { stateRef.current = appReducer(stateRef.current, action); };
|
|
50
|
+
let runtime!: ReturnType<typeof useBrokerImportRuntime>;
|
|
51
|
+
function Harness() {
|
|
52
|
+
runtime = useBrokerImportRuntime({
|
|
53
|
+
dispatch,
|
|
54
|
+
pluginRegistry: {
|
|
55
|
+
brokers: new Map([["demo", broker]]),
|
|
56
|
+
persistence: { resources: undefined },
|
|
57
|
+
events: { emit() {} },
|
|
58
|
+
} as any,
|
|
59
|
+
refreshQuote: () => {},
|
|
60
|
+
stateRef,
|
|
61
|
+
tickerRepository,
|
|
62
|
+
});
|
|
63
|
+
return <text>harness</text>;
|
|
64
|
+
}
|
|
65
|
+
rendered = await testRender(<Harness />, { width: 20, height: 2 });
|
|
66
|
+
await act(async () => { await rendered!.renderOnce(); });
|
|
67
|
+
|
|
68
|
+
const importing = runtime.autoImportBrokerPositions(new Map(tickers));
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
70
|
+
const pane = createPaneInstance("news", { instanceId: "news:opened-during-sync" });
|
|
71
|
+
const docked = getDockedPaneIds(stateRef.current.config.layout);
|
|
72
|
+
dispatch({ type: "PUSH_LAYOUT_HISTORY" });
|
|
73
|
+
dispatch({
|
|
74
|
+
type: "UPDATE_LAYOUT",
|
|
75
|
+
layout: addPaneToLayout(stateRef.current.config.layout, pane, { relativeTo: docked[docked.length - 1]!, position: "right" }),
|
|
76
|
+
});
|
|
77
|
+
answer();
|
|
78
|
+
await importing;
|
|
79
|
+
|
|
80
|
+
const { config: live } = stateRef.current;
|
|
81
|
+
expect(isPaneInLayout(live.layout, pane.instanceId)).toBe(true);
|
|
82
|
+
expect(live.portfolios.some((portfolio) => portfolio.brokerInstanceId === instance.id)).toBe(true);
|
|
83
|
+
expect(live.brokerInstances[0]?.lastSyncedAt).toBeNumber();
|
|
84
|
+
expect(saved.length).toBeGreaterThan(0);
|
|
85
|
+
expect(saved.every((entry) => isPaneInLayout(entry.layout, pane.instanceId))).toBe(true);
|
|
86
|
+
// The sync leaves the layout alone, so opening the pane can still be undone.
|
|
87
|
+
dispatch({ type: "UNDO_LAYOUT" });
|
|
88
|
+
expect(isPaneInLayout(stateRef.current.config.layout, pane.instanceId)).toBe(false);
|
|
89
|
+
});
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { useCallback, type Dispatch } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
rebaseBrokerSyncConfig,
|
|
4
|
+
restoreBrokerPortfoliosFromTickerPositions,
|
|
5
|
+
syncBrokerInstance,
|
|
6
|
+
syncBrokerInstances,
|
|
7
|
+
} from "../../brokers/sync-broker-instance";
|
|
3
8
|
import type { SyncBrokerInstanceResult } from "../../brokers/sync-broker-instance";
|
|
4
9
|
import type { AppTickerRepositoryPort } from "../../core/app-service-ports";
|
|
5
10
|
import type { PluginRegistry } from "../../plugins/registry";
|
|
@@ -7,6 +12,7 @@ import { saveConfigImmediately } from "../../state/config-save-scheduler";
|
|
|
7
12
|
import type { AppAction, AppState } from "../../state/app/context";
|
|
8
13
|
import type { AppConfig } from "../../types/config";
|
|
9
14
|
import type { TickerRecord } from "../../types/ticker";
|
|
15
|
+
import { getBrokerInstance } from "../../utils/broker-instances";
|
|
10
16
|
|
|
11
17
|
export interface AppBrokerImportRuntime {
|
|
12
18
|
importBrokerPositions: (
|
|
@@ -49,17 +55,24 @@ export function useBrokerImportRuntime({
|
|
|
49
55
|
instanceId: string,
|
|
50
56
|
result: SyncBrokerInstanceResult,
|
|
51
57
|
baseConfig: AppConfig,
|
|
52
|
-
options?: Pick<BrokerImportOptions, "refreshImportedTickers" | "signal"
|
|
58
|
+
options?: Pick<BrokerImportOptions, "refreshImportedTickers" | "signal"> & {
|
|
59
|
+
/** The caller passed its own draft config, so the result replaces the live one. */
|
|
60
|
+
callerOwnsConfig?: boolean;
|
|
61
|
+
},
|
|
53
62
|
) => {
|
|
54
63
|
throwIfBrokerImportCancelled(options?.signal);
|
|
55
64
|
dispatch({ type: "SET_BROKER_ACCOUNTS", instanceId, accounts: result.brokerAccounts });
|
|
56
65
|
|
|
57
|
-
|
|
66
|
+
const liveConfig = stateRef.current.config;
|
|
67
|
+
const nextConfig = options?.callerOwnsConfig
|
|
68
|
+
? (result.config !== baseConfig ? result.config : liveConfig)
|
|
69
|
+
: rebaseBrokerSyncConfig(liveConfig, baseConfig, result.config, instanceId);
|
|
70
|
+
if (nextConfig !== liveConfig) {
|
|
58
71
|
throwIfBrokerImportCancelled(options?.signal);
|
|
59
|
-
dispatch({ type: "SET_CONFIG", config:
|
|
60
|
-
await saveConfigImmediately(
|
|
72
|
+
dispatch({ type: "SET_CONFIG", config: nextConfig });
|
|
73
|
+
await saveConfigImmediately(nextConfig);
|
|
61
74
|
throwIfBrokerImportCancelled(options?.signal);
|
|
62
|
-
pluginRegistry.events.emit("config:changed", { config:
|
|
75
|
+
pluginRegistry.events.emit("config:changed", { config: nextConfig });
|
|
63
76
|
}
|
|
64
77
|
|
|
65
78
|
for (const ticker of result.addedTickers) {
|
|
@@ -79,7 +92,7 @@ export function useBrokerImportRuntime({
|
|
|
79
92
|
refreshQuote(position.ticker, position.exchange, undefined, 1);
|
|
80
93
|
}
|
|
81
94
|
}
|
|
82
|
-
}, [dispatch, pluginRegistry.events, refreshQuote]);
|
|
95
|
+
}, [dispatch, pluginRegistry.events, refreshQuote, stateRef]);
|
|
83
96
|
|
|
84
97
|
const importBrokerPositions = useCallback(async (
|
|
85
98
|
instanceId: string,
|
|
@@ -99,6 +112,8 @@ export function useBrokerImportRuntime({
|
|
|
99
112
|
deferPersistence: true,
|
|
100
113
|
});
|
|
101
114
|
|
|
115
|
+
// A profile removed while its broker was answering stays removed, positions included.
|
|
116
|
+
if (!options?.config && !getBrokerInstance(stateRef.current.config.brokerInstances, instanceId)) return result;
|
|
102
117
|
let commitStarted = false;
|
|
103
118
|
try {
|
|
104
119
|
throwIfBrokerImportCancelled(options?.signal);
|
|
@@ -106,7 +121,10 @@ export function useBrokerImportRuntime({
|
|
|
106
121
|
commitStarted = true;
|
|
107
122
|
await result.commit();
|
|
108
123
|
throwIfBrokerImportCancelled(options?.signal);
|
|
109
|
-
await applyBrokerImportResult(instanceId, result, baseConfig,
|
|
124
|
+
await applyBrokerImportResult(instanceId, result, baseConfig, {
|
|
125
|
+
...options,
|
|
126
|
+
callerOwnsConfig: options?.config !== undefined,
|
|
127
|
+
});
|
|
110
128
|
|
|
111
129
|
return result;
|
|
112
130
|
} finally {
|
|
@@ -128,7 +146,10 @@ export function useBrokerImportRuntime({
|
|
|
128
146
|
tickerRepository,
|
|
129
147
|
existingTickers: tickerMap,
|
|
130
148
|
resources: pluginRegistry.persistence.resources,
|
|
149
|
+
deferPersistence: true,
|
|
131
150
|
onResult: async (result, instance, previousConfig) => {
|
|
151
|
+
if (!getBrokerInstance(stateRef.current.config.brokerInstances, instance.id)) return;
|
|
152
|
+
await result.commit();
|
|
132
153
|
await applyBrokerImportResult(instance.id, result, previousConfig, {
|
|
133
154
|
refreshImportedTickers: false,
|
|
134
155
|
});
|
|
@@ -48,6 +48,8 @@ export interface SyncBrokerInstancesArgs {
|
|
|
48
48
|
existingTickers: Map<string, TickerRecord>;
|
|
49
49
|
resources?: AppResourceStorePort;
|
|
50
50
|
persistResolvedBrokerConfig?: boolean;
|
|
51
|
+
/** Leave each result's ticker writes to `onResult`, which calls `result.commit()`. */
|
|
52
|
+
deferPersistence?: boolean;
|
|
51
53
|
onResult?: (result: SyncBrokerInstanceResult, instance: BrokerInstanceConfig, previousConfig: AppConfig) => void | Promise<void>;
|
|
52
54
|
}
|
|
53
55
|
|
|
@@ -77,6 +79,69 @@ function throwIfBrokerImportCancelled(signal?: AbortSignal): void {
|
|
|
77
79
|
}
|
|
78
80
|
}
|
|
79
81
|
|
|
82
|
+
/** The fields a sync changed, over an entry the user may have edited meanwhile (a rename, a disable). */
|
|
83
|
+
function rebaseFields<T extends object>(live: T, base: T, synced: T): T {
|
|
84
|
+
const next = { ...live } as Record<string, unknown>;
|
|
85
|
+
const before = base as Record<string, unknown>;
|
|
86
|
+
const after = synced as Record<string, unknown>;
|
|
87
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
88
|
+
if (after[key] === before[key]) continue;
|
|
89
|
+
if (key in after) next[key] = after[key];
|
|
90
|
+
else delete next[key];
|
|
91
|
+
}
|
|
92
|
+
return next as T;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Applies the entries a sync added, changed or removed (by id) onto the live list. */
|
|
96
|
+
function rebaseEntriesById<T extends { id: string }>(live: T[], base: T[], synced: T[]): T[] {
|
|
97
|
+
if (synced === base) return live;
|
|
98
|
+
const baseById = new Map(base.map((entry) => [entry.id, entry]));
|
|
99
|
+
const syncedById = new Map(synced.map((entry) => [entry.id, entry]));
|
|
100
|
+
const liveIds = new Set(live.map((entry) => entry.id));
|
|
101
|
+
let changed = false;
|
|
102
|
+
const next: T[] = [];
|
|
103
|
+
for (const entry of live) {
|
|
104
|
+
const before = baseById.get(entry.id);
|
|
105
|
+
const after = syncedById.get(entry.id);
|
|
106
|
+
if (before && !after) {
|
|
107
|
+
changed = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (after && after !== before) {
|
|
111
|
+
changed = true;
|
|
112
|
+
next.push(before ? rebaseFields(entry, before, after) : after);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
next.push(entry);
|
|
116
|
+
}
|
|
117
|
+
for (const entry of synced) {
|
|
118
|
+
if (baseById.has(entry.id) || liveIds.has(entry.id)) continue;
|
|
119
|
+
changed = true;
|
|
120
|
+
next.push(entry);
|
|
121
|
+
}
|
|
122
|
+
return changed ? next : live;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A broker can take seconds to answer, and the app is usable meanwhile. A sync
|
|
127
|
+
* only touches portfolios and broker instances, so only its changes to those
|
|
128
|
+
* are carried onto the live config; a pane opened or a tab switched while the
|
|
129
|
+
* broker was answering stays. A profile removed meanwhile stays removed.
|
|
130
|
+
*/
|
|
131
|
+
export function rebaseBrokerSyncConfig(
|
|
132
|
+
live: AppConfig,
|
|
133
|
+
base: AppConfig,
|
|
134
|
+
synced: AppConfig,
|
|
135
|
+
instanceId: string,
|
|
136
|
+
): AppConfig {
|
|
137
|
+
if (synced === base || !getBrokerInstance(live.brokerInstances, instanceId)) return live;
|
|
138
|
+
const portfolios = rebaseEntriesById(live.portfolios, base.portfolios, synced.portfolios);
|
|
139
|
+
const brokerInstances = rebaseEntriesById(live.brokerInstances, base.brokerInstances, synced.brokerInstances);
|
|
140
|
+
return portfolios === live.portfolios && brokerInstances === live.brokerInstances
|
|
141
|
+
? live
|
|
142
|
+
: { ...live, portfolios, brokerInstances };
|
|
143
|
+
}
|
|
144
|
+
|
|
80
145
|
async function maybePersistResolvedBrokerConfig(
|
|
81
146
|
config: AppConfig,
|
|
82
147
|
instance: BrokerInstanceConfig,
|
|
@@ -311,6 +376,7 @@ export async function syncBrokerInstances({
|
|
|
311
376
|
existingTickers,
|
|
312
377
|
resources,
|
|
313
378
|
persistResolvedBrokerConfig = false,
|
|
379
|
+
deferPersistence = false,
|
|
314
380
|
onResult,
|
|
315
381
|
}: SyncBrokerInstancesArgs): Promise<SyncBrokerInstancesResult> {
|
|
316
382
|
let nextConfig = config;
|
|
@@ -331,6 +397,7 @@ export async function syncBrokerInstances({
|
|
|
331
397
|
existingTickers: nextTickers,
|
|
332
398
|
resources,
|
|
333
399
|
persistResolvedBrokerConfig,
|
|
400
|
+
deferPersistence,
|
|
334
401
|
});
|
|
335
402
|
|
|
336
403
|
nextConfig = result.config;
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
rankScreenerQuotes,
|
|
10
10
|
type ScreenerCategory,
|
|
11
11
|
} from "../../plugins/builtin/market-movers/screener";
|
|
12
|
-
import { formatMoverPrice } from "../../plugins/builtin/market-movers/model";
|
|
12
|
+
import { formatMoverPrice, moverReferencePrice } from "../../plugins/builtin/market-movers/model";
|
|
13
13
|
import { loadCalendar, matchesCountry, matchesImpact, type CountryFilter, type ImpactFilter } from "../../plugins/builtin/econ/calendar-model";
|
|
14
14
|
import { isoDate, requireArg, takeOption } from "./command-utils";
|
|
15
15
|
import { buildCorrelationSeries } from "../../plugins/builtin/correlation/matrix/model";
|
|
@@ -32,7 +32,11 @@ const BASKET_NAMES = new Map<string, string>([
|
|
|
32
32
|
// Priced like the MOST pane. Index and yield levels (^GSPC, ^TNX) carry no currency sign.
|
|
33
33
|
const PRICE_COLUMN = { key: "price", header: "Last", align: "right" as const,
|
|
34
34
|
format: (value: unknown, row: Record<string, unknown>) => typeof value === "number"
|
|
35
|
-
? formatMoverPrice(
|
|
35
|
+
? formatMoverPrice(
|
|
36
|
+
value,
|
|
37
|
+
typeof row.currency === "string" && !String(row.symbol ?? "").startsWith("^") ? row.currency : "",
|
|
38
|
+
moverReferencePrice(row as Parameters<typeof moverReferencePrice>[0]),
|
|
39
|
+
) : "" };
|
|
36
40
|
const MOVER_COLUMNS = [
|
|
37
41
|
{ key: "symbol", header: "Symbol" },
|
|
38
42
|
{ key: "name", header: "Name" },
|
package/src/cli/native-smoke.ts
CHANGED
|
@@ -8,6 +8,8 @@ export async function smokeOpenTuiNative(): Promise<void> {
|
|
|
8
8
|
|
|
9
9
|
export async function smokeOpenTuiRuntime(): Promise<void> {
|
|
10
10
|
await import("../renderers/opentui/start");
|
|
11
|
+
const { assertRendersLeaveNoTimingEntries } = await import("../renderers/opentui/render-timing-smoke");
|
|
12
|
+
await assertRendersLeaveNoTimingEntries();
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
formatCompositeCursorValue,
|
|
54
54
|
formatCompositePointDetails,
|
|
55
55
|
formatCompositeSeriesValue,
|
|
56
|
+
seriesPriceReference,
|
|
56
57
|
formatCompositeTimeAxisDate,
|
|
57
58
|
type CompositeAxisValueFormatter,
|
|
58
59
|
} from "./format";
|
|
@@ -1450,9 +1451,27 @@ function legendValue(
|
|
|
1450
1451
|
series: ResolvedSeries,
|
|
1451
1452
|
value: number | null,
|
|
1452
1453
|
formatValue: CompositeChartProps["formatValue"],
|
|
1454
|
+
scene: CompositeChartScene | null,
|
|
1453
1455
|
): string {
|
|
1454
1456
|
if (value === null) return "—";
|
|
1455
|
-
return formatValue ? formatValue(value, series) : formatCompositeSeriesValue(value, series);
|
|
1457
|
+
return formatValue ? formatValue(value, series) : formatCompositeSeriesValue(value, series, axisPriceReference(scene, series));
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/** The reference the axis holding a series keeps for its asset, so an average
|
|
1461
|
+
* of the price shows the price's decimals rather than its own float tail. A
|
|
1462
|
+
* series far from that price (another coin on a shared axis) keeps its own. */
|
|
1463
|
+
function axisPriceReference(scene: CompositeChartScene | null, series: ResolvedSeries): number | undefined {
|
|
1464
|
+
const own = seriesPriceReference(series);
|
|
1465
|
+
for (const panel of scene?.panels ?? []) {
|
|
1466
|
+
for (const domain of [panel.axes.left, panel.axes.right]) {
|
|
1467
|
+
if (!domain?.seriesIds.includes(series.id)) continue;
|
|
1468
|
+
const shared = domain.priceReferences?.[series.priceAssetCategory ?? ""];
|
|
1469
|
+
if (shared === undefined || own === undefined) return own ?? shared;
|
|
1470
|
+
const ratio = Math.abs(own / shared);
|
|
1471
|
+
return ratio > 0.1 && ratio < 10 ? shared : own;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
return own;
|
|
1456
1475
|
}
|
|
1457
1476
|
|
|
1458
1477
|
const LISTED_TICKER_PATTERN = /(^|[\s(])([A-Z0-9^][A-Z0-9.^=/-]*):([A-Z0-9]{2,})(?=$|[\s),])/g;
|
|
@@ -1511,6 +1530,7 @@ function CompositeLegend({
|
|
|
1511
1530
|
entry,
|
|
1512
1531
|
cursorValue?.value ?? null,
|
|
1513
1532
|
formatValue,
|
|
1533
|
+
scene,
|
|
1514
1534
|
)}${changeText}`;
|
|
1515
1535
|
const fullText = [entry.label, valueText].filter(Boolean).join(" ");
|
|
1516
1536
|
const details = formatCompositePointDetails(cursorValue?.point);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatMarketPriceWithCurrency, type MarketFormatOptions } from "../../../market-data/market/format";
|
|
1
|
+
import { formatMarketPriceWithCurrency, stablePriceFractionDigits, type MarketFormatOptions } from "../../../market-data/market/format";
|
|
2
2
|
import type { ResolvedSeries, TimeSeriesPoint } from "../../../time-series/types";
|
|
3
3
|
import type { CompositeAxisDomain, CompositePanelScene } from "./types";
|
|
4
4
|
|
|
@@ -48,12 +48,55 @@ function formatFullCurrencyValue(
|
|
|
48
48
|
return currency ? formatMarketPriceWithCurrency(value, currency, { ...options, assetCategory }) : null;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function pointPriceValue(point: TimeSeriesPoint | undefined): number | undefined {
|
|
52
|
+
const value = point && Number.isFinite(point.value) ? point.value : point?.close;
|
|
53
|
+
return typeof value === "number" && Number.isFinite(value) && value !== 0 ? value : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The value a live price label takes its decimals from: the last completed
|
|
58
|
+
* bar, which holds still while the live one moves and sits near today's price
|
|
59
|
+
* even on a ten-year chart. Only market prices have one; fundamentals such as
|
|
60
|
+
* EPS keep formatting each value on its own.
|
|
61
|
+
*/
|
|
62
|
+
export function seriesPriceReference(series: ResolvedSeries): number | undefined {
|
|
63
|
+
if (series.observationKind !== "market" && series.dataShape !== "ohlcv" && series.priceAssetCategory === undefined) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
for (let index = series.points.length - 2; index >= 0; index -= 1) {
|
|
67
|
+
const value = pointPriceValue(series.points[index]);
|
|
68
|
+
if (value !== undefined) return value;
|
|
69
|
+
}
|
|
70
|
+
return pointPriceValue(series.points.at(-1));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A price label that moves with live data (the last price, the cursor, a
|
|
74
|
+
* legend value) keeps one decimal count for its asset, read from a reference
|
|
75
|
+
* price rather than the value itself: $150.10 does not become $150.1. */
|
|
76
|
+
function stablePriceOptions(unit: string, assetCategory: string | undefined, referencePrice: number | undefined): MarketFormatOptions {
|
|
77
|
+
if (referencePrice === undefined) return {};
|
|
78
|
+
return {
|
|
79
|
+
referencePrice,
|
|
80
|
+
fixedFractionDigits: stablePriceFractionDigits({
|
|
81
|
+
assetCategory,
|
|
82
|
+
currency: unitCurrencyCode(unit) ?? undefined,
|
|
83
|
+
referencePrice,
|
|
84
|
+
sessionPrices: [referencePrice],
|
|
85
|
+
}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
51
89
|
/** An axis can serve several price series. Keep the most precise value the
|
|
52
90
|
* shared formatter produces, rather than inheriting the first asset's rounding. */
|
|
53
|
-
function formatAxisPriceValue(
|
|
91
|
+
function formatAxisPriceValue(
|
|
92
|
+
value: number,
|
|
93
|
+
domain: CompositeAxisDomain,
|
|
94
|
+
options: MarketFormatOptions | ((category: string | undefined) => MarketFormatOptions) = {},
|
|
95
|
+
): string {
|
|
54
96
|
const categories = domain.priceAssetCategories?.length ? domain.priceAssetCategories : [undefined];
|
|
55
97
|
return categories.reduce<string>((best, category) => {
|
|
56
|
-
const
|
|
98
|
+
const categoryOptions = typeof options === "function" ? options(category) : options;
|
|
99
|
+
const formatted = formatFullCurrencyValue(value, domain.unit, category, categoryOptions) ?? "";
|
|
57
100
|
return formatted.length > best.length ? formatted : best;
|
|
58
101
|
}, "");
|
|
59
102
|
}
|
|
@@ -190,11 +233,24 @@ function compactResolution(value: number): number {
|
|
|
190
233
|
return absolute > 0 ? 10 ** (Math.floor(Math.log10(absolute)) - 2) : 0;
|
|
191
234
|
}
|
|
192
235
|
|
|
193
|
-
|
|
194
|
-
|
|
236
|
+
/** `referencePrice` defaults to the series' own; a study on a price axis
|
|
237
|
+
* passes the axis's reference so it shares the price's decimals. */
|
|
238
|
+
export function formatCompositeSeriesValue(
|
|
239
|
+
value: number,
|
|
240
|
+
series: ResolvedSeries,
|
|
241
|
+
referencePrice = seriesPriceReference(series),
|
|
242
|
+
): string {
|
|
243
|
+
return formatChartLegendValue(value, series.unit, series.unitGroup, series.priceAssetCategory, referencePrice);
|
|
195
244
|
}
|
|
196
245
|
|
|
197
|
-
export function formatChartLegendValue(
|
|
246
|
+
export function formatChartLegendValue(
|
|
247
|
+
value: number,
|
|
248
|
+
unit: string,
|
|
249
|
+
unitGroup = "",
|
|
250
|
+
assetCategory?: string,
|
|
251
|
+
/** Set for a live series: its price keeps the decimals this reference calls for. */
|
|
252
|
+
referencePrice?: number,
|
|
253
|
+
): string {
|
|
198
254
|
const trimmed = unit.trim();
|
|
199
255
|
const group = unitGroup.toLowerCase();
|
|
200
256
|
const compact = compactNumber(value);
|
|
@@ -212,7 +268,12 @@ export function formatChartLegendValue(value: number, unit: string, unitGroup =
|
|
|
212
268
|
return prefix ? `${value < 0 ? "-" : ""}${prefix}${amount}` : `${amount} ${trimmed}`.trim();
|
|
213
269
|
}
|
|
214
270
|
}
|
|
215
|
-
const fullPrice = formatFullCurrencyValue(
|
|
271
|
+
const fullPrice = formatFullCurrencyValue(
|
|
272
|
+
value,
|
|
273
|
+
trimmed,
|
|
274
|
+
assetCategory,
|
|
275
|
+
referencePrice === undefined ? {} : stablePriceOptions(trimmed, assetCategory, referencePrice),
|
|
276
|
+
);
|
|
216
277
|
if (fullPrice) return fullPrice;
|
|
217
278
|
return trimmed && trimmed.length <= 6 ? `${compact}${trimmed.startsWith("/") ? "" : " "}${trimmed}` : compact;
|
|
218
279
|
}
|
|
@@ -267,7 +328,9 @@ export function formatCompositeCursorValue(value: number, domain: CompositeAxisD
|
|
|
267
328
|
if (group.split(":")[0] === "currency-total") {
|
|
268
329
|
return formatChartLegendValue(value, domain.unit, domain.unitGroup);
|
|
269
330
|
}
|
|
270
|
-
const fullPrice = formatAxisPriceValue(value, domain)
|
|
331
|
+
const fullPrice = formatAxisPriceValue(value, domain, (category) => (
|
|
332
|
+
stablePriceOptions(domain.unit, category, domain.priceReferences?.[category ?? ""])
|
|
333
|
+
));
|
|
271
334
|
if (fullPrice) return fullPrice;
|
|
272
335
|
return formatCompositeAxisValue(value, domain);
|
|
273
336
|
}
|
|
@@ -6,9 +6,10 @@ import { buildPriceChartPreset } from "../../../plugins/builtin/chart-composer/p
|
|
|
6
6
|
import { formatMarketPriceWithCurrency } from "../../../market-data/market/format";
|
|
7
7
|
import { applyResolvedSeriesTransform } from "../../../time-series/transforms";
|
|
8
8
|
import type { HeadlessPaneContext } from "../../../types/headless";
|
|
9
|
-
import { formatCompositeCursorValue, formatCompositeSeriesValue } from "./format";
|
|
9
|
+
import { formatCompositeCursorValue, formatCompositeSeriesValue, seriesPriceReference } from "./format";
|
|
10
10
|
import { applyCompositeChartCursor, buildCompositeChartScene, resolveCompositeCursorDate } from "./scene";
|
|
11
11
|
import { reuseResolvedSeriesIdentity } from "./panel-series";
|
|
12
|
+
import { pricePointsToResolvedSeries } from "./price-series";
|
|
12
13
|
|
|
13
14
|
const cases = [
|
|
14
15
|
["EURUSD=X", "USD", "CURRENCY", 1.1602274179458618, "$1.160227"],
|
|
@@ -26,7 +27,9 @@ const cases = [
|
|
|
26
27
|
|
|
27
28
|
async function priceModel(symbol: string, currency: string, instrumentType: string, value: number) {
|
|
28
29
|
const step = Math.min(0.0001, value / 10);
|
|
29
|
-
|
|
30
|
+
// The last completed bar closed where the quote is: a live label takes its
|
|
31
|
+
// decimals from that close, which a real feed writes like the quote.
|
|
32
|
+
const points = [value + step, value, value].map((close, offset) => ({ date: new Date(Date.UTC(2026, 8, 9 + offset)), close }));
|
|
30
33
|
const spec = buildPriceChartPreset(symbol);
|
|
31
34
|
spec.viewport = { ...spec.viewport, range: "1M", resolution: "1d", dateWindow: { start: "2026-09-09", end: "2026-09-11" } };
|
|
32
35
|
spec.studies = [{ id: "sma", kind: "sma", inputSeriesIds: [spec.series[0]!.id], parameters: { period: 2 }, panelId: "main", axis: "left" }];
|
|
@@ -64,7 +67,9 @@ for (const [symbol, currency, category, value, expected] of cases) {
|
|
|
64
67
|
expect(formatCompositeCursorValue(cursor.value!, panel.axes[series.axis]!)).toBe(expected);
|
|
65
68
|
const average = model.chart.series.find((entry) => entry.id === "sma")!;
|
|
66
69
|
const averageValue = average.points.at(-1)!.value!;
|
|
67
|
-
|
|
70
|
+
// On the price's axis, a study of the price is written like the price.
|
|
71
|
+
expect(formatCompositeSeriesValue(averageValue, average, seriesPriceReference(series)))
|
|
72
|
+
.toBe(formatCompositeSeriesValue(averageValue, series));
|
|
68
73
|
});
|
|
69
74
|
}
|
|
70
75
|
|
|
@@ -91,3 +96,16 @@ test("FX metadata does not turn normalized returns or ratios into currency price
|
|
|
91
96
|
expect(formatCompositeSeriesValue(latest.value!, percent)).toEndWith("%");
|
|
92
97
|
expect(formatCompositeSeriesValue(1.25, { ...fx, unit: "x", unitGroup: "ratio" })).toBe("1.25x");
|
|
93
98
|
});
|
|
99
|
+
|
|
100
|
+
// Live labels read their decimals from the last completed bar, not the start
|
|
101
|
+
// of the window: a ten-year chart that began under $1 still prints cents.
|
|
102
|
+
test("a long-range chart labels today's price at today's precision", () => {
|
|
103
|
+
const closes = [0.0376, 12.5, 180.07, 180.12];
|
|
104
|
+
const series = pricePointsToResolvedSeries(
|
|
105
|
+
closes.map((close, day) => ({ date: new Date(Date.UTC(2016 + day * 3, 0, 1)), close })),
|
|
106
|
+
{ id: "nvda", label: "NVDA", color: "#fff", unit: "USD" },
|
|
107
|
+
);
|
|
108
|
+
expect(formatCompositeSeriesValue(180.12, series)).toBe("$180.12");
|
|
109
|
+
const earnings = { ...series, dataShape: "scalar" as const, unitGroup: "currency-per-share" };
|
|
110
|
+
expect(formatCompositeSeriesValue(2.35, earnings)).toBe("$2.35");
|
|
111
|
+
});
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
projectCompositeTimestamp,
|
|
21
21
|
unprojectCompositeTimestamp,
|
|
22
22
|
} from "./time-scale";
|
|
23
|
-
import { compositeAxisMaxTicks } from "./format";
|
|
23
|
+
import { compositeAxisMaxTicks, seriesPriceReference } from "./format";
|
|
24
24
|
import type { CompositeLastPriceMarker, CompositeTimeScale } from "./types";
|
|
25
25
|
|
|
26
26
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
@@ -290,6 +290,17 @@ function signBounded(rawMin: number, rawMax: number, padded: { min: number; max:
|
|
|
290
290
|
};
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
function axisPriceReferences(axisSeries: ResolvedSeries[]): Record<string, number> {
|
|
294
|
+
const references: Record<string, number> = {};
|
|
295
|
+
for (const entry of axisSeries) {
|
|
296
|
+
const key = entry.priceAssetCategory ?? "";
|
|
297
|
+
if (key in references) continue;
|
|
298
|
+
const value = seriesPriceReference(entry);
|
|
299
|
+
if (value !== undefined) references[key] = value;
|
|
300
|
+
}
|
|
301
|
+
return references;
|
|
302
|
+
}
|
|
303
|
+
|
|
293
304
|
function buildAxisDomain(
|
|
294
305
|
side: CompositeAxisSide,
|
|
295
306
|
series: ResolvedSeries[],
|
|
@@ -308,6 +319,7 @@ function buildAxisDomain(
|
|
|
308
319
|
unit: first.unit,
|
|
309
320
|
unitGroup: first.unitGroup,
|
|
310
321
|
priceAssetCategories: [...new Set(axisSeries.flatMap((entry) => entry.priceAssetCategory ? [entry.priceAssetCategory] : []))],
|
|
322
|
+
priceReferences: axisPriceReferences(axisSeries),
|
|
311
323
|
seriesIds: axisSeries.map((entry) => entry.id),
|
|
312
324
|
maxTicks: compositeAxisMaxTicks(rows),
|
|
313
325
|
tickRows: rows,
|
|
@@ -26,6 +26,10 @@ export interface CompositeAxisDomain {
|
|
|
26
26
|
unitGroup: string;
|
|
27
27
|
/** Each source's price category, retained when different assets share an axis. */
|
|
28
28
|
priceAssetCategories?: string[];
|
|
29
|
+
/** The reference price of the axis's first market series of each price
|
|
30
|
+
* category (keyed by category, "" for none). It sets the decimals of the
|
|
31
|
+
* cursor and last-price labels, so they do not follow the live value. */
|
|
32
|
+
priceReferences?: Record<string, number>;
|
|
29
33
|
seriesIds: string[];
|
|
30
34
|
/** Most labeled ticks the axis shows, sized to the panel height. */
|
|
31
35
|
maxTicks?: number;
|