gloomberb 0.7.2 → 0.7.3
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 +79 -132
- package/package.json +1 -1
- package/src/app.tsx +73 -67
- package/src/components/chart/chart-data.ts +7 -2
- package/src/components/chart/chart-pane-settings.ts +7 -4
- package/src/components/chart/chart-renderer.ts +49 -15
- package/src/components/chart/chart-types.ts +2 -2
- package/src/components/chart/chart.test.ts +27 -8
- package/src/components/chart/comparison-stock-chart.tsx +84 -121
- package/src/components/chart/native/chart-rasterizer.ts +43 -17
- package/src/components/chart/price-axis-labels.tsx +25 -9
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +1 -1
- package/src/components/chart/stock-chart.tsx +59 -41
- package/src/components/command-bar/command-bar.test.tsx +446 -120
- package/src/components/command-bar/command-bar.tsx +528 -383
- package/src/components/command-bar/command-registry.ts +16 -6
- package/src/components/command-bar/root-shortcuts.ts +4 -5
- package/src/components/command-bar/view-model.test.ts +1 -0
- package/src/components/command-bar/view-model.ts +2 -1
- package/src/components/command-bar/workflow-ops.ts +11 -32
- package/src/components/data-table-view.test.tsx +24 -1
- package/src/components/data-table-view.tsx +3 -2
- package/src/components/feed-data-table-stack-view.tsx +3 -2
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/floating-pane.tsx +9 -2
- package/src/components/layout/pane-footer.tsx +1 -1
- package/src/components/layout/pane-header.tsx +4 -2
- package/src/components/layout/pane-sizing.ts +12 -0
- package/src/components/layout/pane.tsx +24 -2
- package/src/components/layout/shell.tsx +24 -10
- package/src/components/layout/status-bar.test.tsx +4 -1
- package/src/components/layout/status-bar.tsx +4 -24
- package/src/components/markdown-text.tsx +53 -8
- package/src/components/onboarding/onboarding-wizard.tsx +7 -6
- package/src/components/pane-settings-dialog.tsx +9 -6
- package/src/components/pane-template-wizard.tsx +3 -2
- package/src/components/price-selector-dialog.tsx +3 -2
- package/src/components/price-sparkline-view.tsx +463 -0
- package/src/components/price-sparkline.ts +46 -0
- package/src/components/table-view-shared.tsx +21 -3
- package/src/components/ticker-list-table-view.tsx +3 -2
- package/src/components/ticker-list-table.tsx +11 -51
- package/src/components/toggle-list.tsx +3 -3
- package/src/components/ui/choice-dialog.tsx +3 -2
- package/src/components/ui/multi-select-dialog.tsx +3 -2
- package/src/components/ui/native-select.tsx +3 -3
- package/src/core/state/app-state.ts +210 -80
- package/src/data/config-store-node.ts +113 -25
- package/src/data/config-store.test.ts +65 -3
- package/src/data/plugin-state-store.ts +39 -13
- package/src/data/sqlite-cache.test.ts +13 -0
- package/src/market-data/coordinator.ts +8 -1
- package/src/news/aggregator.test.ts +41 -0
- package/src/news/aggregator.ts +61 -9
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
- package/src/plugins/builtin/ai/settings.ts +3 -0
- package/src/plugins/builtin/broker-manager/index.tsx +4 -3
- package/src/plugins/builtin/chat-controller.test.ts +398 -17
- package/src/plugins/builtin/chat-controller.ts +258 -44
- package/src/plugins/builtin/chat.test.tsx +257 -6
- package/src/plugins/builtin/chat.tsx +225 -60
- package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
- package/src/plugins/builtin/correlation/colors.test.ts +39 -0
- package/src/plugins/builtin/correlation/colors.ts +87 -0
- package/src/plugins/builtin/correlation/index.test.tsx +151 -0
- package/src/plugins/builtin/correlation/index.tsx +92 -21
- package/src/plugins/builtin/debug.tsx +4 -3
- package/src/plugins/builtin/econ/index.tsx +3 -2
- package/src/plugins/builtin/help.test.tsx +2 -2
- package/src/plugins/builtin/holders/index.tsx +25 -13
- package/src/plugins/builtin/layout-manager.tsx +1 -7
- package/src/plugins/builtin/news-wire/index.tsx +1 -1
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
- package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
- package/src/plugins/builtin/options.tsx +9 -17
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
- package/src/plugins/builtin/portfolio-list/metrics.ts +11 -0
- package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +117 -18
- package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
- package/src/plugins/builtin/portfolio-list.test.tsx +282 -2
- package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
- package/src/plugins/builtin/sectors/index.tsx +252 -41
- package/src/plugins/builtin/sectors/sector-data.ts +41 -1
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
- package/src/plugins/builtin/ticker-detail/pane.tsx +0 -6
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +442 -63
- package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
- package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
- package/src/plugins/ibkr/trading-pane.tsx +3 -2
- package/src/plugins/plugin-runtime.tsx +2 -2
- package/src/plugins/prediction-markets/settings.ts +4 -0
- package/src/plugins/registry.test.ts +55 -0
- package/src/plugins/registry.ts +11 -4
- package/src/plugins/ticker-navigation.test.ts +35 -5
- package/src/plugins/ticker-navigation.ts +16 -22
- package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
- package/src/renderers/electrobun/bun/application-menu.ts +6 -1
- package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
- package/src/renderers/electrobun/bun/index.ts +52 -13
- package/src/renderers/electrobun/view/app-services.ts +51 -9
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
- package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
- package/src/renderers/electrobun/view/main.tsx +0 -16
- package/src/renderers/electrobun/view/styles.css +23 -21
- package/src/renderers/electrobun/view/ui-host.tsx +6 -4
- package/src/renderers/opentui/host.tsx +2 -1
- package/src/renderers/opentui/start.tsx +1 -1
- package/src/renderers/opentui/test-utils.tsx +6 -5
- package/src/state/app-bootstrap.ts +3 -3
- package/src/state/app-context.test.ts +113 -0
- package/src/state/app-context.tsx +8 -4
- package/src/theme/colors.ts +13 -14
- package/src/types/config.ts +9 -4
- package/src/types/plugin.ts +8 -1
- package/src/ui/context-menu.tsx +4 -4
- package/src/utils/api-client.test.ts +128 -0
- package/src/utils/api-client.ts +112 -7
- package/src/utils/fuzzy-search.ts +12 -1
- package/src/utils/keyboard.ts +31 -0
- package/src/utils/ticker-search.test.ts +2 -2
- package/src/utils/ticker-search.ts +48 -1
- package/src/version.ts +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { act, type ReactElement } from "react";
|
|
2
|
+
import { act, useState, type ReactElement } from "react";
|
|
3
3
|
import { createTestRenderer } from "@opentui/core/testing";
|
|
4
4
|
import { createOpenTuiTestRoot as createRoot } from "../../renderers/opentui/test-utils";
|
|
5
5
|
import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../market-data/coordinator";
|
|
@@ -301,7 +301,6 @@ describe("comparisonChartPlugin", () => {
|
|
|
301
301
|
expect(frame).toContain("1:1D");
|
|
302
302
|
expect(frame).toContain("2:1W");
|
|
303
303
|
expect(frame).toContain("1D");
|
|
304
|
-
expect(frame).toContain("view:");
|
|
305
304
|
expect(frame).toContain("[t]ickers");
|
|
306
305
|
expect(frame).toContain("[m]ode");
|
|
307
306
|
expect(frame).toContain("[r]es");
|
|
@@ -312,6 +311,119 @@ describe("comparisonChartPlugin", () => {
|
|
|
312
311
|
expect(frame).not.toContain("side by side");
|
|
313
312
|
});
|
|
314
313
|
|
|
314
|
+
test("updates every legend value from the crosshair position", async () => {
|
|
315
|
+
const provider = createProvider({
|
|
316
|
+
AAPL: [100, 102, 104],
|
|
317
|
+
MSFT: [200, 202, 204],
|
|
318
|
+
}, {
|
|
319
|
+
AAPL: "USD",
|
|
320
|
+
MSFT: "USD",
|
|
321
|
+
});
|
|
322
|
+
setSharedMarketDataForTests(provider);
|
|
323
|
+
sharedCoordinator = new MarketDataCoordinator(provider);
|
|
324
|
+
setSharedMarketDataCoordinator(sharedCoordinator);
|
|
325
|
+
setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
|
|
326
|
+
|
|
327
|
+
await mountComparisonHarness({
|
|
328
|
+
axisMode: "percent",
|
|
329
|
+
symbols: ["AAPL", "MSFT"],
|
|
330
|
+
symbolsText: "AAPL, MSFT",
|
|
331
|
+
}, [
|
|
332
|
+
makeTicker("AAPL", "USD"),
|
|
333
|
+
makeTicker("MSFT", "USD"),
|
|
334
|
+
], [
|
|
335
|
+
["AAPL", makeFinancials("AAPL", "USD", [100, 102, 104])],
|
|
336
|
+
["MSFT", makeFinancials("MSFT", "USD", [200, 202, 204])],
|
|
337
|
+
]);
|
|
338
|
+
|
|
339
|
+
await flushFrames();
|
|
340
|
+
|
|
341
|
+
let frame = testSetup!.captureCharFrame();
|
|
342
|
+
expect(frame).not.toContain("AAPL - 1D");
|
|
343
|
+
expect(frame).toContain("AAPL $104.00 +4.00%");
|
|
344
|
+
expect(frame).toContain("MSFT $204.00 +2.00%");
|
|
345
|
+
|
|
346
|
+
await act(async () => {
|
|
347
|
+
await testSetup!.mockMouse.moveTo(2, 4);
|
|
348
|
+
await testSetup!.renderOnce();
|
|
349
|
+
});
|
|
350
|
+
await flushFrames();
|
|
351
|
+
|
|
352
|
+
frame = testSetup!.captureCharFrame();
|
|
353
|
+
expect(frame).toContain("AAPL $100.00 0.00%");
|
|
354
|
+
expect(frame).toContain("MSFT $200.00 0.00%");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("does not refetch resolution support when only comparison prices update", async () => {
|
|
358
|
+
let supportCalls = 0;
|
|
359
|
+
const provider = createProvider({
|
|
360
|
+
AAPL: [100, 102, 104],
|
|
361
|
+
MSFT: [200, 202, 204],
|
|
362
|
+
}, {
|
|
363
|
+
AAPL: "USD",
|
|
364
|
+
MSFT: "USD",
|
|
365
|
+
});
|
|
366
|
+
provider.getChartResolutionCapabilities = async () => {
|
|
367
|
+
supportCalls += 1;
|
|
368
|
+
return ["5m", "15m", "1h", "1d", "1wk", "1mo"];
|
|
369
|
+
};
|
|
370
|
+
setSharedMarketDataForTests(provider);
|
|
371
|
+
sharedCoordinator = new MarketDataCoordinator(provider);
|
|
372
|
+
setSharedMarketDataCoordinator(sharedCoordinator);
|
|
373
|
+
setSharedRegistryForTests(createRegistrySpy({ selected: [], focused: [] }));
|
|
374
|
+
|
|
375
|
+
const settings = {
|
|
376
|
+
axisMode: "percent",
|
|
377
|
+
symbols: ["AAPL", "MSFT"],
|
|
378
|
+
symbolsText: "AAPL, MSFT",
|
|
379
|
+
};
|
|
380
|
+
const tickers = [
|
|
381
|
+
makeTicker("AAPL", "USD"),
|
|
382
|
+
makeTicker("MSFT", "USD"),
|
|
383
|
+
];
|
|
384
|
+
const spy = { selected: [] as string[], focused: [] as string[] };
|
|
385
|
+
const runtime = createRuntimeSpy(spy);
|
|
386
|
+
let updateFinancials: ((rows: Array<[string, TickerFinancials]>) => void) | null = null;
|
|
387
|
+
function PriceUpdateHarness({ initialFinancials }: { initialFinancials: Array<[string, TickerFinancials]> }) {
|
|
388
|
+
const [rows, setRows] = useState(initialFinancials);
|
|
389
|
+
updateFinancials = setRows;
|
|
390
|
+
return createComparisonHarness(settings, tickers, rows, runtime);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
|
394
|
+
testSetup = await createTestRenderer({ width: 120, height: 20 });
|
|
395
|
+
root = createRoot(testSetup.renderer);
|
|
396
|
+
await act(async () => {
|
|
397
|
+
root!.render(
|
|
398
|
+
<PriceUpdateHarness
|
|
399
|
+
initialFinancials={[
|
|
400
|
+
["AAPL", makeFinancials("AAPL", "USD", [100, 102, 104])],
|
|
401
|
+
["MSFT", makeFinancials("MSFT", "USD", [200, 202, 204])],
|
|
402
|
+
]}
|
|
403
|
+
/>,
|
|
404
|
+
);
|
|
405
|
+
await Promise.resolve();
|
|
406
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
407
|
+
await testSetup!.renderOnce();
|
|
408
|
+
});
|
|
409
|
+
await flushFrames();
|
|
410
|
+
|
|
411
|
+
expect(supportCalls).toBe(2);
|
|
412
|
+
|
|
413
|
+
await act(async () => {
|
|
414
|
+
updateFinancials!([
|
|
415
|
+
["AAPL", makeFinancials("AAPL", "USD", [101, 103, 105])],
|
|
416
|
+
["MSFT", makeFinancials("MSFT", "USD", [201, 203, 205])],
|
|
417
|
+
]);
|
|
418
|
+
await Promise.resolve();
|
|
419
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
420
|
+
await testSetup!.renderOnce();
|
|
421
|
+
});
|
|
422
|
+
await flushFrames();
|
|
423
|
+
|
|
424
|
+
expect(supportCalls).toBe(2);
|
|
425
|
+
});
|
|
426
|
+
|
|
315
427
|
test("keeps controls visible when comparison history is empty", async () => {
|
|
316
428
|
const provider = createProvider({
|
|
317
429
|
AAPL: [],
|
|
@@ -346,7 +458,7 @@ describe("comparisonChartPlugin", () => {
|
|
|
346
458
|
expect(frame).toContain("AUTO");
|
|
347
459
|
});
|
|
348
460
|
|
|
349
|
-
test("moves legend selection with
|
|
461
|
+
test("moves legend selection with arrow and h/l keys, then opens the selected ticker on Enter", async () => {
|
|
350
462
|
const spy = { selected: [] as string[], focused: [] as string[] };
|
|
351
463
|
const provider = createProvider({
|
|
352
464
|
AAPL: [100, 102, 104],
|
|
@@ -378,6 +490,8 @@ describe("comparisonChartPlugin", () => {
|
|
|
378
490
|
|
|
379
491
|
await flushFrames();
|
|
380
492
|
await pressComparisonInput(() => testSetup!.mockInput.pressArrow("right"));
|
|
493
|
+
await pressComparisonInput(() => testSetup!.mockInput.pressKey("l"));
|
|
494
|
+
await pressComparisonInput(() => testSetup!.mockInput.pressKey("h"));
|
|
381
495
|
await pressComparisonInput(() => testSetup!.mockInput.pressEnter());
|
|
382
496
|
|
|
383
497
|
expect(spy.selected).toEqual(["MSFT"]);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { applyTheme } from "../../../theme/colors";
|
|
3
|
+
import { contrastRatio } from "../../../theme/color-utils";
|
|
4
|
+
import { DEFAULT_THEME, themes } from "../../../theme/themes";
|
|
5
|
+
import { resolveCorrelationHeatmapCellColors } from "./colors";
|
|
6
|
+
|
|
7
|
+
const CORRELATION_VALUES = [-1, -0.75, -0.5, -0.25, 0, 0.13, 0.24, 0.39, 0.49, 0.75, 1] as const;
|
|
8
|
+
const CELL_TEXT_MIN_CONTRAST = 4.5;
|
|
9
|
+
const MUTED_TEXT_MIN_CONTRAST = 3.6;
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
applyTheme(DEFAULT_THEME);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("correlation heatmap colors", () => {
|
|
16
|
+
test("keeps populated cells readable in every theme", () => {
|
|
17
|
+
for (const themeId of Object.keys(themes)) {
|
|
18
|
+
applyTheme(themeId);
|
|
19
|
+
for (const correlation of CORRELATION_VALUES) {
|
|
20
|
+
const cell = resolveCorrelationHeatmapCellColors(correlation);
|
|
21
|
+
expect(
|
|
22
|
+
contrastRatio(cell.foreground, cell.background),
|
|
23
|
+
`${themeId} correlation ${correlation}`,
|
|
24
|
+
).toBeGreaterThanOrEqual(CELL_TEXT_MIN_CONTRAST);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("keeps empty cells readable in every theme", () => {
|
|
30
|
+
for (const themeId of Object.keys(themes)) {
|
|
31
|
+
applyTheme(themeId);
|
|
32
|
+
const cell = resolveCorrelationHeatmapCellColors(null);
|
|
33
|
+
expect(
|
|
34
|
+
contrastRatio(cell.foreground, cell.background),
|
|
35
|
+
`${themeId} empty cell`,
|
|
36
|
+
).toBeGreaterThanOrEqual(MUTED_TEXT_MIN_CONTRAST);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { colors } from "../../../theme/colors";
|
|
2
|
+
import { blendHex, contrastRatio } from "../../../theme/color-utils";
|
|
3
|
+
|
|
4
|
+
const HEATMAP_BASE_BLEND = 0.35;
|
|
5
|
+
const HEATMAP_TEXT_MIN_CONTRAST = 4.5;
|
|
6
|
+
const HEATMAP_MUTED_MIN_CONTRAST = 3.6;
|
|
7
|
+
const HEATMAP_TINT_STEPS = [0.56, 0.5, 0.44, 0.38, 0.32, 0.28, 0.24, 0.2, 0.16, 0.12] as const;
|
|
8
|
+
|
|
9
|
+
export interface CorrelationHeatmapCellColors {
|
|
10
|
+
background: string;
|
|
11
|
+
foreground: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function highestContrast(candidates: readonly string[], background: string): string {
|
|
15
|
+
return candidates.reduce((best, candidate) => (
|
|
16
|
+
contrastRatio(candidate, background) > contrastRatio(best, background) ? candidate : best
|
|
17
|
+
));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function textCandidates(): string[] {
|
|
21
|
+
return [
|
|
22
|
+
colors.text,
|
|
23
|
+
colors.textDim,
|
|
24
|
+
colors.textBright,
|
|
25
|
+
colors.textMuted,
|
|
26
|
+
colors.selectedText,
|
|
27
|
+
colors.headerText,
|
|
28
|
+
colors.bg,
|
|
29
|
+
colors.panel,
|
|
30
|
+
colors.header,
|
|
31
|
+
colors.commandBg,
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function heatmapBaseBackground(): string {
|
|
36
|
+
return blendHex(colors.panel, colors.bg, HEATMAP_BASE_BLEND);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function heatmapSemanticColor(correlation: number): string {
|
|
40
|
+
const clamped = Math.max(-1, Math.min(1, correlation));
|
|
41
|
+
return clamped < 0
|
|
42
|
+
? blendHex(colors.negative, colors.warning, clamped + 1)
|
|
43
|
+
: blendHex(colors.warning, colors.positive, clamped);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function heatmapTintSteps(targetStrength: number): number[] {
|
|
47
|
+
const steps = HEATMAP_TINT_STEPS.filter((step) => step <= targetStrength);
|
|
48
|
+
return steps[0] === targetStrength ? steps : [targetStrength, ...steps];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readableForeground(background: string): string {
|
|
52
|
+
return highestContrast(textCandidates(), background);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolveCorrelationHeatmapCellColors(correlation: number | null): CorrelationHeatmapCellColors {
|
|
56
|
+
const baseBackground = heatmapBaseBackground();
|
|
57
|
+
|
|
58
|
+
if (correlation === null) {
|
|
59
|
+
const muted = colors.textMuted;
|
|
60
|
+
return {
|
|
61
|
+
background: baseBackground,
|
|
62
|
+
foreground: contrastRatio(muted, baseBackground) >= HEATMAP_MUTED_MIN_CONTRAST
|
|
63
|
+
? muted
|
|
64
|
+
: readableForeground(baseBackground),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const clamped = Math.max(-1, Math.min(1, correlation));
|
|
69
|
+
const semanticColor = heatmapSemanticColor(clamped);
|
|
70
|
+
const targetStrength = 0.3 + Math.abs(clamped) * 0.26;
|
|
71
|
+
let fallback: CorrelationHeatmapCellColors | null = null;
|
|
72
|
+
|
|
73
|
+
for (const tintStrength of heatmapTintSteps(targetStrength)) {
|
|
74
|
+
const background = blendHex(baseBackground, semanticColor, tintStrength);
|
|
75
|
+
const foreground = readableForeground(background);
|
|
76
|
+
const cellColors = { background, foreground };
|
|
77
|
+
fallback = cellColors;
|
|
78
|
+
if (contrastRatio(foreground, background) >= HEATMAP_TEXT_MIN_CONTRAST) {
|
|
79
|
+
return cellColors;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return fallback ?? {
|
|
84
|
+
background: baseBackground,
|
|
85
|
+
foreground: readableForeground(baseBackground),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { act, type ReactElement } from "react";
|
|
3
|
+
import { Box } from "../../../ui";
|
|
4
|
+
import { testRender } from "../../../renderers/opentui/test-utils";
|
|
5
|
+
import {
|
|
6
|
+
AppContext,
|
|
7
|
+
createInitialState,
|
|
8
|
+
PaneInstanceProvider,
|
|
9
|
+
} from "../../../state/app-context";
|
|
10
|
+
import { cloneLayout, createDefaultConfig } from "../../../types/config";
|
|
11
|
+
import type { TickerRecord } from "../../../types/ticker";
|
|
12
|
+
import { createTestPluginRuntime } from "../../../test-support/plugin-runtime";
|
|
13
|
+
import { PluginRenderProvider, type PluginRuntimeAccess } from "../../plugin-runtime";
|
|
14
|
+
import { correlationPlugin } from ".";
|
|
15
|
+
|
|
16
|
+
const TEST_PANE_ID = "correlation:test";
|
|
17
|
+
|
|
18
|
+
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
19
|
+
|
|
20
|
+
function makeTicker(symbol: string): TickerRecord {
|
|
21
|
+
return {
|
|
22
|
+
metadata: {
|
|
23
|
+
ticker: symbol,
|
|
24
|
+
exchange: "NASDAQ",
|
|
25
|
+
currency: "USD",
|
|
26
|
+
name: symbol,
|
|
27
|
+
portfolios: [],
|
|
28
|
+
watchlists: [],
|
|
29
|
+
positions: [],
|
|
30
|
+
broker_contracts: [],
|
|
31
|
+
custom: {},
|
|
32
|
+
tags: [],
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function CorrelationHarness({ runtime }: { runtime: PluginRuntimeAccess }) {
|
|
38
|
+
const layout = {
|
|
39
|
+
dockRoot: { kind: "pane" as const, instanceId: TEST_PANE_ID },
|
|
40
|
+
instances: [{
|
|
41
|
+
instanceId: TEST_PANE_ID,
|
|
42
|
+
paneId: "correlation",
|
|
43
|
+
settings: {
|
|
44
|
+
rangePreset: "1Y",
|
|
45
|
+
symbols: ["AAPL", "MSFT"],
|
|
46
|
+
symbolsText: "AAPL, MSFT",
|
|
47
|
+
},
|
|
48
|
+
}],
|
|
49
|
+
floating: [],
|
|
50
|
+
detached: [],
|
|
51
|
+
};
|
|
52
|
+
const config = {
|
|
53
|
+
...createDefaultConfig("/tmp/gloomberb-correlation-test"),
|
|
54
|
+
layout,
|
|
55
|
+
layouts: [{ name: "Default", layout: cloneLayout(layout) }],
|
|
56
|
+
};
|
|
57
|
+
const state = createInitialState(config);
|
|
58
|
+
state.focusedPaneId = TEST_PANE_ID;
|
|
59
|
+
state.tickers = new Map([
|
|
60
|
+
["AAPL", makeTicker("AAPL")],
|
|
61
|
+
["MSFT", makeTicker("MSFT")],
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const CorrelationPane = correlationPlugin.panes?.[0]?.component as (props: {
|
|
65
|
+
paneId: string;
|
|
66
|
+
paneType: string;
|
|
67
|
+
focused: boolean;
|
|
68
|
+
width: number;
|
|
69
|
+
height: number;
|
|
70
|
+
}) => ReactElement;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<AppContext value={{ state, dispatch: () => {} }}>
|
|
74
|
+
<PaneInstanceProvider paneId={TEST_PANE_ID}>
|
|
75
|
+
<PluginRenderProvider pluginId="correlation" runtime={runtime}>
|
|
76
|
+
<Box width={60} height={8}>
|
|
77
|
+
<CorrelationPane
|
|
78
|
+
paneId={TEST_PANE_ID}
|
|
79
|
+
paneType="correlation"
|
|
80
|
+
focused
|
|
81
|
+
width={60}
|
|
82
|
+
height={8}
|
|
83
|
+
/>
|
|
84
|
+
</Box>
|
|
85
|
+
</PluginRenderProvider>
|
|
86
|
+
</PaneInstanceProvider>
|
|
87
|
+
</AppContext>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
if (testSetup) {
|
|
93
|
+
await act(async () => {
|
|
94
|
+
testSetup!.renderer.destroy();
|
|
95
|
+
await Promise.resolve();
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
testSetup = undefined;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("correlationPlugin", () => {
|
|
102
|
+
test("opens tickers from row and column labels", async () => {
|
|
103
|
+
const opened: Array<{ symbol: string; options: { floating?: boolean; paneType?: string } | undefined }> = [];
|
|
104
|
+
const runtime = createTestPluginRuntime({
|
|
105
|
+
pinTicker: (symbol, options) => opened.push({ symbol, options }),
|
|
106
|
+
navigateTicker: () => {
|
|
107
|
+
throw new Error("known correlation tickers should open directly");
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await act(async () => {
|
|
112
|
+
testSetup = await testRender(<CorrelationHarness runtime={runtime} />, {
|
|
113
|
+
width: 60,
|
|
114
|
+
height: 8,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
await act(async () => {
|
|
118
|
+
await testSetup!.renderOnce();
|
|
119
|
+
await Promise.resolve();
|
|
120
|
+
await testSetup!.renderOnce();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const lines = testSetup!.captureCharFrame().split("\n");
|
|
124
|
+
const headerY = lines.findIndex((line) => line.includes("AAPL") && line.includes("MSFT"));
|
|
125
|
+
const headerCol = lines[headerY]?.indexOf("AAPL") ?? -1;
|
|
126
|
+
expect(headerY).toBeGreaterThanOrEqual(0);
|
|
127
|
+
expect(headerCol).toBeGreaterThanOrEqual(0);
|
|
128
|
+
|
|
129
|
+
await act(async () => {
|
|
130
|
+
await testSetup!.mockMouse.click(headerCol + 1, headerY);
|
|
131
|
+
await Promise.resolve();
|
|
132
|
+
await testSetup!.renderOnce();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const rowY = lines.findIndex((line, index) => index > headerY && line.includes("MSFT"));
|
|
136
|
+
const rowCol = lines[rowY]?.indexOf("MSFT") ?? -1;
|
|
137
|
+
expect(rowY).toBeGreaterThanOrEqual(0);
|
|
138
|
+
expect(rowCol).toBeGreaterThanOrEqual(0);
|
|
139
|
+
|
|
140
|
+
await act(async () => {
|
|
141
|
+
await testSetup!.mockMouse.click(rowCol + 1, rowY);
|
|
142
|
+
await Promise.resolve();
|
|
143
|
+
await testSetup!.renderOnce();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(opened).toEqual([
|
|
147
|
+
{ symbol: "AAPL", options: { floating: true, paneType: "ticker-detail" } },
|
|
148
|
+
{ symbol: "MSFT", options: { floating: true, paneType: "ticker-detail" } },
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Box, ScrollBox, Text } from "../../../ui";
|
|
2
|
-
import { useMemo } from "react";
|
|
2
|
+
import { useCallback, useMemo, useState } from "react";
|
|
3
3
|
import { TextAttributes } from "../../../ui";
|
|
4
4
|
import { usePaneFooter } from "../../../components";
|
|
5
5
|
import type { GloomPlugin, PaneProps } from "../../../types/plugin";
|
|
6
|
-
import { colors } from "../../../theme/colors";
|
|
6
|
+
import { colors, hoverBg } from "../../../theme/colors";
|
|
7
7
|
import { usePluginTickerActions } from "../../plugin-runtime";
|
|
8
8
|
import { useAppSelector, usePaneInstance } from "../../../state/app-context";
|
|
9
9
|
import { useChartQueries } from "../../../market-data/hooks";
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
computeDatedReturns,
|
|
16
16
|
correlateDatedReturns,
|
|
17
17
|
formatCorrelation,
|
|
18
|
-
correlationColor,
|
|
19
18
|
type CorrelationResult,
|
|
20
19
|
type DatedReturn,
|
|
21
20
|
} from "./compute";
|
|
@@ -26,6 +25,7 @@ import {
|
|
|
26
25
|
getCorrelationPaneSettings,
|
|
27
26
|
type CorrelationRangePreset,
|
|
28
27
|
} from "./settings";
|
|
28
|
+
import { resolveCorrelationHeatmapCellColors } from "./colors";
|
|
29
29
|
|
|
30
30
|
const ROW_HEADER_WIDTH = 7;
|
|
31
31
|
const MATRIX_CELL_WIDTH = 10;
|
|
@@ -142,10 +142,63 @@ function buildCorrelationPaneTitle(symbols: string[], rangePreset: CorrelationRa
|
|
|
142
142
|
return `${symbols.slice(0, 2).join(" · ")} +${symbols.length - 2} ${rangePreset}`;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
function handleSymbolMouseDown(event: {
|
|
146
|
+
preventDefault?: () => void;
|
|
147
|
+
stopPropagation?: () => void;
|
|
148
|
+
}, openSymbol: () => void): void {
|
|
149
|
+
event.preventDefault?.();
|
|
150
|
+
event.stopPropagation?.();
|
|
151
|
+
openSymbol();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function SymbolLabelCell({
|
|
155
|
+
symbol,
|
|
156
|
+
width,
|
|
157
|
+
color,
|
|
158
|
+
align = "flex-start",
|
|
159
|
+
hovered,
|
|
160
|
+
onHover,
|
|
161
|
+
onLeave,
|
|
162
|
+
onOpen,
|
|
163
|
+
}: {
|
|
164
|
+
symbol: string;
|
|
165
|
+
width: number;
|
|
166
|
+
color: string;
|
|
167
|
+
align?: "flex-start" | "flex-end";
|
|
168
|
+
hovered: boolean;
|
|
169
|
+
onHover: (symbol: string) => void;
|
|
170
|
+
onLeave: (symbol: string) => void;
|
|
171
|
+
onOpen: (symbol: string) => void;
|
|
172
|
+
}) {
|
|
173
|
+
const openSymbol = useCallback(() => onOpen(symbol), [onOpen, symbol]);
|
|
174
|
+
return (
|
|
175
|
+
<Box
|
|
176
|
+
width={width}
|
|
177
|
+
flexShrink={0}
|
|
178
|
+
justifyContent={align}
|
|
179
|
+
paddingRight={align === "flex-end" ? 1 : undefined}
|
|
180
|
+
overflow="hidden"
|
|
181
|
+
backgroundColor={hovered ? hoverBg() : undefined}
|
|
182
|
+
style={{ cursor: "pointer" }}
|
|
183
|
+
onMouseMove={() => onHover(symbol)}
|
|
184
|
+
onMouseOut={() => onLeave(symbol)}
|
|
185
|
+
onMouseDown={(event: any) => handleSymbolMouseDown(event, openSymbol)}
|
|
186
|
+
>
|
|
187
|
+
<Text
|
|
188
|
+
fg={hovered ? colors.textBright : color}
|
|
189
|
+
attributes={TextAttributes.BOLD | TextAttributes.UNDERLINE}
|
|
190
|
+
>
|
|
191
|
+
{displaySymbol(symbol)}
|
|
192
|
+
</Text>
|
|
193
|
+
</Box>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
145
197
|
function CorrelationMatrixPane({ width, height }: PaneProps) {
|
|
146
198
|
const pane = usePaneInstance();
|
|
147
|
-
const { navigateTicker } = usePluginTickerActions();
|
|
199
|
+
const { navigateTicker, pinTicker } = usePluginTickerActions();
|
|
148
200
|
const tickers = useAppSelector((state) => state.tickers);
|
|
201
|
+
const [hoveredSymbol, setHoveredSymbol] = useState<string | null>(null);
|
|
149
202
|
const settings = useMemo(() => getCorrelationPaneSettings(pane?.settings), [pane?.settings]);
|
|
150
203
|
|
|
151
204
|
const instruments = useMemo(() => {
|
|
@@ -234,6 +287,18 @@ function CorrelationMatrixPane({ width, height }: PaneProps) {
|
|
|
234
287
|
],
|
|
235
288
|
}), [settings.rangePreset, settings.symbolsError, statusSummary, symbols.length]);
|
|
236
289
|
|
|
290
|
+
const openSymbol = useCallback((symbol: string) => {
|
|
291
|
+
if (tickers.has(symbol)) {
|
|
292
|
+
pinTicker(symbol, { floating: true, paneType: "ticker-detail" });
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
navigateTicker(symbol);
|
|
296
|
+
}, [navigateTicker, pinTicker, tickers]);
|
|
297
|
+
|
|
298
|
+
const clearHoveredSymbol = useCallback((symbol: string) => {
|
|
299
|
+
setHoveredSymbol((current) => (current === symbol ? null : current));
|
|
300
|
+
}, []);
|
|
301
|
+
|
|
237
302
|
if (settings.symbolsError) {
|
|
238
303
|
return (
|
|
239
304
|
<Box flexDirection="column" width={width} height={height} paddingX={2} paddingY={1}>
|
|
@@ -265,11 +330,17 @@ function CorrelationMatrixPane({ width, height }: PaneProps) {
|
|
|
265
330
|
<Box flexDirection="row" paddingX={1} height={1} backgroundColor={headerBg}>
|
|
266
331
|
<Box width={rowHeaderWidth} flexShrink={0} />
|
|
267
332
|
{symbols.map((sym) => (
|
|
268
|
-
<
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
333
|
+
<SymbolLabelCell
|
|
334
|
+
key={sym}
|
|
335
|
+
symbol={sym}
|
|
336
|
+
width={cellWidth}
|
|
337
|
+
align="flex-end"
|
|
338
|
+
color={colors.textDim}
|
|
339
|
+
hovered={hoveredSymbol === sym}
|
|
340
|
+
onHover={setHoveredSymbol}
|
|
341
|
+
onLeave={clearHoveredSymbol}
|
|
342
|
+
onOpen={openSymbol}
|
|
343
|
+
/>
|
|
273
344
|
))}
|
|
274
345
|
</Box>
|
|
275
346
|
|
|
@@ -283,14 +354,16 @@ function CorrelationMatrixPane({ width, height }: PaneProps) {
|
|
|
283
354
|
width={rowHeaderWidth}
|
|
284
355
|
flexShrink={0}
|
|
285
356
|
overflow="hidden"
|
|
286
|
-
onMouseDown={() => navigateTicker(rowSym)}
|
|
287
357
|
>
|
|
288
|
-
<
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
{
|
|
293
|
-
|
|
358
|
+
<SymbolLabelCell
|
|
359
|
+
symbol={rowSym}
|
|
360
|
+
width={rowHeaderWidth}
|
|
361
|
+
color={rowHeaderColor(seriesBySymbol.get(rowSym)?.status ?? "loading")}
|
|
362
|
+
hovered={hoveredSymbol === rowSym}
|
|
363
|
+
onHover={setHoveredSymbol}
|
|
364
|
+
onLeave={clearHoveredSymbol}
|
|
365
|
+
onOpen={openSymbol}
|
|
366
|
+
/>
|
|
294
367
|
</Box>
|
|
295
368
|
{/* Cells */}
|
|
296
369
|
{symbols.map((colSym) => {
|
|
@@ -301,9 +374,7 @@ function CorrelationMatrixPane({ width, height }: PaneProps) {
|
|
|
301
374
|
} else {
|
|
302
375
|
r = matrix.results.get(pairKey(rowSym, colSym))?.correlation ?? null;
|
|
303
376
|
}
|
|
304
|
-
const
|
|
305
|
-
? colors.textDim
|
|
306
|
-
: correlationColor(r, colors.positive, colors.negative, colors.textMuted);
|
|
377
|
+
const cellColors = resolveCorrelationHeatmapCellColors(r);
|
|
307
378
|
const text = isDiag ? " 1.00" : formatCorrelation(r);
|
|
308
379
|
return (
|
|
309
380
|
<Box
|
|
@@ -311,9 +382,9 @@ function CorrelationMatrixPane({ width, height }: PaneProps) {
|
|
|
311
382
|
width={cellWidth}
|
|
312
383
|
justifyContent="flex-end"
|
|
313
384
|
paddingRight={1}
|
|
314
|
-
backgroundColor={
|
|
385
|
+
backgroundColor={cellColors.background}
|
|
315
386
|
>
|
|
316
|
-
<Text fg={
|
|
387
|
+
<Text fg={cellColors.foreground}>{text}</Text>
|
|
317
388
|
</Box>
|
|
318
389
|
);
|
|
319
390
|
})}
|
|
@@ -7,6 +7,7 @@ import { usePaneFooter } from "../../components";
|
|
|
7
7
|
import { usePluginAppActions } from "../plugin-runtime";
|
|
8
8
|
import { colors, hoverBg } from "../../theme/colors";
|
|
9
9
|
import { debugLog, type LogEntry, type LogLevel } from "../../utils/debug-log";
|
|
10
|
+
import { isPlainKey } from "../../utils/keyboard";
|
|
10
11
|
import { wrapTextLines } from "../../utils/text-wrap";
|
|
11
12
|
import { writeFileSync } from "fs";
|
|
12
13
|
import { join } from "path";
|
|
@@ -37,7 +38,7 @@ function levelColor(level: LogLevel): string {
|
|
|
37
38
|
switch (level) {
|
|
38
39
|
case "debug": return colors.textDim;
|
|
39
40
|
case "info": return colors.positive;
|
|
40
|
-
case "warn": return
|
|
41
|
+
case "warn": return colors.warning;
|
|
41
42
|
case "error": return colors.negative;
|
|
42
43
|
}
|
|
43
44
|
}
|
|
@@ -154,12 +155,12 @@ function DebugPane({ focused, width, height }: PaneProps) {
|
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
// Navigation
|
|
157
|
-
if (event
|
|
158
|
+
if (isPlainKey(event, "j", "down")) {
|
|
158
159
|
setAutoScroll(false);
|
|
159
160
|
setSelectedIdx((prev) => Math.min(prev + 1, entries.length - 1));
|
|
160
161
|
return;
|
|
161
162
|
}
|
|
162
|
-
if (event
|
|
163
|
+
if (isPlainKey(event, "k", "up")) {
|
|
163
164
|
setAutoScroll(false);
|
|
164
165
|
setSelectedIdx((prev) => Math.max(prev - 1, 0));
|
|
165
166
|
return;
|
|
@@ -12,6 +12,7 @@ import { resolveFredMapping } from "./fred-series-map";
|
|
|
12
12
|
import { resolveChartPalette } from "../../../components/chart/chart-renderer";
|
|
13
13
|
import type { ProjectedChartPoint } from "../../../components/chart/chart-data";
|
|
14
14
|
import { apiClient, type CloudFredObservationPayload, type CloudFredSeriesInfoPayload } from "../../../utils/api-client";
|
|
15
|
+
import { isPlainKey } from "../../../utils/keyboard";
|
|
15
16
|
|
|
16
17
|
const CACHE_TTL_MS = 15 * 60 * 1000;
|
|
17
18
|
|
|
@@ -202,11 +203,11 @@ function EconDetailView({ event, width, height, focused }: EconDetailViewProps)
|
|
|
202
203
|
|
|
203
204
|
useShortcut((ev) => {
|
|
204
205
|
if (!focused) return;
|
|
205
|
-
if (ev
|
|
206
|
+
if (isPlainKey(ev, "j", "down")) {
|
|
206
207
|
ev.stopPropagation?.();
|
|
207
208
|
ev.preventDefault?.();
|
|
208
209
|
scrollDetailBy(1);
|
|
209
|
-
} else if (ev
|
|
210
|
+
} else if (isPlainKey(ev, "k", "up")) {
|
|
210
211
|
ev.stopPropagation?.();
|
|
211
212
|
ev.preventDefault?.();
|
|
212
213
|
scrollDetailBy(-1);
|
|
@@ -81,7 +81,7 @@ describe("HelpPane", () => {
|
|
|
81
81
|
paneId: "ticker-detail",
|
|
82
82
|
label: "Quote Monitor",
|
|
83
83
|
description: "Open ticker detail",
|
|
84
|
-
shortcut: { prefix: "QQ", argPlaceholder: "ticker" },
|
|
84
|
+
shortcut: { prefix: "QQ", argPlaceholder: "tickers", argKind: "ticker-list" },
|
|
85
85
|
},
|
|
86
86
|
]]),
|
|
87
87
|
shortcuts: new Map([[
|
|
@@ -110,7 +110,7 @@ describe("HelpPane", () => {
|
|
|
110
110
|
const frame = testSetup!.captureCharFrame();
|
|
111
111
|
expect(frame).toMatch(/AW\s+<ticker>/);
|
|
112
112
|
expect(frame).toMatch(/SA\s+<symbol condition price>/);
|
|
113
|
-
expect(frame).toMatch(/QQ\s+<
|
|
113
|
+
expect(frame).toMatch(/QQ\s+<tickers>/);
|
|
114
114
|
expect(frame).toContain("Add Alert (Alerts)");
|
|
115
115
|
expect(frame).toContain("Shift+C");
|
|
116
116
|
expect(frame).toContain("Toggle chat (Gloomberb Cloud)");
|