gloomberb 0.17.1 → 0.17.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/package.json +1 -1
- package/src/{plugins/builtin/cloud → account}/auth-dialog.tsx +7 -7
- package/src/api-client/earnings.ts +4 -2
- package/src/app/global-shortcuts.ts +10 -0
- package/src/app/keybindings/actions.ts +16 -0
- package/src/app/runtime/desktop-deeplink.ts +1 -1
- package/src/app.tsx +1 -1
- package/src/architecture/core-builtin-allowlist.json +4 -9
- package/src/cli/commands/market.ts +34 -16
- package/src/cli/commands/options-chain.ts +29 -33
- package/src/cli/commands/options-leaps.ts +462 -63
- package/src/cli/commands/search.ts +10 -7
- package/src/cli/commands/ticker.ts +1 -1
- package/src/cli/known-gaps.ts +22 -0
- package/src/cli/pane-functions/catalog.ts +6 -3
- package/src/cli/pane-functions/dom.ts +17 -2
- package/src/cli/pane-functions/function-help.ts +1 -1
- package/src/cli/pane-functions/index.ts +5 -3
- package/src/cli/pane-functions/report-notices.ts +15 -0
- package/src/cli/symbol-args.ts +19 -0
- package/src/components/command-bar/workflow/builtin.ts +2 -2
- package/src/components/command-bar/workflow/collection-actions.ts +1 -1
- package/src/components/feedback-dialog.tsx +1 -1
- package/src/components/layout/shell/index.tsx +1 -1
- package/src/components/layout/status-bar-groups.ts +1 -1
- package/src/components/layout/status-bar.tsx +2 -2
- package/src/components/markdown-text.tsx +14 -2
- package/src/components/reader/article-reader.tsx +1 -1
- package/src/components/reader/detail-reader.tsx +10 -7
- package/src/components/reader/reader-text.tsx +32 -17
- package/src/layout-marketplace/gallery.tsx +2 -2
- package/src/layout-marketplace/linked-sync.ts +1 -1
- package/src/market-data/market/freshness.ts +3 -0
- package/src/market-data/published-apac-sessions.ts +12 -2
- package/src/plugins/builtin/chart-composer/index.tsx +1 -1
- package/src/plugins/builtin/chart-composer/presets.ts +33 -1
- package/src/plugins/builtin/chat/channel-labels.ts +2 -2
- package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -1
- package/src/plugins/builtin/chat/content/index.tsx +2 -2
- package/src/plugins/builtin/chat/conversations.ts +1 -1
- package/src/plugins/builtin/chat/index.tsx +2 -2
- package/src/plugins/builtin/chat/share/flow.tsx +1 -1
- package/src/plugins/builtin/chat/sidebar-rows.ts +1 -1
- package/src/plugins/builtin/chat/sidebar.tsx +2 -2
- package/src/plugins/builtin/chat/unread-inbox-pane.tsx +2 -2
- package/src/plugins/builtin/cloud/auth-actions.tsx +1 -1
- package/src/plugins/builtin/cloud/auth-commands.ts +1 -1
- package/src/plugins/builtin/cloud/mcp-connect/dialog.tsx +1 -1
- package/src/plugins/builtin/cloud/team/acm-tab.tsx +3 -3
- package/src/plugins/builtin/cloud/team/capability.ts +2 -2
- package/src/plugins/builtin/cloud/team/command.ts +4 -4
- package/src/plugins/builtin/cloud/team/module.tsx +3 -3
- package/src/plugins/builtin/cloud/team/pane-model.ts +2 -2
- package/src/plugins/builtin/cloud/team/pane-sections.tsx +1 -1
- package/src/plugins/builtin/cloud/team/pane-ui.tsx +1 -1
- package/src/plugins/builtin/cloud/team/pane.tsx +3 -3
- package/src/plugins/builtin/cloud/team/status-widget.tsx +3 -3
- package/src/plugins/builtin/cloud/team/team-state-host.ts +1 -1
- package/src/plugins/builtin/cloud/team/views.ts +2 -2
- package/src/plugins/builtin/cloud/thesis/board-pane.tsx +1 -1
- package/src/plugins/builtin/cloud/thesis/detail.tsx +1 -1
- package/src/plugins/builtin/cloud/thesis/ticker-tab.tsx +1 -1
- package/src/plugins/builtin/cloud/upgrade-command.ts +1 -1
- package/src/plugins/builtin/custom-view/pane.tsx +2 -2
- package/src/plugins/builtin/dividend-yield/headless.ts +8 -2
- package/src/plugins/builtin/dividend-yield/pane.tsx +4 -2
- package/src/plugins/builtin/docs/blocks.tsx +13 -2
- package/src/plugins/builtin/docs/pane.tsx +6 -5
- package/src/plugins/builtin/earnings/board-model.ts +18 -1
- package/src/plugins/builtin/earnings/board-table.ts +18 -3
- package/src/plugins/builtin/earnings/headless.ts +13 -2
- package/src/plugins/builtin/earnings/history-model.ts +1 -1
- package/src/plugins/builtin/earnings/history-table.ts +2 -2
- package/src/plugins/builtin/help/feedback-tab.tsx +1 -1
- package/src/plugins/builtin/notes/index.tsx +1 -1
- package/src/plugins/builtin/notes/owner.tsx +2 -2
- package/src/plugins/builtin/options/carry.ts +36 -0
- package/src/plugins/builtin/options/open-interest-as-of.ts +16 -5
- package/src/plugins/builtin/options/view.tsx +19 -7
- package/src/plugins/builtin/options-calculator/pane.tsx +56 -1
- package/src/plugins/builtin/options-calculator/premium-history.ts +34 -0
- package/src/plugins/builtin/portfolio-list/mutations.ts +1 -1
- package/src/plugins/builtin/portfolio-list/pane/remove-ticker.ts +1 -1
- package/src/plugins/builtin/research/corporate-actions-pane.tsx +4 -2
- package/src/plugins/builtin/research/event-model.ts +7 -4
- package/src/plugins/builtin/series-inventory.ts +1 -0
- package/src/plugins/builtin/shared/earnings-report.ts +10 -4
- package/src/renderers/dom/styles.css +4 -2
- package/src/sources/gloomberb-cloud/normalizers.ts +2 -0
- package/src/{plugins/builtin/cloud/team → team}/collections-sync.ts +6 -6
- package/src/{plugins/builtin/cloud/team → team}/collections.ts +3 -3
- package/src/{plugins/builtin/cloud/team → team}/model.ts +2 -2
- package/src/{plugins/builtin/cloud/team → team}/pane-request.ts +1 -1
- package/src/{plugins/builtin/cloud/team → team}/store.ts +3 -3
- package/src/types/data-provider.ts +3 -0
- package/src/types/financials.ts +6 -0
- package/src/utils/decimal-comma.ts +18 -0
- package/src/utils/exchanges.ts +7 -0
- package/src/utils/forward-dividend.ts +39 -9
- package/src/utils/option-expiry.ts +16 -0
- package/src/utils/sec.ts +2 -1
|
@@ -23,7 +23,7 @@ import { fail } from "../errors";
|
|
|
23
23
|
import type { MarketContext } from "../types";
|
|
24
24
|
import type { CliCommandContext, GloomPlugin } from "../../types/plugin";
|
|
25
25
|
import { indexRootFor, indexRootTryLine } from "../index-roots";
|
|
26
|
-
import { knownGapMessage } from "../known-gaps";
|
|
26
|
+
import { knownGapMessage, settingsPointer } from "../known-gaps";
|
|
27
27
|
import { instrumentTypeLabel } from "../../tickers/instrument-kind";
|
|
28
28
|
import { canonicalExchange, isUsListingExchange } from "../../utils/exchanges";
|
|
29
29
|
import { groupByCompany } from "../../tickers/company-name";
|
|
@@ -325,9 +325,9 @@ function catalogHint(query: string): string {
|
|
|
325
325
|
|
|
326
326
|
/**
|
|
327
327
|
* The notes beside the results: the index a bare root names, data the query
|
|
328
|
-
* asks for that no source carries yet, the central bank it names,
|
|
329
|
-
*
|
|
330
|
-
* listed already said where to look instead.
|
|
328
|
+
* asks for that no source carries yet, the central bank it names, the setting
|
|
329
|
+
* it names ("light theme"), and for an empty search the catalog, unless one of
|
|
330
|
+
* those or the functions listed already said where to look instead.
|
|
331
331
|
*/
|
|
332
332
|
function searchNotes(
|
|
333
333
|
query: string,
|
|
@@ -337,9 +337,10 @@ function searchNotes(
|
|
|
337
337
|
): string[] {
|
|
338
338
|
const gap = knownGapMessage(query);
|
|
339
339
|
const bank = searchedCentralBank(query);
|
|
340
|
+
const setting = settingsPointer(query);
|
|
340
341
|
const empty = candidates.length === 0 && !fate.stopped;
|
|
341
|
-
return [...fate.notes, indexRootNote(query, candidates), gap, bank ? centralBankLine(bank) : null,
|
|
342
|
-
empty && !gap && !bank && functions.length === 0 ? catalogHint(query) : null]
|
|
342
|
+
return [...fate.notes, indexRootNote(query, candidates), gap, bank ? centralBankLine(bank) : null, setting,
|
|
343
|
+
empty && !gap && !bank && !setting && functions.length === 0 ? catalogHint(query) : null]
|
|
343
344
|
.filter((note): note is string => !!note);
|
|
344
345
|
}
|
|
345
346
|
|
|
@@ -379,6 +380,8 @@ export function buildSearchReport({
|
|
|
379
380
|
if (gap) lines.push("", gap);
|
|
380
381
|
const bank = searchedCentralBank(query);
|
|
381
382
|
if (bank) lines.push("", centralBankLine(bank));
|
|
383
|
+
const setting = settingsPointer(query);
|
|
384
|
+
if (setting) lines.push("", setting);
|
|
382
385
|
const charted = renderSeriesMatches(query, series, SEARCH_SERIES_LIMIT, cliTerminalWidth() ?? 100, "catalog");
|
|
383
386
|
// A query that names the data ("carbon", "day-ahead") lists its series first; words that happen to
|
|
384
387
|
// be in a series name ("apple") list them after the tickers.
|
|
@@ -390,7 +393,7 @@ export function buildSearchReport({
|
|
|
390
393
|
if (candidates.length === 0) {
|
|
391
394
|
if (fate.stopped) return lines.join("\n");
|
|
392
395
|
if (functions.length > 0) lines.push("", renderFunctionMatches(functions));
|
|
393
|
-
else if (!gap && !bank && !charted) lines.push(cliStyles.muted("No matches found."), cliStyles.muted(catalogHint(query)));
|
|
396
|
+
else if (!gap && !bank && !setting && !charted) lines.push(cliStyles.muted("No matches found."), cliStyles.muted(catalogHint(query)));
|
|
394
397
|
return lines.join("\n");
|
|
395
398
|
}
|
|
396
399
|
|
|
@@ -347,7 +347,7 @@ function percentFigure(fraction: number | null | undefined, format: (value: numb
|
|
|
347
347
|
return fraction != null ? { text: format(fraction), value: fraction * 100, unit: "%" } : { text: "—" };
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
-
/** A forward yield says what it annualizes and when, as DVD does; any other yield is the figure alone. */
|
|
350
|
+
/** A forward yield says what it annualizes and when, as DVD does (a declared distribution by its ex-date); any other yield is the figure alone. */
|
|
351
351
|
function forwardYieldFigure(fundamentals: TickerFinancials["fundamentals"]): MetricFigure {
|
|
352
352
|
const figure = percentFigure(fundamentals?.dividendYield, formatFractionPercentCell);
|
|
353
353
|
return fundamentals?.dividendYieldBasis === "forward" && fundamentals.dividendYield != null
|
package/src/cli/known-gaps.ts
CHANGED
|
@@ -82,6 +82,28 @@ export function topicPointer(query: string): { message: string; codes: string[]
|
|
|
82
82
|
return { message: `${pointer.topic}: not in the terminal yet; closest: ${closest}`, codes: pointer.closest.map(([code]) => code) };
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Settings people look for by name that are a `config` command, not a function or a ticker: the line `search` and
|
|
87
|
+
* `catalog` print beside whatever else the query finds. A query asks for one when every word is a word of that
|
|
88
|
+
* setting and one of them names it: "light theme", "dark", "colour-blind", but not "ai theme" (THEM's basket) or
|
|
89
|
+
* "dark pool".
|
|
90
|
+
*/
|
|
91
|
+
const SETTING_POINTERS: ReadonlyArray<{ names: readonly string[]; words: readonly string[]; line: string }> = [
|
|
92
|
+
{
|
|
93
|
+
names: ["theme", "themes", "light", "dark", "colorblind", "colourblind", "blind"],
|
|
94
|
+
words: ["mode", "color", "colour", "colors", "colours", "high", "contrast", "change", "switch", "set", "setting", "settings"],
|
|
95
|
+
line: "Themes: gloomberb config themes",
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
/** The `config` command for a query that names a setting ("light theme"), else null. */
|
|
100
|
+
export function settingsPointer(query: string): string | null {
|
|
101
|
+
const asked = words(query);
|
|
102
|
+
if (asked.length === 0) return null;
|
|
103
|
+
return SETTING_POINTERS.find(({ names, words: other }) => asked.some((word) => names.includes(word))
|
|
104
|
+
&& asked.every((word) => names.includes(word) || other.includes(word)))?.line ?? null;
|
|
105
|
+
}
|
|
106
|
+
|
|
85
107
|
/** Letters and digits only, lower case: `EU-ETS`, `eu ets` and `EUETS` are one lookup. */
|
|
86
108
|
function lookupKey(text: string): string {
|
|
87
109
|
return text.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
@@ -575,25 +575,28 @@ export function renderPaneCatalogReport(
|
|
|
575
575
|
glossary: GlossaryLookup = { exact: [], partial: [] },
|
|
576
576
|
gap: string | null = null,
|
|
577
577
|
series: readonly SeriesMatch[] = [],
|
|
578
|
+
/** The `config` command a query names a setting of ("light theme"), printed last, beside the functions. */
|
|
579
|
+
setting: string | null = null,
|
|
578
580
|
): string {
|
|
579
581
|
const query = args.query.trim().toLowerCase();
|
|
580
582
|
const exact = query
|
|
581
583
|
? entries.find((entry) => entry.token.toLowerCase() === query)
|
|
582
584
|
?? entries.find((entry) => entry.aliases.some((alias) => alias.toLowerCase() === query))
|
|
583
585
|
: undefined;
|
|
584
|
-
|
|
586
|
+
const withSetting = (text: string) => [text, setting ?? ""].filter(Boolean).join("\n\n");
|
|
587
|
+
if (exact) return withSetting(renderCatalogEntry(exact));
|
|
585
588
|
|
|
586
589
|
const width = Math.min(cliTerminalWidth() ?? CATALOG_TEXT_WIDTH, CATALOG_TEXT_WIDTH);
|
|
587
590
|
const terms = renderCatalogGlossary(glossary, width);
|
|
588
591
|
const charted = renderSeriesMatches(args.query.trim(), series, Number.isFinite(args.limit) ? CATALOG_SERIES_LIMIT : series.length, width);
|
|
589
|
-
const withGap = (text: string) => [gap ? wrapText(gap, width).join("\n") : "", text].filter(Boolean).join("\n\n");
|
|
592
|
+
const withGap = (text: string) => withSetting([gap ? wrapText(gap, width).join("\n") : "", text].filter(Boolean).join("\n\n"));
|
|
590
593
|
const withTerms = (text: string) => withGap([text, charted, terms].filter(Boolean).join("\n\n"));
|
|
591
594
|
if (args.query && entries.length === 1) return withTerms(renderCatalogEntry(entries[0]!));
|
|
592
595
|
|
|
593
596
|
const note = (text: string) => wrapText(text, width).map((line) => cliStyles.muted(line)).join("\n");
|
|
594
597
|
if (entries.length === 0) {
|
|
595
598
|
if (charted) return withGap([charted, terms].filter(Boolean).join("\n\n"));
|
|
596
|
-
if (glossary.exact.length > 0 || gap) return withGap(terms);
|
|
599
|
+
if (glossary.exact.length > 0 || gap || setting) return withGap(terms);
|
|
597
600
|
return withTerms(note(args.query
|
|
598
601
|
? `No functions match "${args.query}". Run gloomberb catalog to browse them all.`
|
|
599
602
|
: "No functions are available."));
|
|
@@ -10,7 +10,7 @@ import { lockNoteReason } from "../../plugins/builtin/shared/pro-lock-note";
|
|
|
10
10
|
import { renderDesktopShot, type PaneScreenshotResult } from "./screenshot";
|
|
11
11
|
import { deriveRenderedFreshness, formatFreshnessLine, type ReportFreshness } from "./freshness";
|
|
12
12
|
import { exportTextTable, reportFooterLines, type CliReportTables } from "../report-tables";
|
|
13
|
-
import { renderedReportFreshness, renderedReportNotices, renderedReportObservations } from "./report-notices";
|
|
13
|
+
import { renderedReportFailure, renderedReportFreshness, renderedReportNotices, renderedReportObservations } from "./report-notices";
|
|
14
14
|
|
|
15
15
|
const DOM_REPORT_WIDTH = 1280;
|
|
16
16
|
const DOM_REPORT_HEIGHT = 720;
|
|
@@ -332,13 +332,21 @@ function failedDomReport(
|
|
|
332
332
|
};
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
/** A report the rendered pane says it cannot give; the command fails with the pane's words and advice line. */
|
|
336
|
+
class RenderedReportFailure extends Error {
|
|
337
|
+
constructor(message: string, readonly details?: string) {
|
|
338
|
+
super(message);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
335
342
|
export async function buildDomFunctionReport(
|
|
336
343
|
resolved: ResolvedPaneFunction,
|
|
337
344
|
context: MarketContext,
|
|
338
345
|
rawArg: string,
|
|
339
346
|
): Promise<PaneFunctionReport> {
|
|
347
|
+
let result: PaneScreenshotResult;
|
|
340
348
|
try {
|
|
341
|
-
|
|
349
|
+
result = await renderDesktopShot({
|
|
342
350
|
resolved,
|
|
343
351
|
context,
|
|
344
352
|
rawArg,
|
|
@@ -351,6 +359,13 @@ export async function buildDomFunctionReport(
|
|
|
351
359
|
options: {},
|
|
352
360
|
captureImage: false,
|
|
353
361
|
});
|
|
362
|
+
} catch (error) {
|
|
363
|
+
return failedDomReport(resolved, rawArg, error);
|
|
364
|
+
}
|
|
365
|
+
// No header and no partial view: the error alone, and a failed exit, as the plain command gives.
|
|
366
|
+
const failure = renderedReportFailure(result.render.semanticUi ?? []);
|
|
367
|
+
if (failure) throw new RenderedReportFailure(failure.message, failure.details);
|
|
368
|
+
try {
|
|
354
369
|
return buildDomPaneReportFromRender(resolved, result);
|
|
355
370
|
} catch (error) {
|
|
356
371
|
return failedDomReport(resolved, rawArg, error);
|
|
@@ -395,7 +395,7 @@ export const FUNCTION_HELP: Readonly<Record<string, FunctionHelp>> = {
|
|
|
395
395
|
bloomberg: ["ERN"],
|
|
396
396
|
},
|
|
397
397
|
DVD: {
|
|
398
|
-
summary: "Cash distributions, trailing yield, the forward yield fundamentals show (latest distribution annualized), and payout growth. Future payments are not forecast.",
|
|
398
|
+
summary: "Cash distributions, trailing yield, the forward yield fundamentals show (the latest distribution annualized, or the declared one once announced), and payout growth. Future payments are not forecast.",
|
|
399
399
|
usage: ["DVD KO"],
|
|
400
400
|
keys: [STEP],
|
|
401
401
|
data: same("Daily"),
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
import { incompleteReportGateMessage } from "./access-gate";
|
|
27
27
|
import { withPersistedCloudSession } from "./cloud-session";
|
|
28
28
|
import { ListingArgError, listingReadNotes, loadForListing, quotedListingChoiceNotes } from "../listing-arg";
|
|
29
|
-
import { knownGapMessage, topicPointer } from "../known-gaps";
|
|
29
|
+
import { knownGapMessage, settingsPointer, topicPointer } from "../known-gaps";
|
|
30
30
|
import { selectReportTables } from "../report-tables";
|
|
31
31
|
import { cliOutputWritten } from "../result";
|
|
32
32
|
import { CLI_EXIT_CODES } from "../exit-codes";
|
|
@@ -316,6 +316,8 @@ export async function runPaneCatalog(args: string[], ctx: CliCommandContext) {
|
|
|
316
316
|
const glossary = lookupGlossary(parsed.query);
|
|
317
317
|
// Data no source carries yet is said once: in the text, else as the envelope's warning.
|
|
318
318
|
const gap = knownGapMessage(parsed.query);
|
|
319
|
+
// A setting is a command, not a function: "light theme" names `config themes` beside THEM.
|
|
320
|
+
const setting = settingsPointer(parsed.query);
|
|
319
321
|
// The data catalog series the query names, which `fn G` charts.
|
|
320
322
|
const series = findCatalogSeries(ctx.plugins, parsed.query);
|
|
321
323
|
const listed = pointer
|
|
@@ -324,9 +326,9 @@ export async function runPaneCatalog(args: string[], ctx: CliCommandContext) {
|
|
|
324
326
|
ctx.printResult({
|
|
325
327
|
data: listed,
|
|
326
328
|
...series.length ? { metadata: { series } } : {},
|
|
327
|
-
...gap && ctx.cliOptions.format !== "text" ? { warnings: [gap] } : {},
|
|
329
|
+
...(gap || setting) && ctx.cliOptions.format !== "text" ? { warnings: [gap, setting].filter((note): note is string => !!note) } : {},
|
|
328
330
|
}, {
|
|
329
|
-
text: () => renderPaneCatalogReport(filtered, effectiveParsed, glossary, gap, series),
|
|
331
|
+
text: () => renderPaneCatalogReport(filtered, effectiveParsed, glossary, gap, series, setting),
|
|
330
332
|
});
|
|
331
333
|
} finally {
|
|
332
334
|
registry.destroy();
|
|
@@ -26,6 +26,21 @@ export function renderedReportNotices(semanticUi: readonly RemoteUiNodeSnapshot[
|
|
|
26
26
|
return { notices, facts };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Why a rendered pane cannot give the report it was asked for, from the
|
|
31
|
+
* `report-failure` node it publishes: `{ message, details? }`. OMON opened on
|
|
32
|
+
* an expiry the chain does not list says so in its body; the report fails with
|
|
33
|
+
* the error the plain command (`options --expiration`) prints, instead of
|
|
34
|
+
* reading that body back as a view. Null when the pane publishes none.
|
|
35
|
+
*/
|
|
36
|
+
export function renderedReportFailure(semanticUi: readonly RemoteUiNodeSnapshot[]): { message: string; details?: string } | null {
|
|
37
|
+
const metadata = semanticUi.find((node) => node.role === "report-failure")?.metadata;
|
|
38
|
+
const message = typeof metadata?.message === "string" ? metadata.message.trim() : "";
|
|
39
|
+
if (!message) return null;
|
|
40
|
+
const details = typeof metadata?.details === "string" && metadata.details.trim() ? metadata.details.trim() : undefined;
|
|
41
|
+
return details ? { message, details } : { message };
|
|
42
|
+
}
|
|
43
|
+
|
|
29
44
|
const FRESHNESS_STATUSES: readonly HeadlessFreshnessStatus[] = ["live", "delayed", "stale", "not-a-feed"];
|
|
30
45
|
|
|
31
46
|
/** The `sources` a pane publishes: each a name and a time. */
|
package/src/cli/symbol-args.ts
CHANGED
|
@@ -13,6 +13,25 @@ export function symbolInputKey(symbol: string): string {
|
|
|
13
13
|
return symbol.trim().replace(/^\$/, "").toUpperCase();
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* How a command's symbol list spots one security typed twice: `symbolInputKey`,
|
|
18
|
+
* with a share class written with a dot or a dash as one (BRK.B and BRK-B).
|
|
19
|
+
*/
|
|
20
|
+
function securityInputKey(symbol: string): string {
|
|
21
|
+
return symbolInputKey(symbol).replace(/-/g, ".");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The securities of a symbol list, each once, in the first spelling typed: `AAPL aapl BRK.B BRK-B` is AAPL and BRK.B. */
|
|
25
|
+
export function uniqueSecuritySymbols(symbols: readonly string[]): string[] {
|
|
26
|
+
const seen = new Set<string>();
|
|
27
|
+
return symbols.filter((symbol) => {
|
|
28
|
+
const key = securityInputKey(symbol);
|
|
29
|
+
if (!key || seen.has(key)) return false;
|
|
30
|
+
seen.add(key);
|
|
31
|
+
return true;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
16
35
|
/** The symbols without repeats, first spelling kept, and how many repeats were dropped. */
|
|
17
36
|
export function uniqueSymbols(symbols: readonly string[]): { symbols: string[]; repeated: number } {
|
|
18
37
|
const seen = new Set<string>();
|
|
@@ -5,8 +5,8 @@ import {
|
|
|
5
5
|
} from "../../../plugins/builtin/portfolio-list/command-bar";
|
|
6
6
|
import type { CommandBarFieldOption, CommandBarWorkflowField, CommandBarWorkflowRoute } from "./types";
|
|
7
7
|
import { buildCommandBarWorkflowRoute } from "./route-builder";
|
|
8
|
-
import { teamPrefix } from "../../../
|
|
9
|
-
import { teamStore } from "../../../
|
|
8
|
+
import { teamPrefix } from "../../../team/model";
|
|
9
|
+
import { teamStore } from "../../../team/store";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Where a new collection lives: me, or one of my teams. Only asked when there
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Dispatch } from "react";
|
|
2
2
|
import { apiClient } from "../../../api-client";
|
|
3
3
|
import { t } from "../../../i18n";
|
|
4
|
-
import { teamCollectionLocalId } from "../../../
|
|
4
|
+
import { teamCollectionLocalId } from "../../../team/collections";
|
|
5
5
|
import type { DataProvider } from "../../../types/data-provider";
|
|
6
6
|
import type { AppTickerRepositoryPort } from "../../../core/app-service-ports";
|
|
7
7
|
import type { PluginRegistry } from "../../../plugins/registry";
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
} from "../feedback/types";
|
|
20
20
|
import { t, tf } from "../i18n";
|
|
21
21
|
import { useAppLanguage } from "../i18n/react";
|
|
22
|
-
import { requestAuthDialog } from "../
|
|
22
|
+
import { requestAuthDialog } from "../account/auth-dialog";
|
|
23
23
|
import { getSharedRegistry } from "../plugins/registry";
|
|
24
24
|
import { useViewport } from "../react/input";
|
|
25
25
|
import { useAppStateRef } from "../state/app/context";
|
|
@@ -61,7 +61,7 @@ import {
|
|
|
61
61
|
useShellVisibleLayout,
|
|
62
62
|
} from "./layout-state";
|
|
63
63
|
import { restoreShellHiddenPanes } from "./visible-layout";
|
|
64
|
-
import { AuthDialogHost } from "../../../
|
|
64
|
+
import { AuthDialogHost } from "../../../account/auth-dialog";
|
|
65
65
|
import { DeviceSignInDialogHost } from "../../../account/device-signin-dialog";
|
|
66
66
|
import { McpConnectDialogHost } from "../../../plugins/builtin/cloud/mcp-connect/dialog";
|
|
67
67
|
import { BrokerSignInDialogHost } from "../../../brokers/signed-in/sign-in-dialog";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { TeamSummary } from "../../api-client";
|
|
2
2
|
import type { SavedLayout } from "../../types/config";
|
|
3
|
-
import type { TeamStoreSnapshot } from "../../
|
|
3
|
+
import type { TeamStoreSnapshot } from "../../team/store";
|
|
4
4
|
|
|
5
5
|
export type TabGroupId = "personal" | `team:${string}`;
|
|
6
6
|
|
|
@@ -43,8 +43,8 @@ import { confirmDialog } from "../ui/confirm-dialog";
|
|
|
43
43
|
import { Tabs } from "../ui/tabs";
|
|
44
44
|
import { useTransientLayout } from "./transient-layout";
|
|
45
45
|
import { linkedLayoutMarker, linkedLayoutStatus, linkedLayoutUpdates } from "../../layout-marketplace/linked";
|
|
46
|
-
import { teamAccentHex } from "../../
|
|
47
|
-
import { teamStore } from "../../
|
|
46
|
+
import { teamAccentHex } from "../../team/model";
|
|
47
|
+
import { teamStore } from "../../team/store";
|
|
48
48
|
import { buildStatusBarTabGroups, groupIdFromMarkerValue, groupMarkerValue } from "./status-bar-groups";
|
|
49
49
|
import { requestFeedbackDialog } from "../feedback-dialog";
|
|
50
50
|
import { openFormModal } from "../form-modal";
|
|
@@ -17,6 +17,8 @@ export interface MarkdownTextProps {
|
|
|
17
17
|
lineWidth?: number;
|
|
18
18
|
/** Desktop and web: the space a blank line leaves between paragraphs, in CSS units; one line when unset. */
|
|
19
19
|
paragraphGap?: string;
|
|
20
|
+
/** Desktop and web: the size, weight and space of a heading line, such as a reader's section heading; the text's own when unset. */
|
|
21
|
+
headingStyle?: Readonly<Record<string, string | number>>;
|
|
20
22
|
catalog?: Record<string, InlineTickerCatalogEntry>;
|
|
21
23
|
textColor?: string;
|
|
22
24
|
openTicker?: (symbol: string) => void;
|
|
@@ -86,9 +88,11 @@ function parseInlineMarkdown(text: string, links = false): StyledSegment[] {
|
|
|
86
88
|
return segments;
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
const HEADING_RE = /^(#{1,3})\s+(.+)$/;
|
|
92
|
+
|
|
89
93
|
function parseLine(line: string, links: boolean): ParsedLine {
|
|
90
94
|
// Headings
|
|
91
|
-
const headingMatch = line.match(
|
|
95
|
+
const headingMatch = line.match(HEADING_RE);
|
|
92
96
|
if (headingMatch) {
|
|
93
97
|
return {
|
|
94
98
|
heading: true,
|
|
@@ -595,6 +599,7 @@ export function MarkdownText({
|
|
|
595
599
|
openTicker = () => {},
|
|
596
600
|
onOpenLink,
|
|
597
601
|
paragraphGap,
|
|
602
|
+
headingStyle,
|
|
598
603
|
}: MarkdownTextProps) {
|
|
599
604
|
const { nativePaneChrome } = useUiCapabilities();
|
|
600
605
|
const links = onOpenLink !== undefined;
|
|
@@ -620,12 +625,15 @@ export function MarkdownText({
|
|
|
620
625
|
}
|
|
621
626
|
const line = block.text;
|
|
622
627
|
if (line.trim() === "") {
|
|
628
|
+
// Under a heading set in its own style, the heading's margin is the space: it sits with what it names.
|
|
629
|
+
const previous = blocks[index - 1];
|
|
630
|
+
if (nativePaneChrome && headingStyle && previous?.kind === "line" && HEADING_RE.test(previous.text)) return null;
|
|
623
631
|
return nativePaneChrome && paragraphGap
|
|
624
632
|
? <Box key={index} flexShrink={0} style={{ height: paragraphGap }} />
|
|
625
633
|
: <Text key={index}>{" "}</Text>;
|
|
626
634
|
}
|
|
627
635
|
const parsed = parseLine(line, links);
|
|
628
|
-
|
|
636
|
+
const markdownLine = (
|
|
629
637
|
<MarkdownLine
|
|
630
638
|
key={index}
|
|
631
639
|
parsed={parsed}
|
|
@@ -638,6 +646,10 @@ export function MarkdownText({
|
|
|
638
646
|
onOpenLink={onOpenLink}
|
|
639
647
|
/>
|
|
640
648
|
);
|
|
649
|
+
// A line's height comes from --cell-h, in em of its own size.
|
|
650
|
+
return nativePaneChrome && headingStyle && parsed.heading
|
|
651
|
+
? <Box key={index} flexDirection="column" width="100%" style={{ ...headingStyle, "--cell-h": `${headingStyle.lineHeight ?? 1.35}em` }}>{markdownLine}</Box>
|
|
652
|
+
: markdownLine;
|
|
641
653
|
})}
|
|
642
654
|
</Box>
|
|
643
655
|
);
|
|
@@ -375,7 +375,7 @@ function ArticleReaderView({
|
|
|
375
375
|
<ReaderText text={lead} geometry={geometry} color={colors.textBright} variant="lead" />
|
|
376
376
|
) : null}
|
|
377
377
|
{!raw && facts.length > 0 ? (
|
|
378
|
-
<Box flexDirection="column" style={desktop ? { margin: "0.3em 0
|
|
378
|
+
<Box flexDirection="column" style={desktop ? { margin: "0.3em 0 1em" } : undefined}>
|
|
379
379
|
<ReaderText text={readerLabel("Key facts")} geometry={geometry} color={colors.textDim} variant="label" />
|
|
380
380
|
{facts.map((fact, index) => (
|
|
381
381
|
<ReaderText key={index} text={fact} geometry={geometry} color={colors.text} variant="fact" segments={figureSegments(fact)} figureColor={colors.textBright} />
|
|
@@ -9,7 +9,7 @@ import { DetailScrollBody } from "../ui/detail-scroll-body";
|
|
|
9
9
|
import { KeyValueRow } from "../ui/display";
|
|
10
10
|
import { ExternalLink, usePaneLinkMenuEntry } from "../ui/external-link";
|
|
11
11
|
import { parseArticle } from "./article-text";
|
|
12
|
-
import { DESKTOP_LINE_HEIGHT, READER_APP_FONT, READER_COLUMN_FONT, ReaderBlock, ReaderText, type ReaderGeometry } from "./reader-text";
|
|
12
|
+
import { DESKTOP_LINE_HEIGHT, READER_APP_FONT, READER_COLUMN_FONT, READER_HEADING_STYLE, ReaderBlock, ReaderText, type ReaderGeometry } from "./reader-text";
|
|
13
13
|
|
|
14
14
|
export interface DetailFact {
|
|
15
15
|
label: string;
|
|
@@ -50,14 +50,17 @@ const URL_LINE = /^https?:\/\/\S+$/;
|
|
|
50
50
|
|
|
51
51
|
const COLUMN_STYLE = { fontSize: READER_COLUMN_FONT, lineHeight: DESKTOP_LINE_HEIGHT } as const;
|
|
52
52
|
const META_STYLE = { marginBottom: "0.8em" } as const;
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* At the app's own size, as metadata is in every reader. Its columns come from
|
|
55
|
+
* styles.css: one pair to a row, or two in a wide reader.
|
|
56
|
+
*/
|
|
54
57
|
const FACTS_STYLE = {
|
|
55
58
|
display: "grid",
|
|
56
|
-
columnGap: "
|
|
57
|
-
rowGap: "0.
|
|
58
|
-
fontSize:
|
|
59
|
+
columnGap: "2em",
|
|
60
|
+
rowGap: "0.3em",
|
|
61
|
+
fontSize: READER_APP_FONT,
|
|
59
62
|
lineHeight: 1.5,
|
|
60
|
-
marginBottom: "1.
|
|
63
|
+
marginBottom: "1.6em",
|
|
61
64
|
} as const;
|
|
62
65
|
const FACT_TEXT_STYLE = { display: "block", lineHeight: "inherit", minWidth: 0 } as const;
|
|
63
66
|
/** Markdown lines take the reader's line height (the host sets Text line height from --cell-h). */
|
|
@@ -118,7 +121,7 @@ function NativeDetail({ meta, facts, body, bodyFormat, note }: DetailContentProp
|
|
|
118
121
|
) : null}
|
|
119
122
|
{bodyFormat === "markdown" && body ? (
|
|
120
123
|
<Box flexDirection="column" width="100%" style={MARKDOWN_STYLE}>
|
|
121
|
-
<MarkdownText text={body} paragraphGap={PARAGRAPH_GAP} />
|
|
124
|
+
<MarkdownText text={body} paragraphGap={PARAGRAPH_GAP} headingStyle={READER_HEADING_STYLE} />
|
|
122
125
|
</Box>
|
|
123
126
|
) : blocks.map((block, index) => (
|
|
124
127
|
<Box key={index} flexDirection="column">
|
|
@@ -12,18 +12,32 @@ import type { ArticleBlock } from "./article-text";
|
|
|
12
12
|
* detail reader of list panes.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
/** Line height of reading text on the desktop, against its own size. */
|
|
16
|
-
export const DESKTOP_LINE_HEIGHT = 1.65;
|
|
17
|
-
|
|
18
15
|
/**
|
|
19
|
-
* The
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
16
|
+
* The reader's type scale on the desktop and the web, one for every reader
|
|
17
|
+
* (the article reader, the detail reader, the markdown and docs headings in
|
|
18
|
+
* them). Reading text sits a step above the app's own size (12px by default)
|
|
19
|
+
* and every other level is a modest step from it, so the page reads as part
|
|
20
|
+
* of the app and the levels differ by weight and space as much as by size:
|
|
21
|
+
*
|
|
22
|
+
* | Level | Wide | Column under 480px |
|
|
23
|
+
* |---|---|---|
|
|
24
|
+
* | Metadata, facts, tables, coverage | 12px, dimmer | 12px |
|
|
25
|
+
* | Body | 14px, line height 1.6 | 13.2px |
|
|
26
|
+
* | Lead | 15px | 14.1px |
|
|
27
|
+
* | Section heading | 16px bold | 15.2px |
|
|
28
|
+
* | Title | 20px bold | 18.9px |
|
|
29
|
+
*
|
|
30
|
+
* The body size comes from `--reader-em`, which a container query in
|
|
31
|
+
* styles.css lowers in a narrow column, so a resize changes it with the
|
|
32
|
+
* layout and nothing counts cells. The levels above it are in em of it.
|
|
23
33
|
*/
|
|
24
|
-
export const READER_COLUMN_FONT = "calc(var(--reader-em, 1.
|
|
25
|
-
/** Back to the app's own size inside a reading column: chips, controls, tables. */
|
|
26
|
-
export const READER_APP_FONT = "calc(1em / var(--reader-em, 1.
|
|
34
|
+
export const READER_COLUMN_FONT = "calc(var(--reader-em, 1.1667) * 1em)";
|
|
35
|
+
/** Back to the app's own size inside a reading column: metadata, chips, controls, tables. */
|
|
36
|
+
export const READER_APP_FONT = "calc(1em / var(--reader-em, 1.1667))";
|
|
37
|
+
/** Line height of reading text on the desktop, against its own size. */
|
|
38
|
+
export const DESKTOP_LINE_HEIGHT = 1.6;
|
|
39
|
+
/** A section heading in reading text: a step above the body, set apart by weight and the space above it. */
|
|
40
|
+
export const READER_HEADING_STYLE = { fontSize: "1.15em", fontWeight: 700, lineHeight: 1.35, margin: "0.9em 0 0.4em" } as const;
|
|
27
41
|
|
|
28
42
|
export type Segment = { text: string; marked?: boolean; figure?: boolean };
|
|
29
43
|
|
|
@@ -58,14 +72,15 @@ function hasHeaderRow(rows: readonly string[][]): boolean {
|
|
|
58
72
|
export type TextVariant = "headline" | "meta" | "lead" | "body" | "heading" | "label" | "fact" | "item";
|
|
59
73
|
|
|
60
74
|
const DESKTOP_VARIANT_STYLE: Record<TextVariant, Record<string, string | number>> = {
|
|
61
|
-
headline: { fontSize: "1.
|
|
62
|
-
meta: { fontSize:
|
|
63
|
-
lead: { fontSize: "1.
|
|
75
|
+
headline: { fontSize: "1.43em", lineHeight: 1.25 },
|
|
76
|
+
meta: { fontSize: READER_APP_FONT, lineHeight: 1.5 },
|
|
77
|
+
lead: { fontSize: "1.07em", lineHeight: 1.55, marginBottom: "1em" },
|
|
64
78
|
body: { lineHeight: DESKTOP_LINE_HEIGHT, marginBottom: "0.9em" },
|
|
65
|
-
heading:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
79
|
+
heading: READER_HEADING_STYLE,
|
|
80
|
+
// Names what follows (key facts, coverage) as a section heading does, without the air above a section.
|
|
81
|
+
label: { ...READER_HEADING_STYLE, margin: "0 0 0.4em" },
|
|
82
|
+
fact: { fontSize: "0.93em", lineHeight: 1.55, marginBottom: "0.35em" },
|
|
83
|
+
item: { lineHeight: DESKTOP_LINE_HEIGHT, marginBottom: "0.3em" },
|
|
69
84
|
};
|
|
70
85
|
|
|
71
86
|
function styled(segments: readonly Segment[], color: string, markColor: string, figureColor: string | undefined, attributes: number): StyledText {
|
|
@@ -11,8 +11,8 @@ import { useAppDispatch, useAppSelector } from "../state/app/context";
|
|
|
11
11
|
import { useDialog, useDialogState, type PromptContext } from "../ui/dialog";
|
|
12
12
|
import { useRendererHost, useUiCapabilities } from "../ui";
|
|
13
13
|
import { apiClient, type TeamSummary } from "../api-client";
|
|
14
|
-
import { requestAuthDialog } from "../
|
|
15
|
-
import { teamStore } from "../
|
|
14
|
+
import { requestAuthDialog } from "../account/auth-dialog";
|
|
15
|
+
import { teamStore } from "../team/store";
|
|
16
16
|
import { usePlanAccess } from "../api-client/plan-access";
|
|
17
17
|
import { getMarketplaceHost } from "../plugins/marketplace/store";
|
|
18
18
|
import { rememberLayoutRequirements } from "../components/layout/missing-pane";
|
|
@@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
|
|
|
2
2
|
import { apiClient } from "../api-client";
|
|
3
3
|
import { rememberLayoutRequirements } from "../components/layout/missing-pane";
|
|
4
4
|
import type { PluginRegistry } from "../plugins/registry";
|
|
5
|
-
import { teamStore } from "../
|
|
5
|
+
import { teamStore } from "../team/store";
|
|
6
6
|
import { useAppDispatch, useAppSelector } from "../state/app/context";
|
|
7
7
|
import { decideLinkedAction, linkedLayoutStatus, linkedLayoutUpdates, originFromEntry } from "./linked";
|
|
8
8
|
import { materializeMarketplaceLayout } from "../shares/portable-layout";
|
|
@@ -50,6 +50,7 @@ const REGULAR_OPEN_MINUTES: Record<string, number> = {
|
|
|
50
50
|
FWB: 8 * 60,
|
|
51
51
|
SWX: 9 * 60,
|
|
52
52
|
VIE: 9 * 60,
|
|
53
|
+
WBDM: 9 * 60,
|
|
53
54
|
OSL: 9 * 60,
|
|
54
55
|
ICEX: 9 * 60 + 30,
|
|
55
56
|
WSE: 9 * 60,
|
|
@@ -89,6 +90,8 @@ const REGULAR_CLOSE_MINUTES: Record<string, number> = {
|
|
|
89
90
|
JPX: 15 * 60 + 30, HKEX: 16 * 60 + 10, TWSE: 14 * 60 + 30, TPEX: 14 * 60 + 30,
|
|
90
91
|
NSE: 16 * 60, BSE: 16 * 60, ASX: 16 * 60 + 15, SGX: 17 * 60 + 20, KRX: 16 * 60, KOSDAQ: 16 * 60,
|
|
91
92
|
NZX: 17 * 60, SSE: 15 * 60 + 30, SZSE: 15 * 60 + 30,
|
|
93
|
+
// The Vienna MTF's ETPs and notes trade in continuous auctions to 17:30, with no closing auction.
|
|
94
|
+
WBDM: 17 * 60 + 30,
|
|
92
95
|
BMV: 15 * 60 + 10, B3: 18 * 60 + 30, BYMA: 17 * 60 + 10, JSE: 17 * 60 + 15,
|
|
93
96
|
// Monday to Thursday: trading at last ends 17:30, after the closing auction.
|
|
94
97
|
// Not later: a complete 5-minute copy ends with its 17:09 bar, which must
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Published full-day closures (weekend dates omitted) for Asia-Pacific venues and
|
|
2
|
-
// not a holiday-rule engine. A year a venue has not published is unknown, not
|
|
1
|
+
// Published full-day closures (weekend dates omitted) for Asia-Pacific venues, EGX and Wiener
|
|
2
|
+
// Boerse, not a holiday-rule engine. A year a venue has not published is unknown, not
|
|
3
3
|
// open: every weekday of it reads as a session. Half-day sessions (HKEX and
|
|
4
4
|
// SGX eves, ASX's 14:10 closes) are not modelled. Checked 2026-10-09:
|
|
5
5
|
// KRX, also KOSDAQ: http://open.krx.co.kr/contents/MKD/01/0110/01100305/MKD01100305.jsp
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
// (some moved, as its list warns), and on National Day, 16 and 17 Dec. Its week is Sunday to Thursday.
|
|
10
10
|
// HKEX: https://www.hkex.com.hk/-/media/HKEX-Market/Services/Circulars-and-Notices/Participant-and-Members-Circulars/SEHK/2025/ce_SEHK_CT_075_2025.pdf
|
|
11
11
|
// and https://www.hkex.com.hk/-/media/HKEX-Market/Services/Circulars-and-Notices/Participant-and-Members-Circulars/SEHK/2026/ce_SEHK_CT_077_2026.pdf
|
|
12
|
+
// Wiener Boerse closes its main market (VIE) and the Vienna MTF (WBDM) on its stock exchange holidays,
|
|
13
|
+
// and trades on Austria's other public holidays:
|
|
14
|
+
// https://www.wienerborse.at/uploads/u/cms/files/trading/stock-exchange-holidays-2026-en.pdf (and -2027-en.pdf)
|
|
12
15
|
// SGX closes on Singapore's gazetted public holidays, one on a Sunday moving to the Monday:
|
|
13
16
|
// https://www.sgx.com/stock-exchange/trading and https://www.mom.gov.sg/employment-practices/public-holidays
|
|
14
17
|
// MSX closes on the holidays its site lists (msx.om exchange holidays, read 10 Oct 2026).
|
|
@@ -28,6 +31,11 @@ const KRX_CLOSURES: Record<number, readonly string[]> = {
|
|
|
28
31
|
],
|
|
29
32
|
};
|
|
30
33
|
|
|
34
|
+
const WIENER_BOERSE_CLOSURES: Record<number, readonly string[]> = {
|
|
35
|
+
2026: ["01-01", "04-03", "04-06", "05-01", "10-26", "12-24", "12-25", "12-31"],
|
|
36
|
+
2027: ["01-01", "03-26", "03-29", "10-26", "12-24", "12-31"],
|
|
37
|
+
};
|
|
38
|
+
|
|
31
39
|
const TWSE_CLOSURES: Record<number, readonly string[]> = {
|
|
32
40
|
2026: [
|
|
33
41
|
"01-01", "02-12", "02-13", "02-16", "02-17", "02-18", "02-19", "02-20", "02-27",
|
|
@@ -61,6 +69,8 @@ export const PUBLISHED_APAC_CLOSURES: Readonly<Record<string, Record<number, rea
|
|
|
61
69
|
2026: ["01-01", "02-17", "02-18", "04-03", "05-01", "05-27", "06-01", "08-10", "11-09", "12-25"],
|
|
62
70
|
2027: ["01-01", "02-08", "03-10", "03-26", "05-17", "05-20", "08-09", "10-28"],
|
|
63
71
|
},
|
|
72
|
+
VIE: WIENER_BOERSE_CLOSURES,
|
|
73
|
+
WBDM: WIENER_BOERSE_CLOSURES,
|
|
64
74
|
MSX: {
|
|
65
75
|
2026: ["01-15", "01-18", "03-19", "03-22", "03-23", "05-26", "05-27", "05-28", "06-18", "08-27", "11-25", "11-26"],
|
|
66
76
|
},
|
|
@@ -260,7 +260,7 @@ const chartComposerTemplates: PaneTemplateDef[] = [
|
|
|
260
260
|
type: "text",
|
|
261
261
|
// Empty falls back to the active ticker in createInstance, as `G` with no argument does.
|
|
262
262
|
required: false,
|
|
263
|
-
body: ["Enter comma-separated SYMBOL:field or FRED:series expressions, or leave empty to chart the active ticker."],
|
|
263
|
+
body: ["Enter comma-separated SYMBOL:field or FRED:series expressions, or leave empty to chart the active ticker. A/B charts the ratio. EUR/USD stays one series."],
|
|
264
264
|
}],
|
|
265
265
|
canCreate: () => true,
|
|
266
266
|
createInstance: (context, options) => {
|