gloomberb 0.4.0 → 0.5.0
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 +2 -2
- package/package.json +1 -1
- package/src/app.tsx +184 -69
- package/src/cli/commands/ticker.ts +45 -15
- package/src/cli/helpers.ts +4 -2
- package/src/cli/registry.test.ts +42 -35
- package/src/cli.test.ts +4 -2
- package/src/components/chart/chart-control-hint.tsx +39 -0
- package/src/components/chart/chart-controller.test.ts +110 -0
- package/src/components/chart/chart-controller.ts +364 -2
- package/src/components/chart/chart-data.ts +177 -29
- package/src/components/chart/chart-indicator-selector.tsx +53 -0
- package/src/components/chart/chart-pane-settings.ts +20 -0
- package/src/components/chart/chart-renderer.ts +220 -30
- package/src/components/chart/chart-resolution.test.ts +67 -0
- package/src/components/chart/chart-resolution.ts +74 -4
- package/src/components/chart/chart-scroll.test.ts +69 -0
- package/src/components/chart/chart-scroll.ts +104 -0
- package/src/components/chart/chart-types.ts +12 -0
- package/src/components/chart/chart-viewport.test.ts +5 -1
- package/src/components/chart/chart-viewport.ts +1 -1
- package/src/components/chart/chart.test.ts +249 -10
- package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
- package/src/components/chart/comparison-chart-renderer.ts +40 -3
- package/src/components/chart/comparison-stock-chart.tsx +218 -123
- package/src/components/chart/indicators/bands.test.ts +50 -0
- package/src/components/chart/indicators/bands.ts +29 -0
- package/src/components/chart/indicators/moving-averages.test.ts +54 -0
- package/src/components/chart/indicators/moving-averages.ts +42 -0
- package/src/components/chart/indicators/options.test.ts +32 -0
- package/src/components/chart/indicators/options.ts +107 -0
- package/src/components/chart/indicators/oscillators.test.ts +79 -0
- package/src/components/chart/indicators/oscillators.ts +92 -0
- package/src/components/chart/indicators/types.ts +36 -0
- package/src/components/chart/native/chart-rasterizer.ts +51 -0
- package/src/components/chart/native/kitty.test.ts +68 -0
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
- package/src/components/chart/stock-chart.test.ts +239 -0
- package/src/components/chart/stock-chart.tsx +2257 -372
- package/src/components/command-bar/command-bar.test.tsx +337 -5
- package/src/components/command-bar/command-bar.tsx +296 -120
- package/src/components/command-bar/helpers.ts +1 -1
- package/src/components/command-bar/root-shortcuts.ts +33 -4
- package/src/components/command-bar/view-model.test.ts +3 -3
- package/src/components/command-bar/view-model.ts +4 -4
- package/src/components/command-bar/workflow-ops.ts +10 -0
- package/src/components/layout/floating-pane.tsx +3 -1
- package/src/components/layout/header.tsx +5 -4
- package/src/components/layout/pane-sizing.ts +6 -0
- package/src/components/layout/pane.tsx +5 -1
- package/src/components/layout/shell.test.tsx +263 -32
- package/src/components/layout/shell.tsx +10 -6
- package/src/components/layout/status-bar.test.tsx +16 -7
- package/src/components/layout/status-bar.tsx +4 -1
- package/src/components/markdown-editor.tsx +1 -0
- package/src/components/pane-settings-dialog.tsx +13 -152
- package/src/components/pane-template-wizard.tsx +1 -0
- package/src/components/price-selector-dialog.tsx +12 -7
- package/src/components/ticker-badge-text.test.tsx +50 -0
- package/src/components/ticker-badge-text.tsx +17 -2
- package/src/components/ticker-badge.tsx +2 -2
- package/src/components/ticker-list-table.tsx +62 -2
- package/src/components/toggle-list.tsx +15 -2
- package/src/components/ui/data-table.tsx +82 -5
- package/src/components/ui/external-link.tsx +48 -11
- package/src/components/ui/fields.tsx +2 -1
- package/src/components/ui/index.ts +8 -1
- package/src/components/ui/multi-select-chips.tsx +69 -0
- package/src/components/ui/multi-select-dialog.tsx +267 -0
- package/src/components/ui/multi-select.ts +78 -0
- package/src/components/ui/ui.test.tsx +343 -1
- package/src/data/plugin-state-store.ts +30 -19
- package/src/data/resource-store.ts +133 -103
- package/src/data/session-store.ts +19 -12
- package/src/data/sqlite-cache.test.ts +37 -2
- package/src/data/sqlite-database.ts +8 -2
- package/src/data/sqlite-retry.test.ts +63 -0
- package/src/data/sqlite-retry.ts +66 -0
- package/src/data/ticker-store.ts +29 -18
- package/src/index.tsx +6 -1
- package/src/market-data/coordinator.test.ts +114 -5
- package/src/market-data/coordinator.ts +70 -1
- package/src/market-data/hooks.test.tsx +73 -2
- package/src/market-data/hooks.tsx +16 -4
- package/src/notifications/app-notifier.test.ts +177 -0
- package/src/notifications/app-notifier.ts +241 -0
- package/src/pane-settings.test.ts +2 -0
- package/src/plugins/builtin/ai/index.test.tsx +1 -1
- package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
- package/src/plugins/builtin/ai/ticker-context.ts +3 -2
- package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
- package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
- package/src/plugins/builtin/alerts/index.test.tsx +357 -0
- package/src/plugins/builtin/alerts/index.tsx +519 -0
- package/src/plugins/builtin/alerts/types.ts +14 -0
- package/src/plugins/builtin/analytics/index.test.tsx +300 -0
- package/src/plugins/builtin/analytics/index.tsx +775 -0
- package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
- package/src/plugins/builtin/analytics/metrics.ts +155 -0
- package/src/plugins/builtin/ask-ai.test.tsx +2 -0
- package/src/plugins/builtin/chat-controller.test.ts +92 -18
- package/src/plugins/builtin/chat-controller.ts +40 -11
- package/src/plugins/builtin/chat.test.tsx +715 -4
- package/src/plugins/builtin/chat.tsx +420 -119
- package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
- package/src/plugins/builtin/comparison-chart.tsx +1 -2
- package/src/plugins/builtin/correlation/compute.test.ts +147 -0
- package/src/plugins/builtin/correlation/compute.ts +115 -0
- package/src/plugins/builtin/correlation/index.tsx +385 -0
- package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
- package/src/plugins/builtin/correlation/settings.test.ts +34 -0
- package/src/plugins/builtin/correlation/settings.ts +95 -0
- package/src/plugins/builtin/debug.tsx +36 -50
- package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
- package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
- package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
- package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
- package/src/plugins/builtin/earnings/index.tsx +337 -0
- package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
- package/src/plugins/builtin/econ/calendar-source.ts +121 -0
- package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
- package/src/plugins/builtin/econ/fred-client.ts +79 -0
- package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
- package/src/plugins/builtin/econ/index.tsx +999 -0
- package/src/plugins/builtin/econ/types.ts +13 -0
- package/src/plugins/builtin/fred-settings.ts +10 -0
- package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
- package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
- package/src/plugins/builtin/help.test.tsx +1 -5
- package/src/plugins/builtin/help.tsx +1 -16
- package/src/plugins/builtin/insider/index.tsx +417 -0
- package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
- package/src/plugins/builtin/insider/insider-data.ts +49 -0
- package/src/plugins/builtin/layout-manager.tsx +33 -16
- package/src/plugins/builtin/market-movers/index.tsx +573 -0
- package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
- package/src/plugins/builtin/market-movers/screener.ts +105 -0
- package/src/plugins/builtin/options.tsx +4 -3
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
- package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
- package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
- package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
- package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
- package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
- package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
- package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
- package/src/plugins/builtin/sec.tsx +114 -7
- package/src/plugins/builtin/sectors/index.tsx +359 -0
- package/src/plugins/builtin/sectors/sector-data.ts +18 -0
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
- package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
- package/src/plugins/builtin/world-indices/index.tsx +410 -0
- package/src/plugins/builtin/world-indices/indices.ts +52 -0
- package/src/plugins/builtin/yield-curve/index.tsx +242 -0
- package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
- package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
- package/src/plugins/catalog.ts +22 -0
- package/src/plugins/gridlock-notification.ts +15 -0
- package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
- package/src/plugins/ibkr/gateway-service.test.ts +22 -0
- package/src/plugins/ibkr/gateway-service.ts +6 -0
- package/src/plugins/ibkr/index.test.tsx +1 -1
- package/src/plugins/ibkr/index.tsx +8 -2
- package/src/plugins/ibkr/trade-tab.tsx +23 -5
- package/src/plugins/ibkr/trade-utils.test.ts +1 -1
- package/src/plugins/ibkr/trade-utils.ts +7 -6
- package/src/plugins/ibkr/trading-pane.tsx +8 -7
- package/src/plugins/plugin-runtime.test.tsx +2 -0
- package/src/plugins/plugin-runtime.tsx +10 -0
- package/src/plugins/prediction-markets/controller-data.ts +129 -14
- package/src/plugins/prediction-markets/controller.ts +1 -1
- package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
- package/src/plugins/prediction-markets/index.tsx +5 -1
- package/src/plugins/prediction-markets/pane.test.tsx +78 -0
- package/src/plugins/prediction-markets/pane.tsx +7 -3
- package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
- package/src/plugins/prediction-markets/services/fetch.ts +17 -7
- package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
- package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
- package/src/plugins/prediction-markets/test-helpers.tsx +22 -5
- package/src/plugins/registry.ts +45 -13
- package/src/sources/provider-router.test.ts +3 -2
- package/src/sources/sec-edgar.ts +13 -1
- package/src/sources/yahoo-finance.ts +75 -1
- package/src/state/app-context-selector.test.tsx +17 -1
- package/src/state/app-context.test.tsx +24 -0
- package/src/state/app-context.tsx +29 -6
- package/src/theme/colors.ts +5 -0
- package/src/theme/themes.ts +25 -0
- package/src/types/config.test.ts +107 -0
- package/src/types/config.ts +30 -1
- package/src/types/data-provider.ts +13 -0
- package/src/types/plugin.ts +35 -1
- package/src/updater.test.ts +2 -0
- package/src/utils/api-client.ts +12 -0
- package/src/utils/format.test.ts +11 -1
- package/src/utils/format.ts +86 -8
- package/src/utils/inline-content-tokenizer.test.ts +22 -0
- package/src/utils/inline-content-tokenizer.ts +76 -0
- package/src/utils/link-tokenizer.test.ts +27 -0
- package/src/utils/link-tokenizer.ts +93 -0
- package/src/utils/market-format.test.ts +109 -0
- package/src/utils/market-format.ts +299 -0
- package/src/utils/market-status.ts +3 -2
- package/src/utils/selection-clipboard.test.ts +43 -0
- package/src/utils/selection-clipboard.ts +88 -0
- package/src/utils/terminal-input-reset.test.ts +50 -0
- package/src/utils/terminal-input-reset.ts +24 -0
- package/src/utils/throttled-fetch.test.ts +145 -0
- package/src/utils/throttled-fetch.ts +212 -0
- package/src/utils/ticker-tokenizer.test.ts +31 -0
- package/src/utils/ticker-tokenizer.ts +7 -42
- package/src/version.ts +1 -1
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
|
|
3
|
+
import { useKeyboard } from "@opentui/react";
|
|
4
|
+
import type { GloomPlugin, DetailTabProps } from "../../../types/plugin";
|
|
5
|
+
import type { SecFilingItem } from "../../../types/data-provider";
|
|
6
|
+
import {
|
|
7
|
+
useResolvedEntryValue,
|
|
8
|
+
useSecFilingContent,
|
|
9
|
+
useSecFilingsQuery,
|
|
10
|
+
} from "../../../market-data/hooks";
|
|
11
|
+
import { instrumentFromTicker } from "../../../market-data/request-types";
|
|
12
|
+
import { usePaneTicker } from "../../../state/app-context";
|
|
13
|
+
import { colors } from "../../../theme/colors";
|
|
14
|
+
import { Spinner } from "../../../components/spinner";
|
|
15
|
+
import { isUsEquityTicker } from "../../../utils/sec";
|
|
16
|
+
import { formatCompact, formatCurrency } from "../../../utils/format";
|
|
17
|
+
import { parseForm4Xml, transactionTypeLabel, type InsiderTransaction } from "./insider-data";
|
|
18
|
+
|
|
19
|
+
const FORM4_LIMIT = 20;
|
|
20
|
+
const NINETY_DAYS_MS = 90 * 24 * 60 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
|
|
23
|
+
|
|
24
|
+
function formatDate(d: Date | string | number): string {
|
|
25
|
+
const date = d instanceof Date ? d : new Date(d);
|
|
26
|
+
if (isNaN(date.getTime())) return "—";
|
|
27
|
+
return `${MONTH_NAMES[date.getMonth()]} ${String(date.getDate()).padStart(2, " ")} ${date.getFullYear()}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function transactionColor(type: InsiderTransaction["transactionType"]): string {
|
|
31
|
+
if (type === "P") return colors.positive;
|
|
32
|
+
if (type === "S") return colors.negative;
|
|
33
|
+
return colors.textDim;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function wrapLines(text: string, width: number): string[] {
|
|
37
|
+
const words = text.trim().split(/\s+/);
|
|
38
|
+
const lines: string[] = [];
|
|
39
|
+
let current = "";
|
|
40
|
+
for (const word of words) {
|
|
41
|
+
if (!current) { current = word; continue; }
|
|
42
|
+
if (current.length + 1 + word.length <= width) {
|
|
43
|
+
current = `${current} ${word}`;
|
|
44
|
+
} else {
|
|
45
|
+
lines.push(current);
|
|
46
|
+
current = word;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (current) lines.push(current);
|
|
50
|
+
return lines;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderNotice(message: string, width: number) {
|
|
54
|
+
const lines = wrapLines(message, width - 4);
|
|
55
|
+
return (
|
|
56
|
+
<box flexDirection="column" paddingX={1} paddingY={1}>
|
|
57
|
+
{lines.map((line, i) => (
|
|
58
|
+
<box key={i} height={1}>
|
|
59
|
+
<text fg={colors.textDim}>{line}</text>
|
|
60
|
+
</box>
|
|
61
|
+
))}
|
|
62
|
+
</box>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ParsedFiling {
|
|
67
|
+
filing: SecFilingItem;
|
|
68
|
+
transaction: InsiderTransaction | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildSummary(parsed: ParsedFiling[]): string {
|
|
72
|
+
const cutoff = new Date(Date.now() - NINETY_DAYS_MS);
|
|
73
|
+
let buyShares = 0;
|
|
74
|
+
let sellShares = 0;
|
|
75
|
+
let buyValue = 0;
|
|
76
|
+
let sellValue = 0;
|
|
77
|
+
|
|
78
|
+
for (const { transaction } of parsed) {
|
|
79
|
+
if (!transaction) continue;
|
|
80
|
+
const txDate = transaction.filingDate instanceof Date ? transaction.filingDate : new Date(transaction.filingDate);
|
|
81
|
+
if (isNaN(txDate.getTime()) || txDate < cutoff) continue;
|
|
82
|
+
if (transaction.transactionType === "P") {
|
|
83
|
+
buyShares += transaction.shares;
|
|
84
|
+
buyValue += transaction.totalValue ?? 0;
|
|
85
|
+
} else if (transaction.transactionType === "S") {
|
|
86
|
+
sellShares += transaction.shares;
|
|
87
|
+
sellValue += transaction.totalValue ?? 0;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (buyShares === 0 && sellShares === 0) return "No buy/sell activity in last 90 days.";
|
|
92
|
+
|
|
93
|
+
const parts: string[] = [];
|
|
94
|
+
if (buyShares > 0) parts.push(`Bought ${formatCompact(buyShares)} shares (${formatCurrency(buyValue)})`);
|
|
95
|
+
if (sellShares > 0) parts.push(`Sold ${formatCompact(sellShares)} shares (${formatCurrency(sellValue)})`);
|
|
96
|
+
return parts.join(" | ");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function InsiderTab({ width, height, focused }: DetailTabProps) {
|
|
100
|
+
const { ticker } = usePaneTicker();
|
|
101
|
+
const eligibleTicker = isUsEquityTicker(ticker);
|
|
102
|
+
const instrument = instrumentFromTicker(ticker, ticker?.metadata.ticker ?? null);
|
|
103
|
+
|
|
104
|
+
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
105
|
+
const [nameFilter, setNameFilter] = useState<string | null>(null);
|
|
106
|
+
const [detailFiling, setDetailFiling] = useState<ParsedFiling | null>(null);
|
|
107
|
+
|
|
108
|
+
const filingsEntry = useSecFilingsQuery(
|
|
109
|
+
instrument && eligibleTicker ? { instrument, count: FORM4_LIMIT } : null,
|
|
110
|
+
);
|
|
111
|
+
const allFilings = useResolvedEntryValue(filingsEntry) ?? [];
|
|
112
|
+
const form4Filings = allFilings.filter((f) => f.form.trim() === "4");
|
|
113
|
+
|
|
114
|
+
const loading =
|
|
115
|
+
filingsEntry?.phase === "loading" ||
|
|
116
|
+
(filingsEntry?.phase === "refreshing" && allFilings.length === 0);
|
|
117
|
+
const error =
|
|
118
|
+
filingsEntry?.phase === "error"
|
|
119
|
+
? (filingsEntry.error?.message ?? "Failed to load SEC filings")
|
|
120
|
+
: null;
|
|
121
|
+
|
|
122
|
+
// Fetch content for each Form 4 filing sequentially via an index pointer
|
|
123
|
+
const [contentMap, setContentMap] = useState<Map<string, string | null>>(new Map());
|
|
124
|
+
const fetchIndexRef = useRef(0);
|
|
125
|
+
const [fetchPointer, setFetchPointer] = useState(0);
|
|
126
|
+
|
|
127
|
+
// Reset when ticker changes
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
fetchIndexRef.current += 1;
|
|
130
|
+
setContentMap(new Map());
|
|
131
|
+
setFetchPointer(0);
|
|
132
|
+
}, [ticker?.metadata.ticker]);
|
|
133
|
+
|
|
134
|
+
// Pick the next un-fetched filing to load
|
|
135
|
+
const nextToFetch = form4Filings.find((f) => !contentMap.has(f.accessionNumber)) ?? null;
|
|
136
|
+
const contentEntry = useSecFilingContent(nextToFetch);
|
|
137
|
+
const contentValue = useResolvedEntryValue(contentEntry);
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (!nextToFetch) return;
|
|
141
|
+
if (contentEntry?.phase === "error") {
|
|
142
|
+
setContentMap((prev) => new Map(prev).set(nextToFetch.accessionNumber, null));
|
|
143
|
+
setFetchPointer((p) => p + 1);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (contentValue !== null && contentValue !== undefined) {
|
|
147
|
+
setContentMap((prev) => new Map(prev).set(nextToFetch.accessionNumber, contentValue));
|
|
148
|
+
setFetchPointer((p) => p + 1);
|
|
149
|
+
}
|
|
150
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
151
|
+
}, [contentValue, contentEntry?.phase, nextToFetch?.accessionNumber, fetchPointer]);
|
|
152
|
+
|
|
153
|
+
const scrollRef = useRef<ScrollBoxRenderable>(null);
|
|
154
|
+
|
|
155
|
+
const allParsed: ParsedFiling[] = form4Filings.map((filing) => {
|
|
156
|
+
const xml = contentMap.get(filing.accessionNumber) ?? null;
|
|
157
|
+
return {
|
|
158
|
+
filing,
|
|
159
|
+
transaction: xml ? parseForm4Xml(xml) : null,
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Apply name filter
|
|
164
|
+
const parsed = nameFilter
|
|
165
|
+
? allParsed.filter((p) => p.transaction?.reportedName === nameFilter)
|
|
166
|
+
: allParsed;
|
|
167
|
+
|
|
168
|
+
// Keyboard handling
|
|
169
|
+
useKeyboard((event) => {
|
|
170
|
+
if (!focused) return;
|
|
171
|
+
|
|
172
|
+
if (event.name === "escape") {
|
|
173
|
+
if (detailFiling) { setDetailFiling(null); return; }
|
|
174
|
+
if (nameFilter) { setNameFilter(null); setSelectedIdx(0); return; }
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (detailFiling) return; // No navigation in detail view
|
|
179
|
+
|
|
180
|
+
if (event.name === "j" || event.name === "down") {
|
|
181
|
+
setSelectedIdx((prev) => Math.min(prev + 1, parsed.length - 1));
|
|
182
|
+
} else if (event.name === "k" || event.name === "up") {
|
|
183
|
+
setSelectedIdx((prev) => Math.max(prev - 1, 0));
|
|
184
|
+
} else if (event.name === "return") {
|
|
185
|
+
const item = parsed[selectedIdx];
|
|
186
|
+
if (item?.transaction) setDetailFiling(item);
|
|
187
|
+
} else if (event.name === "f") {
|
|
188
|
+
// Toggle name filter on selected row's insider
|
|
189
|
+
const item = parsed[selectedIdx];
|
|
190
|
+
if (item?.transaction) {
|
|
191
|
+
if (nameFilter === item.transaction.reportedName) {
|
|
192
|
+
setNameFilter(null);
|
|
193
|
+
} else {
|
|
194
|
+
setNameFilter(item.transaction.reportedName);
|
|
195
|
+
}
|
|
196
|
+
setSelectedIdx(0);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// Scroll to follow selection
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
const sb = scrollRef.current;
|
|
204
|
+
if (!sb?.viewport || parsed.length === 0 || selectedIdx < 0) return;
|
|
205
|
+
const viewportHeight = Math.max(sb.viewport.height, 1);
|
|
206
|
+
if (selectedIdx < sb.scrollTop) sb.scrollTo(selectedIdx);
|
|
207
|
+
else if (selectedIdx >= sb.scrollTop + viewportHeight) sb.scrollTo(selectedIdx - viewportHeight + 1);
|
|
208
|
+
}, [selectedIdx, parsed.length]);
|
|
209
|
+
|
|
210
|
+
if (!ticker) return <text fg={colors.textDim}>Select a ticker to view insider activity.</text>;
|
|
211
|
+
if (!eligibleTicker) return renderNotice("Insider transactions are only shown for US equities.", width);
|
|
212
|
+
if (loading && allFilings.length === 0) return <Spinner label="Loading insider filings..." />;
|
|
213
|
+
if (error) return renderNotice(`Error: ${error}`, width);
|
|
214
|
+
if (!loading && form4Filings.length === 0) {
|
|
215
|
+
return renderNotice(`No Form 4 filings found for ${ticker.metadata.ticker}.`, width);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Detail view
|
|
219
|
+
if (detailFiling?.transaction) {
|
|
220
|
+
const tx = detailFiling.transaction;
|
|
221
|
+
const filing = detailFiling.filing;
|
|
222
|
+
return (
|
|
223
|
+
<box flexDirection="column" width={width} height={height}>
|
|
224
|
+
<box height={1} paddingX={1}>
|
|
225
|
+
<text fg={colors.textBright} attributes={TextAttributes.BOLD}>◂ {tx.reportedName}</text>
|
|
226
|
+
{tx.title && <text fg={colors.textDim}> — {tx.title}</text>}
|
|
227
|
+
</box>
|
|
228
|
+
<scrollbox flexGrow={1} scrollY focusable={false}>
|
|
229
|
+
<box flexDirection="column" paddingX={1} paddingY={1} gap={1}>
|
|
230
|
+
<box flexDirection="column">
|
|
231
|
+
<box flexDirection="row" height={1}>
|
|
232
|
+
<box width={16}><text fg={colors.textDim}>Transaction</text></box>
|
|
233
|
+
<text fg={transactionColor(tx.transactionType)} attributes={TextAttributes.BOLD}>
|
|
234
|
+
{transactionTypeLabel(tx.transactionType)}
|
|
235
|
+
</text>
|
|
236
|
+
</box>
|
|
237
|
+
<box flexDirection="row" height={1}>
|
|
238
|
+
<box width={16}><text fg={colors.textDim}>Date</text></box>
|
|
239
|
+
<text fg={colors.text}>{formatDate(tx.filingDate)}</text>
|
|
240
|
+
</box>
|
|
241
|
+
<box flexDirection="row" height={1}>
|
|
242
|
+
<box width={16}><text fg={colors.textDim}>Shares</text></box>
|
|
243
|
+
<text fg={colors.text}>{formatCompact(tx.shares)}</text>
|
|
244
|
+
</box>
|
|
245
|
+
<box flexDirection="row" height={1}>
|
|
246
|
+
<box width={16}><text fg={colors.textDim}>Price/Share</text></box>
|
|
247
|
+
<text fg={colors.text}>
|
|
248
|
+
{tx.pricePerShare != null ? formatCurrency(tx.pricePerShare) : "—"}
|
|
249
|
+
</text>
|
|
250
|
+
</box>
|
|
251
|
+
<box flexDirection="row" height={1}>
|
|
252
|
+
<box width={16}><text fg={colors.textDim}>Total Value</text></box>
|
|
253
|
+
<text fg={transactionColor(tx.transactionType)}>
|
|
254
|
+
{tx.totalValue != null ? formatCurrency(tx.totalValue) : "—"}
|
|
255
|
+
</text>
|
|
256
|
+
</box>
|
|
257
|
+
<box flexDirection="row" height={1}>
|
|
258
|
+
<box width={16}><text fg={colors.textDim}>Shares Owned</text></box>
|
|
259
|
+
<text fg={colors.text}>
|
|
260
|
+
{tx.sharesOwned != null ? formatCompact(tx.sharesOwned) : "—"}
|
|
261
|
+
</text>
|
|
262
|
+
</box>
|
|
263
|
+
</box>
|
|
264
|
+
<box flexDirection="column">
|
|
265
|
+
<box height={1}>
|
|
266
|
+
<text fg={colors.textDim} attributes={TextAttributes.BOLD}>Filing</text>
|
|
267
|
+
</box>
|
|
268
|
+
<box flexDirection="row" height={1}>
|
|
269
|
+
<box width={16}><text fg={colors.textDim}>Accession</text></box>
|
|
270
|
+
<text fg={colors.text}>{filing.accessionNumber}</text>
|
|
271
|
+
</box>
|
|
272
|
+
<box flexDirection="row" height={1}>
|
|
273
|
+
<box width={16}><text fg={colors.textDim}>Filed</text></box>
|
|
274
|
+
<text fg={colors.text}>{formatDate(filing.filingDate)}</text>
|
|
275
|
+
</box>
|
|
276
|
+
<box flexDirection="row" height={1}>
|
|
277
|
+
<box width={16}><text fg={colors.textDim}>Form</text></box>
|
|
278
|
+
<text fg={colors.text}>{filing.form}</text>
|
|
279
|
+
</box>
|
|
280
|
+
</box>
|
|
281
|
+
</box>
|
|
282
|
+
</scrollbox>
|
|
283
|
+
</box>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const summary = buildSummary(allParsed);
|
|
288
|
+
const dateW = 12;
|
|
289
|
+
const typeW = 7;
|
|
290
|
+
const sharesW = 10;
|
|
291
|
+
const priceW = 10;
|
|
292
|
+
const valueW = 12;
|
|
293
|
+
const nameW = Math.max(10, width - dateW - typeW - sharesW - priceW - valueW - 2);
|
|
294
|
+
const pendingCount = form4Filings.filter((f) => !contentMap.has(f.accessionNumber)).length;
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<box flexDirection="column" width={width} height={height}>
|
|
298
|
+
{/* Summary bar */}
|
|
299
|
+
<box height={1} paddingX={1} flexDirection="row">
|
|
300
|
+
<text fg={colors.textDim}>{summary}</text>
|
|
301
|
+
{nameFilter && (
|
|
302
|
+
<box marginLeft={1}>
|
|
303
|
+
<text fg={colors.warning}>[filtered: {nameFilter}]</text>
|
|
304
|
+
</box>
|
|
305
|
+
)}
|
|
306
|
+
{pendingCount > 0 && (
|
|
307
|
+
<>
|
|
308
|
+
<box flexGrow={1} />
|
|
309
|
+
<text fg={colors.textMuted}>loading {pendingCount}…</text>
|
|
310
|
+
</>
|
|
311
|
+
)}
|
|
312
|
+
</box>
|
|
313
|
+
|
|
314
|
+
{/* Column headers */}
|
|
315
|
+
<box height={1} paddingX={1} flexDirection="row">
|
|
316
|
+
<box width={dateW}><text fg={colors.textDim}>DATE</text></box>
|
|
317
|
+
<box width={nameW}><text fg={colors.textDim}>INSIDER</text></box>
|
|
318
|
+
<box width={typeW}><text fg={colors.textDim}>TYPE</text></box>
|
|
319
|
+
<box width={sharesW} justifyContent="flex-end"><text fg={colors.textDim}>SHARES</text></box>
|
|
320
|
+
<box width={priceW} justifyContent="flex-end"><text fg={colors.textDim}>PRICE</text></box>
|
|
321
|
+
<box width={valueW} justifyContent="flex-end"><text fg={colors.textDim}>VALUE</text></box>
|
|
322
|
+
</box>
|
|
323
|
+
|
|
324
|
+
<scrollbox ref={scrollRef} flexGrow={1} scrollY focusable={false}>
|
|
325
|
+
<box flexDirection="column">
|
|
326
|
+
{parsed.map(({ filing, transaction }, i) => {
|
|
327
|
+
const isSelected = i === selectedIdx;
|
|
328
|
+
const bg = isSelected ? colors.selected : undefined;
|
|
329
|
+
|
|
330
|
+
if (!transaction) {
|
|
331
|
+
const isLoading = !contentMap.has(filing.accessionNumber);
|
|
332
|
+
return (
|
|
333
|
+
<box key={filing.accessionNumber} height={1} paddingX={1} flexDirection="row" backgroundColor={bg}>
|
|
334
|
+
<box width={dateW}>
|
|
335
|
+
<text fg={colors.textDim}>{formatDate(filing.filingDate)}</text>
|
|
336
|
+
</box>
|
|
337
|
+
<box flexGrow={1}>
|
|
338
|
+
<text fg={colors.textMuted}>{isLoading ? "loading…" : "—"}</text>
|
|
339
|
+
</box>
|
|
340
|
+
</box>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const typeLabel = transactionTypeLabel(transaction.transactionType);
|
|
345
|
+
const typeFg = isSelected ? colors.selectedText : transactionColor(transaction.transactionType);
|
|
346
|
+
const fg = isSelected ? colors.selectedText : colors.text;
|
|
347
|
+
const dimFg = isSelected ? colors.selectedText : colors.textDim;
|
|
348
|
+
|
|
349
|
+
return (
|
|
350
|
+
<box
|
|
351
|
+
key={filing.accessionNumber}
|
|
352
|
+
height={1}
|
|
353
|
+
paddingX={1}
|
|
354
|
+
flexDirection="row"
|
|
355
|
+
backgroundColor={bg}
|
|
356
|
+
onMouseDown={() => setSelectedIdx(i)}
|
|
357
|
+
>
|
|
358
|
+
<box width={dateW}>
|
|
359
|
+
<text fg={dimFg}>{formatDate(transaction.filingDate)}</text>
|
|
360
|
+
</box>
|
|
361
|
+
<box
|
|
362
|
+
width={nameW}
|
|
363
|
+
onMouseDown={(ev: any) => {
|
|
364
|
+
ev.preventDefault?.();
|
|
365
|
+
if (nameFilter === transaction.reportedName) {
|
|
366
|
+
setNameFilter(null);
|
|
367
|
+
} else {
|
|
368
|
+
setNameFilter(transaction.reportedName);
|
|
369
|
+
setSelectedIdx(0);
|
|
370
|
+
}
|
|
371
|
+
}}
|
|
372
|
+
>
|
|
373
|
+
<text fg={fg} attributes={TextAttributes.UNDERLINE}>
|
|
374
|
+
{transaction.reportedName.slice(0, nameW - 1)}
|
|
375
|
+
</text>
|
|
376
|
+
</box>
|
|
377
|
+
<box width={typeW}>
|
|
378
|
+
<text fg={typeFg} attributes={TextAttributes.BOLD}>{typeLabel}</text>
|
|
379
|
+
</box>
|
|
380
|
+
<box width={sharesW} justifyContent="flex-end">
|
|
381
|
+
<text fg={fg}>{formatCompact(transaction.shares)}</text>
|
|
382
|
+
</box>
|
|
383
|
+
<box width={priceW} justifyContent="flex-end">
|
|
384
|
+
<text fg={fg}>
|
|
385
|
+
{transaction.pricePerShare != null ? formatCurrency(transaction.pricePerShare) : "—"}
|
|
386
|
+
</text>
|
|
387
|
+
</box>
|
|
388
|
+
<box width={valueW} justifyContent="flex-end">
|
|
389
|
+
<text fg={typeFg}>
|
|
390
|
+
{transaction.totalValue != null ? formatCompact(transaction.totalValue) : "—"}
|
|
391
|
+
</text>
|
|
392
|
+
</box>
|
|
393
|
+
</box>
|
|
394
|
+
);
|
|
395
|
+
})}
|
|
396
|
+
</box>
|
|
397
|
+
</scrollbox>
|
|
398
|
+
</box>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export const insiderPlugin: GloomPlugin = {
|
|
403
|
+
id: "insider",
|
|
404
|
+
name: "Insider Trading",
|
|
405
|
+
version: "1.0.0",
|
|
406
|
+
description: "SEC Form 4 insider transaction activity",
|
|
407
|
+
|
|
408
|
+
setup(ctx) {
|
|
409
|
+
ctx.registerDetailTab({
|
|
410
|
+
id: "insider",
|
|
411
|
+
name: "Insider",
|
|
412
|
+
order: 47,
|
|
413
|
+
component: InsiderTab,
|
|
414
|
+
isVisible: ({ ticker }) => isUsEquityTicker(ticker),
|
|
415
|
+
});
|
|
416
|
+
},
|
|
417
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseForm4Xml, transactionTypeLabel } from "./insider-data";
|
|
3
|
+
|
|
4
|
+
const SAMPLE_FORM4 = `<?xml version="1.0"?>
|
|
5
|
+
<ownershipDocument>
|
|
6
|
+
<reportingOwner>
|
|
7
|
+
<reportingOwnerId><rptOwnerName>COOK TIMOTHY D</rptOwnerName></reportingOwnerId>
|
|
8
|
+
<reportingOwnerRelationship>
|
|
9
|
+
<isOfficer>1</isOfficer>
|
|
10
|
+
<officerTitle>Chief Executive Officer</officerTitle>
|
|
11
|
+
</reportingOwnerRelationship>
|
|
12
|
+
</reportingOwner>
|
|
13
|
+
<nonDerivativeTable>
|
|
14
|
+
<nonDerivativeTransaction>
|
|
15
|
+
<transactionDate><value>2024-04-01</value></transactionDate>
|
|
16
|
+
<transactionCoding><transactionCode>S</transactionCode></transactionCoding>
|
|
17
|
+
<transactionAmounts>
|
|
18
|
+
<transactionShares><value>50000</value></transactionShares>
|
|
19
|
+
<transactionPricePerShare><value>171.50</value></transactionPricePerShare>
|
|
20
|
+
</transactionAmounts>
|
|
21
|
+
<postTransactionAmounts>
|
|
22
|
+
<sharesOwnedFollowingTransaction><value>3500000</value></sharesOwnedFollowingTransaction>
|
|
23
|
+
</postTransactionAmounts>
|
|
24
|
+
</nonDerivativeTransaction>
|
|
25
|
+
</nonDerivativeTable>
|
|
26
|
+
</ownershipDocument>`;
|
|
27
|
+
|
|
28
|
+
describe("parseForm4Xml", () => {
|
|
29
|
+
test("parses insider sale from Form 4 XML", () => {
|
|
30
|
+
const result = parseForm4Xml(SAMPLE_FORM4);
|
|
31
|
+
expect(result).not.toBeNull();
|
|
32
|
+
expect(result!.reportedName).toBe("COOK TIMOTHY D");
|
|
33
|
+
expect(result!.title).toBe("Chief Executive Officer");
|
|
34
|
+
expect(result!.transactionType).toBe("S");
|
|
35
|
+
expect(result!.shares).toBe(50000);
|
|
36
|
+
expect(result!.pricePerShare).toBe(171.50);
|
|
37
|
+
expect(result!.totalValue).toBe(50000 * 171.50);
|
|
38
|
+
expect(result!.sharesOwned).toBe(3500000);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returns null for non-Form-4 XML", () => {
|
|
42
|
+
expect(parseForm4Xml("<html>not a form</html>")).toBeNull();
|
|
43
|
+
expect(parseForm4Xml("")).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("transactionTypeLabel", () => {
|
|
48
|
+
test("maps codes to labels", () => {
|
|
49
|
+
expect(transactionTypeLabel("P")).toBe("BUY");
|
|
50
|
+
expect(transactionTypeLabel("S")).toBe("SELL");
|
|
51
|
+
expect(transactionTypeLabel("A")).toBe("AWARD");
|
|
52
|
+
expect(transactionTypeLabel("D")).toBe("DISPOSE");
|
|
53
|
+
expect(transactionTypeLabel("")).toBe("—");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export interface InsiderTransaction {
|
|
2
|
+
filingDate: Date;
|
|
3
|
+
reportedName: string;
|
|
4
|
+
title: string;
|
|
5
|
+
transactionType: "P" | "S" | "A" | "D" | "";
|
|
6
|
+
shares: number;
|
|
7
|
+
pricePerShare: number | null;
|
|
8
|
+
totalValue: number | null;
|
|
9
|
+
sharesOwned: number | null;
|
|
10
|
+
form: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseForm4Xml(xml: string): InsiderTransaction | null {
|
|
14
|
+
const nameMatch = xml.match(/<rptOwnerName>([^<]+)<\/rptOwnerName>/);
|
|
15
|
+
if (!nameMatch) return null;
|
|
16
|
+
|
|
17
|
+
const titleMatch = xml.match(/<officerTitle>([^<]+)<\/officerTitle>/);
|
|
18
|
+
const codeMatch = xml.match(/<transactionCode>([^<]+)<\/transactionCode>/);
|
|
19
|
+
const sharesMatch = xml.match(/<transactionShares>[\s\S]*?<value>([^<]+)<\/value>/);
|
|
20
|
+
const priceMatch = xml.match(/<transactionPricePerShare>[\s\S]*?<value>([^<]+)<\/value>/);
|
|
21
|
+
const ownedMatch = xml.match(/<sharesOwnedFollowingTransaction>[\s\S]*?<value>([^<]+)<\/value>/);
|
|
22
|
+
const dateMatch = xml.match(/<transactionDate>[\s\S]*?<value>([^<]+)<\/value>/);
|
|
23
|
+
|
|
24
|
+
const shares = sharesMatch ? parseFloat(sharesMatch[1]!) : 0;
|
|
25
|
+
const price = priceMatch ? parseFloat(priceMatch[1]!) : null;
|
|
26
|
+
const code = codeMatch?.[1] ?? "";
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
filingDate: dateMatch ? new Date(dateMatch[1]!) : new Date(),
|
|
30
|
+
reportedName: nameMatch[1]!.trim(),
|
|
31
|
+
title: titleMatch?.[1]?.trim() ?? "",
|
|
32
|
+
transactionType: (code === "P" || code === "S" || code === "A" || code === "D") ? code : "",
|
|
33
|
+
shares: isNaN(shares) ? 0 : shares,
|
|
34
|
+
pricePerShare: price !== null && !isNaN(price) ? price : null,
|
|
35
|
+
totalValue: price !== null && !isNaN(price) && !isNaN(shares) ? shares * price : null,
|
|
36
|
+
sharesOwned: ownedMatch ? parseFloat(ownedMatch[1]!) : null,
|
|
37
|
+
form: "4",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function transactionTypeLabel(type: InsiderTransaction["transactionType"]): string {
|
|
42
|
+
switch (type) {
|
|
43
|
+
case "P": return "BUY";
|
|
44
|
+
case "S": return "SELL";
|
|
45
|
+
case "A": return "AWARD";
|
|
46
|
+
case "D": return "DISPOSE";
|
|
47
|
+
default: return "—";
|
|
48
|
+
}
|
|
49
|
+
}
|