canli-markets-mcp 0.2.1 → 0.3.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/CHANGELOG.md +45 -0
- package/README.md +2 -1
- package/package.json +2 -2
- package/src/earnings.mjs +172 -0
- package/src/events.mjs +44 -5
- package/src/research.mjs +12 -10
- package/src/server.mjs +9 -5
- package/src/sources.mjs +79 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,51 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.3.0
|
|
6
|
+
|
|
7
|
+
- Fix: `event_study` took filing times from the submissions file's `acceptanceDateTime`, which is
|
|
8
|
+
labelled UTC but is not reliable. Measured 2026-10-11 on 450 filings from 25 companies' files:
|
|
9
|
+
- each file was either right for every filing in it, or shifted by one US Eastern offset (+4 h
|
|
10
|
+
in daylight time, +5 h in standard time);
|
|
11
|
+
- the same filing read differently in two files (Apple's and its director's);
|
|
12
|
+
- the shifted files were the ones rebuilt since early October 2026;
|
|
13
|
+
- edgartools#1501 also saw files that give Eastern time labelled UTC.
|
|
14
|
+
|
|
15
|
+
Day 0 turns on the 16:00 close, so a filing could land on the wrong day. With today's +1 shift
|
|
16
|
+
that happens only to afternoon filings: 1 of 31 JPMorgan 8-Ks since 2025, and none of 55 NVIDIA
|
|
17
|
+
insider sales. In a file that labels Eastern time as UTC, every release after the close would
|
|
18
|
+
fall on its own day.
|
|
19
|
+
|
|
20
|
+
Event times are now calibrated per file against the filing index page's "Accepted" time (the
|
|
21
|
+
newest and oldest event; 2 extra requests). If the two disagree, each event is read from its own
|
|
22
|
+
index page. The result says which was done (`event_times`).
|
|
23
|
+
|
|
24
|
+
- New tool `earnings_calendar`: earnings dates from the companies' own 8-Ks under Item 2.02 (results
|
|
25
|
+
of operations).
|
|
26
|
+
- For a company: its recent results releases, the quarter each covered (from its 10-Q and 10-K
|
|
27
|
+
period ends), and whether each came before the open or after the close. The time is EDGAR's
|
|
28
|
+
acceptance time from the filing index, in Eastern time; the submissions API's
|
|
29
|
+
`acceptanceDateTime` is not used (see the `event_study` fix below).
|
|
30
|
+
- The next release is estimated 52 weeks after the one that followed the same quarter a year
|
|
31
|
+
earlier, with a window from how far the company's dates have moved year on year, and marked
|
|
32
|
+
upcoming, due now or late.
|
|
33
|
+
- Across the market: the companies that reported in the same weeks last year, with this year's
|
|
34
|
+
implied date, marked reported once they have (within three weeks).
|
|
35
|
+
- Estimates, not announcements. Checked live: Apple 2026-10-29 after the close, NVIDIA 2026-11-18
|
|
36
|
+
after the close, Walmart 2026-11-19 before the open, JPMorgan 2026-10-13 before the open.
|
|
37
|
+
- `company_events` gives each 8-K the headline of the press release attached to it (an EX-99
|
|
38
|
+
exhibit), for example "Apple reports third quarter results", "Pfizer Announces Chief Financial
|
|
39
|
+
Officer Transition" or "Bank of America Increases Common Stock Dividend 14% to $0.32 Per Share".
|
|
40
|
+
The headline is the first line of real text after EDGAR's document header and the boilerplate; it
|
|
41
|
+
is never the company's letterhead, and an exhibit that opens with financial statements has none.
|
|
42
|
+
These are news headlines from the companies themselves, not a news wire. Reading them adds up to
|
|
43
|
+
two requests per filing; `excerpts: false` skips both.
|
|
44
|
+
- Fix: a ticker that is not in the SEC's file of listed companies went to EDGAR's name search, which
|
|
45
|
+
matches prefixes, so "VOO" (an ETF) was Vooy, Inc. Mutual fund and ETF tickers now resolve through
|
|
46
|
+
the SEC's fund ticker file to the trust that registers them (VOO is Vanguard Index Funds). A
|
|
47
|
+
ticker in neither file is refused rather than guessed. If the fund file cannot be read, names are
|
|
48
|
+
searched as before.
|
|
49
|
+
|
|
5
50
|
## 0.2.1
|
|
6
51
|
|
|
7
52
|
The first 0.2 release on npm: 0.2.0's tools, and a fix for answers that were wrong without warning.
|
package/README.md
CHANGED
|
@@ -44,7 +44,8 @@ received and when, so any figure can be checked against the same document later.
|
|
|
44
44
|
| `list_filings` | What has it filed: by form and date, with links | SEC |
|
|
45
45
|
| `read_filing` | What does the filing say: one 10-K/10-Q item ("risk factors", "md&a"), an 8-K press release (`EX-99.1`), paged | SEC |
|
|
46
46
|
| `search_filings` | Which filings mention a phrase, since 2001 | EDGAR full-text search |
|
|
47
|
-
| `company_events` | A company's news from its 8-Ks (earnings, deals, executive changes, restatements, bankruptcies, cyber incidents)
|
|
47
|
+
| `company_events` | A company's news from its 8-Ks (earnings, deals, executive changes, restatements, bankruptcies, cyber incidents): the headline of the press release attached, and the filing's own words; or one kind of event across the market | Form 8-K |
|
|
48
|
+
| `earnings_calendar` | A company's results release dates with the quarter covered and before the open or after the close, and the next one estimated from the same quarter last year; or who is due to report in a window across the market | SEC EDGAR (8-K Item 2.02) |
|
|
48
49
|
| `segment_revenue` | Where revenue comes from: by segment (with operating income), product and region, three years, checked against total revenue | 10-K/10-Q XBRL |
|
|
49
50
|
| `insider_trades` | Who inside bought or sold, at what price, under a 10b5-1 plan or not | Form 4 |
|
|
50
51
|
| `fund_holdings` | What a fund manager holds, its weights, and what it bought and sold last quarter | 13F |
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canli-markets-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server for markets, funds and the economy from the sources themselves, no key: options chains and volatility, short interest, dark pools, futures positioning, fund and ETF holdings, 13D/13G, 8-K
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "MCP server for markets, funds and the economy from the sources themselves, no key: options chains and volatility, short interest, dark pools, futures positioning, fund and ETF holdings, 13D/13G, earnings dates and 8-K press-release headlines, segment revenue, executive pay, Form D, the Fed, Treasury auctions, World Bank and OECD macro, screens, filings and prices. Every result cites its sources.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
package/src/earnings.mjs
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Earnings dates from the companies' own filings. A results release is filed on Form 8-K under Item
|
|
2
|
+
// 2.02 (Results of Operations and Financial Condition), so a company's 8-Ks give the dates it
|
|
3
|
+
// reported, and the filing index gives the time EDGAR accepted each one (Eastern time).
|
|
4
|
+
//
|
|
5
|
+
// For one company: its recent releases, the quarter each covered and whether it came before the
|
|
6
|
+
// open or after the close, and the next one estimated from the same quarter a year earlier. Across
|
|
7
|
+
// the market: the companies that reported in the same weeks last year, with the date that implies
|
|
8
|
+
// this year, marked when they have already reported. Estimates, not announcements. No key.
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
import { archiveBase, filings, getJson, getText, NotFound, resolveEntity, tickerTable } from "./sources.mjs";
|
|
12
|
+
|
|
13
|
+
const DAY = 86400000;
|
|
14
|
+
const DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "a date as YYYY-MM-DD");
|
|
15
|
+
const iso = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
16
|
+
const plus = (d, days) => iso(Date.parse(d) + days * DAY);
|
|
17
|
+
const between = (a, b) => Math.round((Date.parse(b) - Date.parse(a)) / DAY);
|
|
18
|
+
const median = (xs) => { const s = [...xs].sort((x, y) => x - y); return s.length ? (s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2) : null; };
|
|
19
|
+
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
20
|
+
const weekday = (d) => WEEKDAYS[new Date(`${d}T12:00:00Z`).getUTCDay()];
|
|
21
|
+
// Fifty-two weeks: the same weekday a year on, as most companies keep.
|
|
22
|
+
const YEAR = 364;
|
|
23
|
+
|
|
24
|
+
// EDGAR prints the acceptance time on the filing index in Eastern time ("2026-07-14 06:30:38").
|
|
25
|
+
// The submissions API's acceptanceDateTime is not used: many files shift it by an Eastern offset
|
|
26
|
+
// (see acceptanceTimes in sources.mjs).
|
|
27
|
+
export function acceptedEastern(indexHtml) {
|
|
28
|
+
const m = String(indexHtml).match(/Accepted<\/div>\s*<div class="info">\s*(\d{4}-\d{2}-\d{2}) (\d{2}):(\d{2})(?::\d{2})?/);
|
|
29
|
+
return m ? { date: m[1], time: `${m[2]}:${m[3]}`, minutes: Number(m[2]) * 60 + Number(m[3]) } : null;
|
|
30
|
+
}
|
|
31
|
+
export function timing(minutes) {
|
|
32
|
+
if (minutes == null) return null;
|
|
33
|
+
return minutes < 9 * 60 + 30 ? "before the open" : minutes >= 16 * 60 ? "after the close" : "during market hours";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The releases: 8-Ks carrying Item 2.02, oldest first. A second one within 20 days (a correction,
|
|
37
|
+
// a supplement) belongs to the same quarter and is not a new release.
|
|
38
|
+
export function releasesFrom(rows) {
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const r of [...rows].filter((x) => x.form === "8-K" && String(x.items ?? "").split(",").map((s) => s.trim()).includes("2.02")).sort((x, y) => x.filingDate.localeCompare(y.filingDate))) {
|
|
41
|
+
const last = out[out.length - 1];
|
|
42
|
+
if (last && between(last.date, r.filingDate) <= 20) { last.also.push(r.accessionNumber); continue; }
|
|
43
|
+
out.push({ date: r.filingDate, accession: r.accessionNumber, document: r.primaryDocument, also: [] });
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The next release, from the release that followed the same quarter a year earlier; failing that,
|
|
49
|
+
// from the usual gap between releases. The window is how far the company's dates have moved from
|
|
50
|
+
// one year to the next.
|
|
51
|
+
export function nextRelease(releases, today) {
|
|
52
|
+
if (!releases.length) return null;
|
|
53
|
+
const last = releases[releases.length - 1];
|
|
54
|
+
const yearAgo = releases.findIndex((r) => Math.abs(between(r.date, last.date) - YEAR) <= 30);
|
|
55
|
+
const shifts = [];
|
|
56
|
+
for (const r of releases) {
|
|
57
|
+
const before = releases.find((q) => Math.abs(between(q.date, r.date) - YEAR) <= 30);
|
|
58
|
+
if (before) shifts.push(Math.abs(between(before.date, r.date) - YEAR));
|
|
59
|
+
}
|
|
60
|
+
const window = Math.min(21, Math.max(3, Math.ceil(median(shifts) ?? 7) + 2));
|
|
61
|
+
let expected, basis, basedOn = null;
|
|
62
|
+
const prior = yearAgo >= 0 ? releases[yearAgo + 1] : null;
|
|
63
|
+
if (prior && between(last.date, plus(prior.date, YEAR)) > 30) {
|
|
64
|
+
expected = plus(prior.date, YEAR);
|
|
65
|
+
basedOn = prior.date;
|
|
66
|
+
basis = `52 weeks after ${prior.date}, the release that followed the same quarter a year earlier`;
|
|
67
|
+
} else {
|
|
68
|
+
const gaps = releases.slice(-9).map((r, i, a) => (i ? between(a[i - 1].date, r.date) : null)).filter((g) => g != null);
|
|
69
|
+
const gap = median(gaps);
|
|
70
|
+
if (!gap) return null;
|
|
71
|
+
expected = plus(last.date, Math.round(gap));
|
|
72
|
+
basis = `${Math.round(gap)} days after ${last.date}, the usual gap between its releases (less than a year of history to compare with)`;
|
|
73
|
+
}
|
|
74
|
+
const status = today > plus(expected, window) ? "late: no release filed yet" : today >= plus(expected, -window) ? "due now" : "upcoming";
|
|
75
|
+
return { expected, weekday: weekday(expected), window_days: window, earliest: plus(expected, -window), latest: plus(expected, window), basis, based_on: basedOn, status, year_on_year_moves_days: shifts.slice(-8) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const earningsInput = z.object({
|
|
79
|
+
company: z.string().min(1).max(120).optional().describe("Ticker, CIK or name: its results releases and the next one estimated. Leave out for the market."),
|
|
80
|
+
quarters: z.number().int().min(1).max(20).optional().describe("Past releases to list for a company; default 8."),
|
|
81
|
+
since: DATE.optional().describe("Across the market: first day of the window; default today."),
|
|
82
|
+
until: DATE.optional().describe("Across the market: last day; default 14 days after since, at most 31."),
|
|
83
|
+
limit: z.number().int().min(1).max(500).optional().describe("Across the market: most companies to list (the largest first); default 100."),
|
|
84
|
+
}).strict();
|
|
85
|
+
|
|
86
|
+
async function companyCalendar(session, a) {
|
|
87
|
+
const e = await resolveEntity(session, a.company);
|
|
88
|
+
const today = iso(session.now());
|
|
89
|
+
// The quarters shown, and at least five, so the release a year before the latest is in hand.
|
|
90
|
+
const { sub, rows } = await filings(session, e.cik, { since: plus(today, -(Math.max(a.quarters ?? 8, 5) + 1) * 92) });
|
|
91
|
+
const releases = releasesFrom(rows);
|
|
92
|
+
if (!releases.length) throw new NotFound(`${sub.name} has filed no 8-K under Item 2.02 (results of operations) in the period read${sub.category?.includes("foreign") || (sub.filings?.recent?.form ?? []).includes("6-K") ? "; foreign private issuers furnish results on Form 6-K, which has no item numbers" : ""}.`);
|
|
93
|
+
// The quarter each release covered: the latest fiscal period end from its 10-Qs and 10-Ks in the
|
|
94
|
+
// 100 days before it.
|
|
95
|
+
const ends = [...new Set(rows.filter((r) => /^10-[QK]/.test(r.form) && r.reportDate).map((r) => r.reportDate))].sort();
|
|
96
|
+
const covered = (d) => ends.filter((p) => p < d && between(p, d) <= 100).pop() ?? null;
|
|
97
|
+
const shown = releases.slice(-(a.quarters ?? 8));
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const r of shown) {
|
|
100
|
+
const base = archiveBase(e.cik, r.accession);
|
|
101
|
+
let at = null;
|
|
102
|
+
try { at = acceptedEastern(await getText(session, `${base}/${r.accession}-index.htm`, { maxBytes: 2 * 1024 * 1024 })); } catch (err) { if (!(err instanceof NotFound)) throw err; }
|
|
103
|
+
out.push([r.date, weekday(r.date), at?.time ?? null, timing(at?.minutes), covered(r.date), r.accession, `${base}/${r.document}`]);
|
|
104
|
+
}
|
|
105
|
+
const next = nextRelease(releases, today);
|
|
106
|
+
const lastYearRow = next?.based_on ? out.find((row) => row[0] === next.based_on) : null;
|
|
107
|
+
return {
|
|
108
|
+
company: { cik: e.cik, name: sub.name, ticker: sub.tickers?.[0] ?? e.ticker ?? null, fiscal_year_end: sub.fiscalYearEnd ?? null },
|
|
109
|
+
as_of: today,
|
|
110
|
+
next: next ? { ...next, ...(lastYearRow?.[3] ? { last_year_timing: lastYearRow[3] } : {}) } : null,
|
|
111
|
+
releases: { columns: ["date", "weekday", "accepted_et", "timing", "quarter_ended", "accession", "url"], rows: out.reverse() },
|
|
112
|
+
note: "Dates are when the company filed its results 8-K (Item 2.02); accepted_et is when EDGAR accepted it, usually minutes after the press release. The next date is an estimate from the company's own history, not an announcement: companies move dates, and a release can come before its 8-K.",
|
|
113
|
+
source: `https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${e.cik}&type=8-K&dateb=&owner=include&count=40`,
|
|
114
|
+
limits: ["From the company's own 8-K filings on EDGAR, as filed. Foreign private issuers furnish results on Form 6-K, which has no item numbers, and are not covered.", "Not investment advice."],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Companies that filed a results 8-K in a window, one row per company (the first release in it).
|
|
119
|
+
async function reporters(session, since, until) {
|
|
120
|
+
const by = new Map();
|
|
121
|
+
for (let from = 0; from < 3000; from += 100) {
|
|
122
|
+
const r = await getJson(session, `https://efts.sec.gov/LATEST/search-index?q=%22Item%202.02%22&forms=8-K&dateRange=custom&startdt=${since}&enddt=${until}${from ? `&from=${from}` : ""}`);
|
|
123
|
+
const page = r?.hits?.hits ?? [];
|
|
124
|
+
for (const h of page) {
|
|
125
|
+
const s = h._source ?? {};
|
|
126
|
+
if (!(s.items ?? []).includes("2.02") || !s.ciks?.[0]) continue;
|
|
127
|
+
const cik = Number(s.ciks[0]);
|
|
128
|
+
const prev = by.get(cik);
|
|
129
|
+
if (!prev || s.file_date < prev.date) by.set(cik, { cik, date: s.file_date, name: String(s.display_names?.[0] ?? "").replace(/\s*\(CIK \d+\)\s*$/, "").replace(/\s*\([^)]*\)\s*$/, "").replace(/\s+/g, " ").trim() || null });
|
|
130
|
+
}
|
|
131
|
+
if (page.length < 100) break;
|
|
132
|
+
}
|
|
133
|
+
return by;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function marketCalendar(session, a) {
|
|
137
|
+
const today = iso(session.now());
|
|
138
|
+
const since = a.since ?? today, until = a.until ?? plus(since, 14);
|
|
139
|
+
if (until < since) throw new Error("until must be on or after since.");
|
|
140
|
+
if (between(since, until) > 31) throw new Error("The window can be at most 31 days; narrow since and until.");
|
|
141
|
+
// Last year's same weeks (52 weeks earlier, so weekdays line up), and this year's releases so far.
|
|
142
|
+
const before = await reporters(session, plus(since, -YEAR), plus(until, -YEAR));
|
|
143
|
+
// Reports this year from three weeks before the window to three weeks after it (or today), the
|
|
144
|
+
// same tolerance a release is matched to its expected date with.
|
|
145
|
+
const doneUntil = plus(until, 21) < today ? plus(until, 21) : today;
|
|
146
|
+
const done = since <= today ? await reporters(session, plus(since, -21), doneUntil) : new Map();
|
|
147
|
+
const table = Object.values(await tickerTable(session));
|
|
148
|
+
const rank = new Map(), ticker = new Map();
|
|
149
|
+
table.forEach((r, i) => { if (!rank.has(r.cik_str)) { rank.set(r.cik_str, i); ticker.set(r.cik_str, r.ticker); } });
|
|
150
|
+
const rows = [...before.values()].map((b) => {
|
|
151
|
+
const expected = plus(b.date, YEAR);
|
|
152
|
+
const reported = done.get(b.cik);
|
|
153
|
+
const hit = reported && Math.abs(between(expected, reported.date)) <= 21 ? reported.date : null;
|
|
154
|
+
return { expected, name: b.name, ticker: ticker.get(b.cik) ?? null, status: hit ? "reported" : expected < today ? "expected (date passed, not yet filed)" : "expected", reported_on: hit, last_year: b.date, cik: b.cik, rank: rank.get(b.cik) ?? Infinity };
|
|
155
|
+
});
|
|
156
|
+
const listed = rows.filter((r) => Number.isFinite(r.rank));
|
|
157
|
+
const pick = [...listed].sort((x, y) => x.rank - y.rank).slice(0, a.limit ?? 100).sort((x, y) => x.expected.localeCompare(y.expected) || x.rank - y.rank);
|
|
158
|
+
return {
|
|
159
|
+
since, until, as_of: today,
|
|
160
|
+
companies: rows.length, listed: listed.length, shown: pick.length,
|
|
161
|
+
columns: ["expected", "company", "ticker", "status", "reported_on", "last_year", "cik"],
|
|
162
|
+
rows: pick.map((r) => [r.expected, r.name, r.ticker, r.status, r.reported_on, r.last_year, r.cik]),
|
|
163
|
+
note: "Each expected date is 52 weeks after the company's results 8-K (Item 2.02) in the same weeks last year, an estimate rather than an announcement. Listed companies only, largest first (by the SEC's ticker file) when there are more than the limit. A company is marked reported when it filed a results 8-K within three weeks of its expected date. earnings_calendar with company refines one company from its full history.",
|
|
164
|
+
sources: [`https://efts.sec.gov/LATEST/search-index?q=%22Item%202.02%22&forms=8-K&dateRange=custom&startdt=${plus(since, -YEAR)}&enddt=${plus(until, -YEAR)}`, "https://www.sec.gov/files/company_tickers.json"],
|
|
165
|
+
limits: ["From EDGAR full-text search of 8-Ks under Item 2.02, as filed. Companies that changed their schedule, listed in the past year or furnish results on Form 6-K (foreign private issuers) are missed or misdated.", "Not investment advice."],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function earningsCalendar(session, args) {
|
|
170
|
+
const a = earningsInput.parse(args);
|
|
171
|
+
return a.company ? companyCalendar(session, a) : marketCalendar(session, a);
|
|
172
|
+
}
|
package/src/events.mjs
CHANGED
|
@@ -67,6 +67,43 @@ export function itemExcerpt(text, code, max = 420) {
|
|
|
67
67
|
return out ? out.slice(0, max) + (out.length > max ? "…" : "") : null;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// The headline of a press release exhibit: its first line of real text after EDGAR's document header
|
|
71
|
+
// and the boilerplate around it ("Exhibit 99.1", "FOR IMMEDIATE RELEASE", contacts, the dateline).
|
|
72
|
+
const BOILERPLATE = /^(exhibit\s*99(\.\d+)?|ex-?99(\.\d+)?|document|press release|news release|for immediate release|for release|investor (relations|contacts?)|media (relations|contacts?)|contacts?:?|page \d+|\d+|[•■▪●◦\-–—*]+)$/i;
|
|
73
|
+
// A company's name as words, without punctuation or legal suffixes, to tell its letterhead from a headline.
|
|
74
|
+
const nameKey = (n) => String(n ?? "").toLowerCase().replace(/[^a-z0-9& ]+/g, " ").split(/\s+/).filter((w) => w && !["inc", "corp", "corporation", "co", "company", "ltd", "plc", "llc", "lp", "the", "nv", "sa", "ag", "holdings", "group"].includes(w)).join(" ");
|
|
75
|
+
export function pressHeadline(html, company = null) {
|
|
76
|
+
const letterhead = company ? nameKey(company) : null;
|
|
77
|
+
let s = String(html ?? "");
|
|
78
|
+
// EDGAR's document header (<TYPE>, <SEQUENCE>, <FILENAME>, <DESCRIPTION>) comes before <TEXT>.
|
|
79
|
+
const textAt = s.search(/<TEXT>/i);
|
|
80
|
+
if (textAt >= 0) s = s.slice(textAt + 6);
|
|
81
|
+
const lines = htmlToText(s).split("\n").map((x) => x.replace(/\s+/g, " ").replace(/\s*Exhibit 99(\.\d+)?$/i, "").trim()).filter(Boolean);
|
|
82
|
+
for (const l of lines.slice(0, 60)) {
|
|
83
|
+
if (BOILERPLATE.test(l) || /\.(htm|html|jpe?g|png|gif)$/i.test(l)) continue;
|
|
84
|
+
if (letterhead && nameKey(l) === letterhead) continue;
|
|
85
|
+
// An exhibit that opens with financial statements is not a press release.
|
|
86
|
+
if (/^(unaudited |audited )?(condensed |interim )?(consolidated |combined )?(financial (statements?|information|data)\b|financial$|balance sheets?\b|statements? of (operations|income|cash flows|financial (position|condition)))/i.test(l)) return null;
|
|
87
|
+
// A dateline ("NEW YORK, June 18, 2026") or a date alone is not the headline.
|
|
88
|
+
if (/^[A-Z][A-Za-z .,'&-]*,\s+[A-Z][a-z]+\.? \d{1,2},? \d{4}\b/.test(l) || /^[A-Z][a-z]+\.? \d{1,2},? \d{4}$/.test(l)) continue;
|
|
89
|
+
const words = l.split(" ").length;
|
|
90
|
+
if (words < 3 || words > 40 || l.length > 300 || !/[A-Za-z]{4,}/.test(l)) continue;
|
|
91
|
+
return l;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The headline of the press release attached to an 8-K, when it has one (an EX-99 exhibit).
|
|
97
|
+
async function headlineFor(session, cik, accession, company) {
|
|
98
|
+
try {
|
|
99
|
+
const ex = (await filingDocuments(session, cik, accession)).find((d) => /^EX-99/.test(d.type ?? ""));
|
|
100
|
+
return ex ? pressHeadline(await getText(session, ex.url, { maxBytes: 8 * 1024 * 1024 }), company) : null;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err instanceof NotFound) return null;
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
70
107
|
async function excerptFor(session, url, codes) {
|
|
71
108
|
try {
|
|
72
109
|
const text = await getText(session, url, { maxBytes: 8 * 1024 * 1024, transform: htmlToText });
|
|
@@ -86,7 +123,7 @@ export const eventsInput = z.object({
|
|
|
86
123
|
since: DATE.optional().describe("Filed on or after this date; default 180 days ago for a company, 30 days across the market."),
|
|
87
124
|
until: DATE.optional().describe("Filed on or before this date; default today."),
|
|
88
125
|
limit: z.number().int().min(1).max(40).optional().describe("Most events to list; default 15."),
|
|
89
|
-
excerpts: z.boolean().optional().describe("Read each filing for the opening of what it says; default true (
|
|
126
|
+
excerpts: z.boolean().optional().describe("Read each filing for the opening of what it says and its press release's headline; default true (up to three requests per filing)."),
|
|
90
127
|
}).strict();
|
|
91
128
|
|
|
92
129
|
export async function companyEvents(session, args) {
|
|
@@ -94,9 +131,9 @@ export async function companyEvents(session, args) {
|
|
|
94
131
|
const code = a.event ? eventCode(a.event) : null;
|
|
95
132
|
const limit = a.limit ?? 15;
|
|
96
133
|
const until = a.until ?? isoDay(session);
|
|
97
|
-
const columns = ["filed", "company", "items", "events", "excerpt", "accession", "url"];
|
|
134
|
+
const columns = ["filed", "company", "items", "events", "headline", "excerpt", "accession", "url"];
|
|
98
135
|
const limits = [
|
|
99
|
-
"From Forms 8-K as filed with the SEC, usually within four business days of the event; Canli Capital does not edit or verify them. Excerpts are the opening of the filing's text under the item.",
|
|
136
|
+
"From Forms 8-K as filed with the SEC, usually within four business days of the event; Canli Capital does not edit or verify them. Excerpts are the opening of the filing's text under the item; a headline is the title of the press release attached as an exhibit (EX-99), when there is one.",
|
|
100
137
|
"An earnings release (2.02) or investor presentation (7.01) is usually in exhibit 99.1; read it with read_filing and document \"EX-99.1\".",
|
|
101
138
|
"Not investment advice.",
|
|
102
139
|
];
|
|
@@ -111,7 +148,8 @@ export async function companyEvents(session, args) {
|
|
|
111
148
|
const out = [];
|
|
112
149
|
for (const { r, codes } of picked.slice(0, limit)) {
|
|
113
150
|
const url = `${archiveBase(e.cik, r.accessionNumber)}/${String(r.primaryDocument).replace(/^xsl[^/]*\//, "")}`;
|
|
114
|
-
|
|
151
|
+
const read = a.excerpts !== false;
|
|
152
|
+
out.push([r.filingDate, sub.name, codes, decode(codes), read ? await headlineFor(session, e.cik, r.accessionNumber, sub.name) : null, read ? await excerptFor(session, url, code ? [code] : codes) : null, r.accessionNumber, url]);
|
|
115
153
|
}
|
|
116
154
|
const counts = {};
|
|
117
155
|
for (const { codes } of picked) for (const c of codes) if (c !== "9.01") counts[ITEMS[c]?.[0] ?? c] = (counts[ITEMS[c]?.[0] ?? c] ?? 0) + 1;
|
|
@@ -145,7 +183,8 @@ export async function companyEvents(session, args) {
|
|
|
145
183
|
if (main) file = main.name;
|
|
146
184
|
}
|
|
147
185
|
const url = `${archiveBase(cik, adsh)}/${file}`;
|
|
148
|
-
|
|
186
|
+
const read = a.excerpts !== false;
|
|
187
|
+
out.push([s.file_date, (s.display_names?.[0] ?? "").replace(/\s+/g, " ").trim() || null, s.items ?? [], decode(s.items ?? []), read ? await headlineFor(session, cik, adsh, String(s.display_names?.[0] ?? "").replace(/\s*\(.*$/, "")) : null, read ? await excerptFor(session, url, [code]) : null, adsh, url]);
|
|
149
188
|
}
|
|
150
189
|
return { event: { item: code, name: ITEMS[code][0], meaning: ITEMS[code][1] }, since, until, found: hits.length, columns, rows: out, source: `https://efts.sec.gov/LATEST/search-index?q=%22Item%20${code}%22&forms=8-K&dateRange=custom&startdt=${since}&enddt=${until}`, limits };
|
|
151
190
|
}
|
package/src/research.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
|
|
6
6
|
import { parseForm4 } from "./forms.mjs";
|
|
7
|
-
import { archiveBase, filings, getJson, getText, NotFound, resolveEntity, submissions } from "./sources.mjs";
|
|
7
|
+
import { acceptanceTimes, archiveBase, filings, getJson, getText, NotFound, resolveEntity, submissions } from "./sources.mjs";
|
|
8
8
|
import { priceHistory } from "./tools.mjs";
|
|
9
9
|
|
|
10
10
|
const DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "a date as YYYY-MM-DD");
|
|
@@ -456,7 +456,7 @@ export const eventInput = z.object({
|
|
|
456
456
|
}).strict();
|
|
457
457
|
|
|
458
458
|
// The first trading session at or after a filing's acceptance time (16:00 New York close).
|
|
459
|
-
function sessionAfter(iso, tradingDays) {
|
|
459
|
+
export function sessionAfter(iso, tradingDays) {
|
|
460
460
|
const t = new Date(iso);
|
|
461
461
|
const nyDate = new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York", year: "numeric", month: "2-digit", day: "2-digit" }).format(t);
|
|
462
462
|
const nyHour = Number(new Intl.DateTimeFormat("en-US", { timeZone: "America/New_York", hour: "2-digit", hour12: false }).format(t)) % 24;
|
|
@@ -470,29 +470,30 @@ export async function eventStudy(session, args) {
|
|
|
470
470
|
const symbol = a.symbol.toUpperCase(), bench = (a.benchmark ?? "SPY").toUpperCase();
|
|
471
471
|
const [pre, post] = a.window ?? [-1, 5], est = a.estimation_days ?? 120, gap = 10, model = a.model ?? "market";
|
|
472
472
|
// Events: dates, or filing acceptance times.
|
|
473
|
-
let raw = [], source = null;
|
|
473
|
+
let raw = [], source = null, eventTimes = null;
|
|
474
474
|
if (a.events) raw = a.events.map((d) => ({ at: d, kind: "date", label: d }));
|
|
475
475
|
else {
|
|
476
476
|
const e = await resolveEntity(session, symbol);
|
|
477
477
|
const since = a.since ?? daysAgo(session, 3 * 365);
|
|
478
|
-
const { rows } = await filings(session, e.cik, { since });
|
|
478
|
+
const { sub, rows } = await filings(session, e.cik, { since });
|
|
479
479
|
source = `https://data.sec.gov/submissions/CIK${String(e.cik).padStart(10, "0")}.json`;
|
|
480
|
-
const sub = await submissions(session, e.cik);
|
|
481
|
-
const acc = new Map();
|
|
482
|
-
const r = sub.filings?.recent ?? {};
|
|
483
|
-
(r.accessionNumber ?? []).forEach((x, i) => acc.set(x, r.acceptanceDateTime?.[i]));
|
|
484
480
|
if (a.event_type === "earnings" || a.event_type === "8k") {
|
|
485
|
-
for (const f of rows) if (f.form === "8-K" && f.filingDate >= since && (a.event_type === "8k" || String(f.items ?? "").split(",").includes("2.02"))) raw.push({
|
|
481
|
+
for (const f of rows) if (f.form === "8-K" && f.filingDate >= since && (a.event_type === "8k" || String(f.items ?? "").split(",").includes("2.02"))) raw.push({ accession: f.accessionNumber, filed: f.filingDate, kind: "filing", label: `${f.form} ${f.accessionNumber}` });
|
|
486
482
|
} else {
|
|
487
483
|
const code = a.event_type === "insider_buys" ? "P" : "S";
|
|
488
484
|
const f4 = rows.filter((f) => f.form === "4" && f.filingDate >= since).slice(0, 120);
|
|
489
485
|
await Promise.all(f4.map(async (f) => {
|
|
490
486
|
try {
|
|
491
487
|
const doc = parseForm4(await getText(session, `${archiveBase(e.cik, f.accessionNumber)}/${f.primaryDocument.replace(/^xsl[^/]*\//, "")}`, { maxBytes: 4 * 1024 * 1024 }));
|
|
492
|
-
if (doc.rows.some((t) => t.code === code && t.kind === "stock")) raw.push({
|
|
488
|
+
if (doc.rows.some((t) => t.code === code && t.kind === "stock")) raw.push({ accession: f.accessionNumber, filed: f.filingDate, kind: "filing", label: `Form 4 ${f.accessionNumber} (${doc.owners.map((o) => o.name).join("; ")})` });
|
|
493
489
|
} catch { /* unreadable Form 4 skipped */ }
|
|
494
490
|
}));
|
|
495
491
|
}
|
|
492
|
+
// Day 0 turns on before or after the 16:00 close, so the time must be right (acceptanceTimes).
|
|
493
|
+
const { times, shift } = await acceptanceTimes(session, e.cik, sub, raw.map((x) => x.accession));
|
|
494
|
+
for (const x of raw) x.at = times.has(x.accession) ? new Date(times.get(x.accession)).toISOString() : `${x.filed}T21:00:00.000Z`;
|
|
495
|
+
eventTimes = shift == null ? "Acceptance times read from each filing's index page on EDGAR (Eastern time); the submissions file's times did not agree with them."
|
|
496
|
+
: `Acceptance times from EDGAR's submissions file${shift ? `, which shifted them by ${shift > 0 ? "+" : "-"}1 US Eastern offset (4 or 5 hours); corrected` : ""}, checked against the filing index pages of the newest and oldest event.`;
|
|
496
497
|
}
|
|
497
498
|
if (!raw.length) throw new NotFound(`No ${a.event_type ?? "given"} events for ${symbol}${a.event_type ? ` since ${a.since ?? daysAgo(session, 3 * 365)}` : ""}.`);
|
|
498
499
|
raw.sort((x, y) => String(x.at).localeCompare(String(y.at)));
|
|
@@ -540,6 +541,7 @@ export async function eventStudy(session, args) {
|
|
|
540
541
|
...(skipped.length ? { skipped: { columns: ["event", "reason"], rows: skipped.slice(0, 20) } } : {}),
|
|
541
542
|
meaning: "Abnormal return = actual return minus the model's expected return; CAR sums it over the window. t_stat and p_value test whether the mean CAR across events differs from zero (cross-sectional t test). Returns are simple daily returns of adjusted closes.",
|
|
542
543
|
...(source ? { sources: [source] } : {}),
|
|
544
|
+
...(eventTimes ? { event_times: eventTimes } : {}),
|
|
543
545
|
price_source: ps.source,
|
|
544
546
|
limits: [...LIMITS, "Daily data; events on nearby days overlap and are not adjusted for; a few events give a weak test; a significant average is not a trading rule after costs."],
|
|
545
547
|
};
|
package/src/server.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { createSession, evidenceScope } from "./sources.mjs";
|
|
|
15
15
|
import * as Adv from "./advisers.mjs";
|
|
16
16
|
import * as Cal from "./calendar.mjs";
|
|
17
17
|
import * as CB from "./centralbank.mjs";
|
|
18
|
+
import * as Earn from "./earnings.mjs";
|
|
18
19
|
import * as E from "./events.mjs";
|
|
19
20
|
import * as F from "./funds.mjs";
|
|
20
21
|
import * as G from "./global.mjs";
|
|
@@ -29,7 +30,7 @@ import * as T from "./tools.mjs";
|
|
|
29
30
|
|
|
30
31
|
export const SERVER_NAME = "canli-markets-mcp";
|
|
31
32
|
export const SERVER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
32
|
-
export const SERVER_INSTRUCTIONS = "Market data and research from the public sources themselves. Across the market: screen_companies ranks every US-listed company on fundamentals, company_report gives one company's financials, segment_revenue its revenue by segment, product and region, valuation, risk, insiders and filings in one call, event_study measures price reactions to earnings or insider trades, mentions_trend counts a phrase in filings over time. Companies: company_profile, list_filings, then read_filing (section \"risk factors\", \"md&a\" and so on for a 10-K or 10-Q; document \"EX-99.1\" for an 8-K press release); search_filings finds words across all filings; executive_pay reads a proxy's pay-versus-performance table; company_events gives a company's 8-K news (or, by event, the whole market's: restatements, bankruptcies, cyber incidents). Ownership: insider_trades (Form 4) and fund_holdings (a manager's 13F); large_holders (13D/13G: 5 percent holders and activists), investment_advisers (Form ADV); funds: fund_portfolio (what a mutual fund or ETF holds, its flows and returns, from N-PORT); private markets: private_offerings (Form D raises by startups and funds); positioning: short_interest, off_exchange_volume (FINRA dark pools), fails_to_deliver (SEC) and futures_positioning (CFTC Commitments of Traders). Rates and the economy: economic_calendar (when the data comes out), fomc (the Fed's decisions, votes and projections), treasury_yields, treasury_auctions, federal_debt, economic_series (FRED), world_economy (World Bank, every country) and economic_outlook (OECD projections). Options: options_chain, volatility_surface and futures_curve (Cboe delayed data). Prices: price_history for stocks in the US and abroad, currencies, futures, indices and crypto (no key needed; the user's Alpaca or Tiingo key is used for US stocks when set). Every result has its source URL; cite it.";
|
|
33
|
+
export const SERVER_INSTRUCTIONS = "Market data and research from the public sources themselves. Across the market: screen_companies ranks every US-listed company on fundamentals, company_report gives one company's financials, segment_revenue its revenue by segment, product and region, valuation, risk, insiders and filings in one call, event_study measures price reactions to earnings or insider trades, mentions_trend counts a phrase in filings over time. Companies: company_profile, list_filings, then read_filing (section \"risk factors\", \"md&a\" and so on for a 10-K or 10-Q; document \"EX-99.1\" for an 8-K press release); search_filings finds words across all filings; executive_pay reads a proxy's pay-versus-performance table; company_events gives a company's 8-K news (or, by event, the whole market's: restatements, bankruptcies, cyber incidents). earnings_calendar gives a company's results dates and the next one estimated, or who reports in a window. Ownership: insider_trades (Form 4) and fund_holdings (a manager's 13F); large_holders (13D/13G: 5 percent holders and activists), investment_advisers (Form ADV); funds: fund_portfolio (what a mutual fund or ETF holds, its flows and returns, from N-PORT); private markets: private_offerings (Form D raises by startups and funds); positioning: short_interest, off_exchange_volume (FINRA dark pools), fails_to_deliver (SEC) and futures_positioning (CFTC Commitments of Traders). Rates and the economy: economic_calendar (when the data comes out), fomc (the Fed's decisions, votes and projections), treasury_yields, treasury_auctions, federal_debt, economic_series (FRED), world_economy (World Bank, every country) and economic_outlook (OECD projections). Options: options_chain, volatility_surface and futures_curve (Cboe delayed data). Prices: price_history for stocks in the US and abroad, currencies, futures, indices and crypto (no key needed; the user's Alpaca or Tiingo key is used for US stocks when set). Every result has its source URL; cite it.";
|
|
33
34
|
export const SERVER_INFO = Object.freeze({
|
|
34
35
|
name: SERVER_NAME, version: SERVER_VERSION, title: "Canli Markets",
|
|
35
36
|
websiteUrl: "https://canlicapital.com/developers",
|
|
@@ -44,9 +45,10 @@ export const TOOL_DESCRIPTIONS = Object.freeze({
|
|
|
44
45
|
insider_trades: "Insider transactions at a company from its Form 4 filings: who (officers, directors, 10% owners), what (open-market buys and sales, option exercises, tax withholding, gifts), shares, price, value, holdings after, and 10b5-1 plan flags, with a buy/sell summary. Default: the last 90 days.",
|
|
45
46
|
fund_holdings: "A fund manager's 13F holdings for a quarter (latest by default): positions by value with portfolio weights, the total checked against the filing's cover page, and what changed since the previous quarter (new, exited, added, reduced).",
|
|
46
47
|
fund_portfolio: "What a US mutual fund or ETF holds, from its own SEC Form N-PORT (VOO, IVV, QQQ, AGG, FXAIX): every position with value and weight, exposure by asset type, issuer, country and currency, a bond fund's coupon and maturity profile, its monthly returns, the money that came in and went out each month, and what changed since the previous quarter.",
|
|
48
|
+
earnings_calendar: "Earnings dates from the companies' own 8-Ks (Item 2.02, results of operations). For a company: its recent results releases with the quarter each covered and whether it came before the open or after the close (EDGAR's acceptance time), and the next release estimated from the same quarter a year earlier, with a window. Across the market: who is due to report in a window, from when they reported in the same weeks last year, marked once they have. Estimates, not announcements.",
|
|
47
49
|
investment_advisers: "Investment advisers from the SEC's Form ADV database (IAPD) by name or CRD: matching firms with SEC file numbers, status, whether they report disclosure events, offices and branch counts, then the best match in full: registration and since when, other names and relying advisers, its latest ADV date, and links to its Form ADV and brochures.",
|
|
48
50
|
large_holders: "Who owns more than 5 percent of a company, from Schedules 13D and 13G: each holder's latest stake (shares, percent of class, voting power), whether it is passive (13G) or may seek to influence control (13D, with its stated purpose); or, with no company, the newest 13D stakes across the whole market.",
|
|
49
|
-
company_events: "Company news from the source: the events companies must report on Form 8-K (earnings, deals, executive changes, restatements, auditor changes, bankruptcies, cyber incidents, layoffs, delisting notices), for one company or, by event, across the whole market, each with the opening of what the filing says.",
|
|
51
|
+
company_events: "Company news from the source: the events companies must report on Form 8-K (earnings, deals, executive changes, restatements, auditor changes, bankruptcies, cyber incidents, layoffs, delisting notices), for one company or, by event, across the whole market, each with the headline of the press release it attached and the opening of what the filing says.",
|
|
50
52
|
private_offerings: "Private markets from Form D, filed when startups and funds sell securities without registering: amount offered and sold, number of investors, minimum investment, exemption (Rule 506(b) or 506(c)), industry, fund type and the people behind it, for one issuer or across the market (newest first, filtered by industry or amount sold).",
|
|
51
53
|
fails_to_deliver: "Shares that failed to settle, from the SEC's fails-to-deliver data (NSCC): a stock's outstanding fail balance on each settlement date over the latest half-month files, with prices and dollar values, or the securities with the largest fails across the market.",
|
|
52
54
|
off_exchange_volume: "How much of a US stock trades away from the exchanges, from FINRA's OTC transparency data: weekly shares and trades in dark pools (ATSs) and with other off-exchange dealers, and the largest dark pools and dealers in the latest week.",
|
|
@@ -83,9 +85,10 @@ export const TOOL_KEYWORDS = Object.freeze({
|
|
|
83
85
|
insider_trades: "insider insiders buying selling buy sell form 4 executives directors ceo cfo officers stock sales purchases",
|
|
84
86
|
fund_holdings: "13f hedge fund institutional investor holdings portfolio positions manager owns own bought sold stake",
|
|
85
87
|
fund_portfolio: "etf etfs mutual fund funds holdings constituents portfolio composition what does it hold exposure top holdings weights bond fund maturity coupon flows inflows outflows creations redemptions n-port index fund vanguard ishares fidelity invesco",
|
|
88
|
+
earnings_calendar: "earnings date dates calendar when report reports reporting results release next earnings upcoming this week season quarterly results announcement before the open after the close bmo amc schedule due expected",
|
|
86
89
|
investment_advisers: "investment adviser advisers ria registered investment adviser form adv iapd hedge fund manager asset manager wealth manager firm crd disclosures brochure",
|
|
87
90
|
large_holders: "activist activists 13d 13g beneficial owner owners 5 percent stake stakes major shareholders blockholders largest holders who owns control proxy fight hostile",
|
|
88
|
-
company_events: "news events announcement announced 8-k current report ceo cfo resign resignation departure appointed hired fired merger acquisition deal agreement bankruptcy chapter 11 restatement restated auditor cyber hack breach layoffs restructuring impairment delisting earnings release guidance",
|
|
91
|
+
company_events: "news headlines headline press release press releases latest news events announcement announced 8-k current report ceo cfo resign resignation departure appointed hired fired merger acquisition deal agreement bankruptcy chapter 11 restatement restated auditor cyber hack breach layoffs restructuring impairment delisting earnings release guidance",
|
|
89
92
|
private_offerings: "private placement placements form d raise raised fundraising funding round startup startups venture capital vc private equity hedge fund real estate syndication reg d 506b 506c accredited investors",
|
|
90
93
|
fails_to_deliver: "fails to deliver ftd failed settlement naked short selling short squeeze reg sho threshold securities settlement fails nscc",
|
|
91
94
|
off_exchange_volume: "dark pool dark pools ats off exchange otc volume internalization wholesalers citadel virtu where does it trade hidden liquidity block trades",
|
|
@@ -123,6 +126,7 @@ export const OUTPUT_SCHEMAS = Object.freeze({
|
|
|
123
126
|
insider_trades: z.looseObject({ summary: opt(z.looseObject({})), ...table, ...common }).describe("rows are Form 4 transactions, newest first; summary totals open-market purchases and sales."),
|
|
124
127
|
fund_holdings: z.looseObject({ period: opt(z.string()), total_value: opt(z.number()), entries_total: opt(z.number()), positions_total: opt(z.number()), matches_cover_page: opt(z.boolean()), changes_since: opt(z.looseObject({})), ...table, ...common }).describe("rows are positions by value with weights; changes_since compares with the previous quarter."),
|
|
125
128
|
fund_portfolio: z.looseObject({ fund: opt(z.looseObject({})), period: opt(z.string()), net_assets: opt(z.number().nullable()), positions: opt(z.number()), exposure: opt(z.looseObject({})), bonds: opt(z.looseObject({})), monthly_returns: opt(z.looseObject({})), flows: opt(z.looseObject({})), changes_since: opt(z.looseObject({})), ...table, ...common }).describe("rows are the largest positions by value, weights as fractions of net assets; exposure sums weights by asset type, issuer, country and currency; flows are dollars in and out each month."),
|
|
129
|
+
earnings_calendar: z.looseObject({ company: opt(z.looseObject({})), next: opt(z.looseObject({}).nullable()), releases: opt(z.looseObject({})), since: opt(z.string()), until: opt(z.string()), ...table, ...common }).describe("For a company: next is the estimated next release (expected, earliest, latest, basis, status) and releases is a table, newest first. Across the market: rows are companies in the order of columns."),
|
|
126
130
|
investment_advisers: z.looseObject({ matches: opt(z.number()), firm: opt(z.looseObject({})), ...table, ...common }).describe("rows are matching firms in the order of columns; firm is the best match (or the CRD asked for) in full."),
|
|
127
131
|
large_holders: z.looseObject({ company: opt(z.looseObject({})), purposes_13d: opt(z.array(z.unknown())), earlier_text_filings: opt(z.looseObject({})), ...table, ...common }).describe("rows are holders (latest filing each, largest stake first) or, across the market, filings; percent_of_class is a fraction."),
|
|
128
132
|
company_events: z.looseObject({ company: opt(z.looseObject({})), event: opt(z.looseObject({})), counts: opt(z.looseObject({})), ...table, ...common }).describe("rows are 8-K filings, newest first: items are the 8-K item numbers, events their names, excerpt the opening of the filing's text under the item."),
|
|
@@ -152,13 +156,13 @@ export const OUTPUT_SCHEMAS = Object.freeze({
|
|
|
152
156
|
price_history: z.looseObject({ symbol: opt(z.string()), change: opt(z.looseObject({})), dates: opt(z.array(z.string())), close: opt(z.array(z.number())), ...common }).describe("dates, open, high, low, close and volume are aligned columns, oldest first; change.total_return is the window's return with dividends reinvested (a fraction)."),
|
|
153
157
|
});
|
|
154
158
|
|
|
155
|
-
const TITLES = { screen_companies: "Screen companies", company_report: "Company report", event_study: "Event study", mentions_trend: "Mentions over time", company_profile: "Company profile", list_filings: "List filings", read_filing: "Read a filing", search_filings: "Search filings", insider_trades: "Insider trades", fund_holdings: "Fund holdings (13F)", fund_portfolio: "Fund or ETF portfolio (N-PORT)", large_holders: "Large holders (13D/13G)", investment_advisers: "Investment advisers (Form ADV)", company_events: "Company events (8-K)", private_offerings: "Private offerings (Form D)", short_interest: "Short interest (FINRA)", off_exchange_volume: "Dark pool and OTC volume (FINRA)", fails_to_deliver: "Fails to deliver (SEC)", executive_pay: "Executive pay vs performance", fomc: "Fed rate decisions (FOMC)", economic_calendar: "Economic calendar", index_constituents: "Index constituents", options_chain: "Options chain (Cboe)", volatility_surface: "Volatility surface (Cboe)", futures_curve: "Futures curve (Cboe)", segment_revenue: "Revenue by segment", futures_positioning: "Futures positioning (CFTC COT)", treasury_yields: "Treasury yields", economic_series: "Economic series (FRED)", world_economy: "World economy (World Bank)", economic_outlook: "Economic outlook (OECD projections)", treasury_auctions: "Treasury auctions", federal_debt: "US federal debt", price_history: "Price history" };
|
|
159
|
+
const TITLES = { screen_companies: "Screen companies", company_report: "Company report", event_study: "Event study", mentions_trend: "Mentions over time", company_profile: "Company profile", list_filings: "List filings", read_filing: "Read a filing", search_filings: "Search filings", insider_trades: "Insider trades", fund_holdings: "Fund holdings (13F)", fund_portfolio: "Fund or ETF portfolio (N-PORT)", large_holders: "Large holders (13D/13G)", earnings_calendar: "Earnings calendar", investment_advisers: "Investment advisers (Form ADV)", company_events: "Company events (8-K)", private_offerings: "Private offerings (Form D)", short_interest: "Short interest (FINRA)", off_exchange_volume: "Dark pool and OTC volume (FINRA)", fails_to_deliver: "Fails to deliver (SEC)", executive_pay: "Executive pay vs performance", fomc: "Fed rate decisions (FOMC)", economic_calendar: "Economic calendar", index_constituents: "Index constituents", options_chain: "Options chain (Cboe)", volatility_surface: "Volatility surface (Cboe)", futures_curve: "Futures curve (Cboe)", segment_revenue: "Revenue by segment", futures_positioning: "Futures positioning (CFTC COT)", treasury_yields: "Treasury yields", economic_series: "Economic series (FRED)", world_economy: "World economy (World Bank)", economic_outlook: "Economic outlook (OECD projections)", treasury_auctions: "Treasury auctions", federal_debt: "US federal debt", price_history: "Price history" };
|
|
156
160
|
export const TOOLS = Object.freeze({
|
|
157
161
|
screen_companies: [R.screenInput, R.screenCompanies], company_report: [R.reportInput, R.companyReport], event_study: [R.eventInput, R.eventStudy], mentions_trend: [R.trendInput, R.mentionsTrend],
|
|
158
162
|
company_profile: [T.profileInput, T.companyProfile], list_filings: [T.listInput, T.listFilings], read_filing: [T.readInput, T.readFiling],
|
|
159
163
|
search_filings: [T.searchInput, T.searchFilings], insider_trades: [T.insiderInput, T.insiderTrades], fund_holdings: [T.holdingsInput, T.fundHoldings],
|
|
160
164
|
treasury_yields: [T.treasuryInput, T.treasuryYields], economic_series: [T.fredInput, T.economicSeries], price_history: [T.priceInput, T.priceHistory],
|
|
161
|
-
fund_portfolio: [F.portfolioInput, F.fundPortfolio], options_chain: [Opt.chainInput, Opt.optionsChain], volatility_surface: [Opt.surfaceInput, Opt.volatilitySurface], futures_curve: [Opt.curveInput, Opt.futuresCurve], segment_revenue: [Seg.segmentInput, Seg.segmentRevenue], index_constituents: [F.indexInput, F.indexConstituents], fomc: [CB.fomcInput, CB.fomc], economic_calendar: [Cal.calendarInput, Cal.economicCalendar], executive_pay: [Pay.payInput, Pay.executivePay], short_interest: [Pos.shortInput, Pos.shortInterest], off_exchange_volume: [Pos.offExchangeInput, Pos.offExchangeVolume], fails_to_deliver: [Pos.ftdInput, Pos.failsToDeliver], futures_positioning: [Pos.cotInput, Pos.futuresPositioning], private_offerings: [P.offeringsInput, P.privateOfferings], company_events: [E.eventsInput, E.companyEvents], large_holders: [O.holdersInput, O.largeHolders], investment_advisers: [Adv.adviserInput, Adv.investmentAdvisers], world_economy: [G.worldInput, G.worldEconomy], economic_outlook: [G.outlookInput, G.economicOutlook], treasury_auctions: [G.auctionInput, G.treasuryAuctions], federal_debt: [G.debtInput, G.federalDebt],
|
|
165
|
+
fund_portfolio: [F.portfolioInput, F.fundPortfolio], options_chain: [Opt.chainInput, Opt.optionsChain], volatility_surface: [Opt.surfaceInput, Opt.volatilitySurface], futures_curve: [Opt.curveInput, Opt.futuresCurve], segment_revenue: [Seg.segmentInput, Seg.segmentRevenue], index_constituents: [F.indexInput, F.indexConstituents], fomc: [CB.fomcInput, CB.fomc], economic_calendar: [Cal.calendarInput, Cal.economicCalendar], executive_pay: [Pay.payInput, Pay.executivePay], short_interest: [Pos.shortInput, Pos.shortInterest], off_exchange_volume: [Pos.offExchangeInput, Pos.offExchangeVolume], fails_to_deliver: [Pos.ftdInput, Pos.failsToDeliver], futures_positioning: [Pos.cotInput, Pos.futuresPositioning], private_offerings: [P.offeringsInput, P.privateOfferings], company_events: [E.eventsInput, E.companyEvents], large_holders: [O.holdersInput, O.largeHolders], earnings_calendar: [Earn.earningsInput, Earn.earningsCalendar], investment_advisers: [Adv.adviserInput, Adv.investmentAdvisers], world_economy: [G.worldInput, G.worldEconomy], economic_outlook: [G.outlookInput, G.economicOutlook], treasury_auctions: [G.auctionInput, G.treasuryAuctions], federal_debt: [G.debtInput, G.federalDebt],
|
|
162
166
|
});
|
|
163
167
|
const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
164
168
|
|
package/src/sources.mjs
CHANGED
|
@@ -170,6 +170,13 @@ export async function postJson(session, url, payload, opts = {}) {
|
|
|
170
170
|
export const pad10 = (cik) => String(Number(cik)).padStart(10, "0");
|
|
171
171
|
export const archiveBase = (cik, accession) => `https://www.sec.gov/Archives/edgar/data/${Number(cik)}/${String(accession).replace(/-/g, "")}`;
|
|
172
172
|
|
|
173
|
+
// Fund share classes by ticker (mutual funds and ETFs), from the SEC's fund ticker file.
|
|
174
|
+
export async function fundTickers(session) {
|
|
175
|
+
const d = await getJson(session, "https://www.sec.gov/files/company_tickers_mf.json", { maxBytes: 8 * 1024 * 1024 });
|
|
176
|
+
const at = Object.fromEntries((d?.fields ?? []).map((f, i) => [f, i]));
|
|
177
|
+
return new Map((d?.data ?? []).map((r) => [r[at.symbol], { cik: r[at.cik], series: r[at.seriesId], class: r[at.classId] }]));
|
|
178
|
+
}
|
|
179
|
+
|
|
173
180
|
export async function tickerTable(session) {
|
|
174
181
|
return getJson(session, "https://www.sec.gov/files/company_tickers.json");
|
|
175
182
|
}
|
|
@@ -218,6 +225,14 @@ export async function resolveEntity(session, query) {
|
|
|
218
225
|
const table = await tickerTable(session);
|
|
219
226
|
const want = q.toUpperCase().replace(/\./g, "-").replace(/^\$/, "");
|
|
220
227
|
for (const row of Object.values(table)) if (row.ticker === want) return { cik: row.cik_str, name: row.title, ticker: row.ticker, matched_by: "ticker" };
|
|
228
|
+
// Mutual funds and ETFs are in the SEC's fund ticker file, under the trust that registers them. If
|
|
229
|
+
// that file cannot be read, names are searched as before.
|
|
230
|
+
let funds = null;
|
|
231
|
+
try { funds = await fundTickers(session); } catch (err) { if (!(err instanceof NotFound)) throw err; }
|
|
232
|
+
const fund = funds?.get(want);
|
|
233
|
+
if (fund) return { cik: fund.cik, name: null, ticker: want, matched_by: "fund ticker", series: fund.series, class: fund.class };
|
|
234
|
+
// Written like a ticker but in neither file: not a name to guess at ("VOO" is not Vooy, Inc.).
|
|
235
|
+
if (funds && /^\$?[A-Z]{1,5}([.-][A-Z])?$/.test(q)) throw new NotFound(`${want} is not a ticker in the SEC's files of listed companies, mutual funds and ETFs (a delisted or changed ticker, or a foreign listing). Pass the company's name or CIK instead.`);
|
|
221
236
|
const filer = knownAs(nameWords(q).join(" "));
|
|
222
237
|
if (filer && typeof filer !== "string") return { cik: filer.cik, name: filer.name, ticker: null, matched_by: "name" };
|
|
223
238
|
const listed = listedByName(table, q);
|
|
@@ -253,6 +268,70 @@ function filingRows(block) {
|
|
|
253
268
|
return out;
|
|
254
269
|
}
|
|
255
270
|
|
|
271
|
+
// When EDGAR accepted each filing. The submissions files label acceptanceDateTime UTC, but it is not
|
|
272
|
+
// reliable. Measured 2026-10-11 on 450 filings from 25 companies' files:
|
|
273
|
+
// - each file was either right for every filing in it, or shifted by one US Eastern offset
|
|
274
|
+
// (+4 h in daylight time, +5 h in standard time);
|
|
275
|
+
// - the same filing read differently in two files (the issuer's and the insider's);
|
|
276
|
+
// - the shifted files were the ones rebuilt since early October 2026.
|
|
277
|
+
// edgartools#1501 also saw files with Eastern time labelled UTC. So the filing index page's
|
|
278
|
+
// "Accepted" time (Eastern) calibrates a whole file: one probe, a second to check it, and
|
|
279
|
+
// every time is read from its own index page if they disagree.
|
|
280
|
+
const NY = new Intl.DateTimeFormat("en-US", { timeZone: "America/New_York", hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
281
|
+
const HOUR = 3600000;
|
|
282
|
+
export function easternOffsetMs(ms) {
|
|
283
|
+
const at = Math.floor(ms / 1000) * 1000;
|
|
284
|
+
const p = Object.fromEntries(NY.formatToParts(new Date(at)).map((x) => [x.type, x.value]));
|
|
285
|
+
return at - Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour) % 24, Number(p.minute), Number(p.second));
|
|
286
|
+
}
|
|
287
|
+
// An Eastern wall-clock time ("2026-07-14 06:30:38") as a moment.
|
|
288
|
+
export function easternToMs(wall) {
|
|
289
|
+
const asUtc = Date.parse(`${wall.replace(" ", "T")}Z`);
|
|
290
|
+
for (const h of [4, 5]) if (easternOffsetMs(asUtc + h * HOUR) === h * HOUR) return asUtc + h * HOUR;
|
|
291
|
+
return asUtc + 5 * HOUR;
|
|
292
|
+
}
|
|
293
|
+
export function acceptedOnIndex(html) {
|
|
294
|
+
const m = String(html).match(/Accepted<\/div>\s*<div class="info">\s*(\d{4}-\d{2}-\d{2} \d{2}:\d{2})(:\d{2})?/);
|
|
295
|
+
return m ? `${m[1]}${m[2] ?? ":00"}` : null;
|
|
296
|
+
}
|
|
297
|
+
// The file's error in Eastern offsets (-1, 0 or 1) from one filing's true time, or NaN.
|
|
298
|
+
export function fileShift(apiMs, trueMs) {
|
|
299
|
+
const d = apiMs - trueMs, off = easternOffsetMs(trueMs);
|
|
300
|
+
return d === 0 ? 0 : d === off ? 1 : d === -off ? -1 : NaN;
|
|
301
|
+
}
|
|
302
|
+
export function unshift(apiMs, shift) {
|
|
303
|
+
if (shift === 0) return apiMs;
|
|
304
|
+
for (const h of [4, 5]) if (easternOffsetMs(apiMs - shift * h * HOUR) === h * HOUR) return apiMs - shift * h * HOUR;
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
export async function acceptanceTimes(session, cik, sub, accessions) {
|
|
308
|
+
const r = sub.filings?.recent ?? {};
|
|
309
|
+
const api = new Map();
|
|
310
|
+
(r.accessionNumber ?? []).forEach((a, i) => { const ms = Date.parse(r.acceptanceDateTime?.[i] ?? ""); if (Number.isFinite(ms)) api.set(a, ms); });
|
|
311
|
+
const fromIndex = async (acc) => {
|
|
312
|
+
try {
|
|
313
|
+
const wall = acceptedOnIndex(await getText(session, `${archiveBase(cik, acc)}/${acc}-index.htm`, { maxBytes: 2 * 1024 * 1024 }));
|
|
314
|
+
return wall ? easternToMs(wall) : null;
|
|
315
|
+
} catch (err) { if (err instanceof NotFound) return null; throw err; }
|
|
316
|
+
};
|
|
317
|
+
const want = [...new Set(accessions)];
|
|
318
|
+
const inFile = want.filter((a) => api.has(a)).sort((x, y) => api.get(x) - api.get(y));
|
|
319
|
+
const times = new Map();
|
|
320
|
+
const shifts = [];
|
|
321
|
+
for (const acc of new Set([inFile.at(-1), inFile[0]].filter(Boolean))) {
|
|
322
|
+
const t = await fromIndex(acc);
|
|
323
|
+
if (t == null) continue;
|
|
324
|
+
times.set(acc, t);
|
|
325
|
+
shifts.push(fileShift(api.get(acc), t));
|
|
326
|
+
}
|
|
327
|
+
const shift = shifts.length && shifts.every((s) => s === shifts[0]) && !Number.isNaN(shifts[0]) ? shifts[0] : null;
|
|
328
|
+
await Promise.all(want.filter((a) => !times.has(a)).map(async (acc) => {
|
|
329
|
+
const t = shift != null && api.has(acc) ? unshift(api.get(acc), shift) : await fromIndex(acc);
|
|
330
|
+
if (t != null) times.set(acc, t);
|
|
331
|
+
}));
|
|
332
|
+
return { times, shift };
|
|
333
|
+
}
|
|
334
|
+
|
|
256
335
|
// Filings newest first: the recent block (at least a year, up to 1,000 filings), then older pages
|
|
257
336
|
// only while they may hold filings on or after `since`.
|
|
258
337
|
export async function filings(session, cik, { since } = {}) {
|