diff --git a/.claude/skills/quartr/SKILL.md b/.claude/skills/quartr/SKILL.md index a9a8a2a..7c1d1db 100644 --- a/.claude/skills/quartr/SKILL.md +++ b/.claude/skills/quartr/SKILL.md @@ -1,6 +1,6 @@ --- name: quartr -description: Query Quartr Public API v3 via the local `quartr` CLI — companies, events, earnings calls, transcripts, reports, slides, audio, live events. Use when the user asks about a ticker's earnings or fiscal periods, SEC filings (10-K / 10-Q / 8-K / 20-F / proxy) via Quartr, downloading transcripts or reports, streaming live calls, or anything sourced from api.quartr.com / quartr.com. +description: Query Quartr Public API v3 via the local `quartr` CLI — companies, events, earnings calls, transcripts, reports, slides, parsed Markdown of reports and slides, audio, live events. Use when the user asks about a ticker's earnings or fiscal periods, SEC filings (10-K / 10-Q / 8-K / 20-F / proxy) via Quartr, reading or downloading transcripts, reports or slides as text, streaming live calls, or anything sourced from api.quartr.com / quartr.com. --- # quartr @@ -92,8 +92,11 @@ explicitly passed, `--all` raises it to 500 to minimize round-trips. ```bash # Companies quartr companies list --tickers AAPL,MSFT --fields id,name,country +quartr companies list --openfigis BBG000B9XRY4 quartr companies resolve CE # every company using that ticker +quartr companies resolve BBG000B9XRY4 # a CIK or OpenFIGI works too quartr companies get 4742 --format json +quartr companies segments 4742 # legacy dataset; 403 on most plans # Events (earnings calls, AGMs, etc.) quartr events list --tickers AAPL --sort-by date --direction desc --limit 10 @@ -109,12 +112,15 @@ quartr transcripts chapters --levels 1,2 # Reports (10-K, 10-Q, 8-K, etc.) quartr reports list --tickers AAPL --type-ids 11 --limit 5 +quartr reports text # parsed Markdown on stdout — read this, not the PDF +quartr reports text --output 10k.md quartr reports download --output annual-report.pdf quartr reports pages --format csv quartr reports summary --length long --plain # Slides quartr slides list --tickers AAPL --limit 5 +quartr slides text # parsed Markdown of the deck quartr slides download quartr slides pages @@ -158,13 +164,22 @@ quartr request get /events --query tickers=AAPL --query limit=3 --format json Credito Emiliano and Cortus Energy. Run `quartr companies resolve ` when a symbol might be shared, then either use `--company-ids` or qualify the ticker as `NYSE:BLD` (the CLI resolves it to a companyId before querying). - There is no name search in the API — tickers and CIKs only. + There is no name search in the API — tickers, CIKs and OpenFIGIs only + (`--openfigis` on `companies list`; `resolve` recognises a FIGI by shape). - **`--expand company` is a client-side join.** The API rejects `expand=company`; the CLI strips it and batch-fetches `/companies` instead. Use it whenever rows need to be attributable — otherwise they carry only a bare `companyId` and a collision is invisible. +- **Read reports and slides with `text`, not `download`.** `reports text ` + and `slides text ` print Quartr's parsed Markdown (headings and tables + preserved) on stdout — pipe it to `head`, redirect it, or feed it to a model. + `download` fetches the PDF and is only right when the user wants the file. + `text` is a separate paid package: without it the endpoint returns 403. + Transcripts have no `text`; `transcripts download --output -` is already JSON. - **Tier-restricted endpoints** return `403 Forbidden` on the user's API tier. - Observed restrictions: `events summary`, `audio list`, `live transcripts list`. + Observed restrictions: `events summary`, `audio list`, `live transcripts list`, + `companies segments`; `reports text` / `slides text` without the parsed + documents package. The CLI prints a `hint:` line clarifying that 403 is entitlement, not authentication. Surface the error verbatim — do not retry, hide, silently fall back, or start debugging the API key. A rejected key returns 401. @@ -186,6 +201,7 @@ For a worked example of each, see `references/recipes.md`: - Find a company by ticker and grab its ID - Pull the last N earnings calls for a ticker - Download the latest annual report (10-K) +- Read a report or slide deck as Markdown - Fetch all transcripts for a ticker, paginated, with parent event metadata - Stream a live earnings transcript - Use the raw `request get` for an unwrapped endpoint diff --git a/.claude/skills/quartr/references/commands.md b/.claude/skills/quartr/references/commands.md index cfd8f31..5e9170b 100644 --- a/.claude/skills/quartr/references/commands.md +++ b/.claude/skills/quartr/references/commands.md @@ -8,11 +8,11 @@ specific flag or endpoint at hand. | Command | Operations | Base path | Notes | |--------------------|---------------------------------------------|----------------------------|--------------------------------| | `auth` | `login`, `show`, `logout` | (local) | Manages `~/.config/quartr/config.json` | -| `companies` | `list`, `get`, `resolve` | `/companies` | Uses `ids` API param, not `companyIds`; `resolve ` lists collision candidates | +| `companies` | `list`, `get`, `resolve`, `segments` | `/companies` | Uses `ids` API param, not `companyIds`; `resolve ` lists collision candidates; `segments` is a legacy dataset (403 on most plans) | | `events` | `list`, `get`, `summary` | `/events` | `summary` is tier-restricted | | `documents` | `list`, `get`, `download` | `/documents` | Generic parent; prefer typed resources | -| `reports` | `list`, `get`, `summary`, `pages`, `download` | `/documents/reports` | `fileUrl` is the download field | -| `slides` | `list`, `get`, `summary`, `pages`, `download` | `/documents/slides` | `fileUrl` is the download field | +| `reports` | `list`, `get`, `summary`, `pages`, `text`, `download` | `/documents/reports` | `fileUrl` is the download field; `text` streams parsed Markdown (paid package) | +| `slides` | `list`, `get`, `summary`, `pages`, `text`, `download` | `/documents/slides` | `fileUrl` is the download field; `text` streams parsed Markdown (paid package) | | `transcripts` | `list`, `get`, `summary`, `chapters`, `download` | `/documents/transcripts` | `fileUrl` is the download field | | `audio` | `list`, `get`, `chapters`, `download` | `/audio` | `list` may be tier-restricted; `fileUrl` | | `live` | `list`, `get` | `/live` | Honors `transcriptVersion` | @@ -57,6 +57,7 @@ Auth precedence: flags > env > config file > defaults. --exchanges NYSE,NASDAQ exchange symbols --isins US0378331005 ISINs --ciks 0000320193 SEC CIKs +--openfigis BBG000B9XRY4 OpenFIGI codes (figi, compositeFigi or shareClassFigi); companies only --ids foo,bar alias used by companies-only consumers --start-date 2024-01-01 ISO 8601 --end-date 2024-12-31 ISO 8601 @@ -96,6 +97,12 @@ company` on rows that carry no companyId. Both exit 2. | ` summary` | `--plain` | Strip embedded document sources | | ` summary` | `--fields` | Output columns | | ` pages` | list flags | reports, slides only | +| ` text` | (none) | reports, slides only; prints parsed Markdown on **stdout** | +| ` text` | `--output PATH` | Write the Markdown to a file instead; `Saved ` on stderr | +| ` text` | `--metadata` | Print the envelope (`documentId`, `textUrl`, `updatedAt`) instead of fetching it | +| ` text` | `--fields` | Output columns, only with `--metadata` | +| ` text` | `--with-api-key` | Send `x-api-key` when fetching `textUrl` | +| ` segments` | list flags | companies only (`limit`, `cursor`, `direction`) | | ` chapters` | list flags + `--levels` | transcripts, audio only | | ` download` | `--output PATH` | Defaults to `-.` in cwd; `-` streams to stdout | | ` download` | (status line) | `Saved ` goes to **stderr**, never stdout | @@ -205,12 +212,18 @@ and pass it via `--type-ids`. ## Key code locations (for skill maintenance) +Quartr publishes the live spec at +and a docs index at (datasets, changelog at +`/docs/changelogs/api-updates.md`). When Quartr announces a new dataset, diff +the spec's `paths` against the `resources` map first — that is how `text`, +`segments`, and `openfigis` were found. + If the CLI gets new commands, refresh this reference from: - `internal/cli/app.go` — top-level command dispatch - `internal/cli/resources.go` — resource map, paths, allowed param sets - `internal/cli/flags.go` — global flags, listFlags, `toParams` -- `internal/cli/handlers.go` — list/get/summary/pages/chapters/download/stream/request +- `internal/cli/handlers.go` — list/get/summary/pages/text/chapters/segments/download/stream/request - `internal/output/output.go` — format implementations, dotted-path lookup - `internal/quartr/client.go` — retry, backoff, BuildURL - `internal/quartr/config.go` — config file precedence and shape diff --git a/.claude/skills/quartr/references/recipes.md b/.claude/skills/quartr/references/recipes.md index 8408e2c..b64cce5 100644 --- a/.claude/skills/quartr/references/recipes.md +++ b/.claude/skills/quartr/references/recipes.md @@ -41,10 +41,11 @@ quartr companies resolve CE # 16930 Cortus Energy SE OM:CE ``` -`resolve` takes a ticker, an `EXCHANGE:TICKER` pair, or a CIK, and lists every -candidate with the exchange pairs that matched. There is no name search — the -API has no `search`/`query`/`name` parameter — so never try to look a company -up by name. +`resolve` takes a ticker, an `EXCHANGE:TICKER` pair, a CIK, or an OpenFIGI +(`BBG000B9XRY4`), and lists every candidate with the exchange pairs that +matched. There is no name search — the API has no `search`/`query`/`name` +parameter — so never try to look a company up by name. When the user hands you +a FIGI, `quartr companies list --openfigis ` is exact and collision-free. Once the exchange is known, qualify the ticker anywhere `--tickers` is accepted and the CLI resolves it to a companyId before querying: @@ -145,6 +146,58 @@ country first. `fileUrl` is publicly fetchable. Only add it if the file URL itself returns 401/403. +**If the user wants to read the filing rather than have the PDF**, skip the +download and use recipe 3a. + +--- + +## 3a. Read a report or slide deck as Markdown + +**Intent:** "What does Apple's latest 10-K say about services margins?" / +"Summarize this deck" / anything where the content matters and the PDF is a +detour. + +Quartr's parsed documents package renders each report and slide deck to one +Markdown file with headings and tables preserved. `text` fetches it: + +```bash +quartr reports text # Markdown on stdout +quartr reports text --output 10k.md # or to a file +quartr slides text | head -80 # first slides of a deck +``` + +Find the id the same way as recipe 3 (pull wide, sort locally on `createdAt`), +then: + +```bash +quartr reports text 105446 > apple-q4-2019.md +``` + +Tables come through as pipe tables, so financial statements are greppable: + +```bash +quartr reports text 105446 | grep -i 'total net sales' +# |Total net sales (1)|64,040|62,900|260,174|265,595| +``` + +Response shape of `--metadata` (for provenance or freshness checks): + +```json +{"data":{"documentId":105446,"textUrl":"https://files.quartr.com/document-artifacts/…/….markdown?ref=…","updatedAt":"2026-08-05T22:36:36.000Z","createdAt":"…"}} +``` + +**Pitfalls:** + +- `text` prints to stdout by default; `download` writes a file by default. Do + not expect a `Saved` line from `text` unless you passed `--output`. +- Parsed text is a separately licensed package. A `403` here with a working + key elsewhere means the plan lacks it — say so, do not debug auth. +- Coverage is per document: a recent filing may have `text` before an old one + does. If `text` fails for one document, fall back to `download` and say the + parsed rendering is not available for it. +- Transcripts have no `text` endpoint: `quartr transcripts download + --output -` already returns structured JSON with the speaker turns. + --- ## 4. Fetch all transcripts for a ticker, paginated, with parent event metadata diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d06ed4..725fdc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: - uses: golangci/golangci-lint-action@v7 with: - version: v2.12.0 + version: v2.13.2 # Exercises the release recipe on every PR. Without this, a broken cross # compile only shows up after a tag is pushed, which is the worst time. diff --git a/.golangci.yml b/.golangci.yml index 7b09b27..1ee28d2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,6 @@ version: "2" run: - go: "1.26" + go: "1.27" linters: default: none enable: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc5e0c4..f199245 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,10 +3,12 @@ repos: hooks: # Uses the golangci-lint on PATH rather than the upstream pre-commit # repo, which builds the linter from source with whatever Go it finds. - # A linter built with Go < 1.26 refuses to load this config ("the Go + # A linter built with Go < 1.27 refuses to load this config ("the Go # language version used to build golangci-lint is lower than the - # targeted Go version"). Install the matching binary with: - # go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.0 + # targeted Go version"), and one built with an older toolchain than + # the `go` on PATH panics loading the standard library. Rebuild it + # with the current toolchain: + # go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 - id: golangci-lint name: golangci-lint entry: golangci-lint run --fix diff --git a/CLAUDE.md b/CLAUDE.md index 400f4e7..062ac36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,10 +19,10 @@ pre-commit run --all-files # lint+test against the whole tree ~/go/bin/golangci-lint run # lint without pre-commit (needs v2.12+) ``` -`pre-commit install` was already run in this clone — every commit runs golangci-lint (with `--fix`) and `go test ./...`. The lint hook shells out to the `golangci-lint` on `PATH` instead of the upstream pre-commit repo, which builds the linter from source with whatever Go it finds; a linter built with Go < 1.26 refuses to load this config. Install the matching binary once: +`pre-commit install` was already run in this clone — every commit runs golangci-lint (with `--fix`) and `go test ./...`. The lint hook shells out to the `golangci-lint` on `PATH` instead of the upstream pre-commit repo, which builds the linter from source with whatever Go it finds; a linter built with Go < 1.27 refuses to load this config, and one built with an older patch release than the `go` on `PATH` panics while type-checking the standard library (`file requires newer Go version`). Whenever the toolchain moves, rebuild the linter with it: ```bash -go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.0 +go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 ``` CI pins the same version through `golangci/golangci-lint-action@v7`. @@ -32,7 +32,7 @@ CI pins the same version through `golangci/golangci-lint-action@v7`. Three internal packages, no external deps (Go stdlib only): - **`internal/quartr`** — HTTP client and config persistence. `Client.GetBytes` retries 429/5xx with backoff (honors `Retry-After`). `LoadConfig`/`SaveConfig` handle the JSON file at `~/.config/quartr/config.json`. -- **`internal/cli`** — command dispatch and request shaping. The whole CLI surface is driven by a single `resources` map in `resources.go` keyed by command name; each entry describes the API path templates, allowed query params (`paramSet`), and download/stream URL fields. `handlers.go` dispatches the operations (list/get/summary/pages/chapters/download/stream) against any resource by reading from that map. Adding a new resource = one map entry, no per-command handler code. +- **`internal/cli`** — command dispatch and request shaping. The whole CLI surface is driven by a single `resources` map in `resources.go` keyed by command name; each entry describes the API path templates, allowed query params (`paramSet`), and download/stream URL fields. `handlers.go` dispatches the operations (list/get/summary/pages/text/chapters/segments/download/stream) against any resource by reading from that map. Adding a new resource = one map entry, no per-command handler code. - **`internal/output`** — formats results as `table` / `json` / `csv` / `raw`. `--fields` supports dotted paths (`event.title`) via `getPath` recursive traversal. `cmd/quartr/main.go` is a 3-line entry point that calls `cli.Run`. @@ -44,10 +44,12 @@ Three internal packages, no external deps (Go stdlib only): - **`--all` auto-bumps `--limit` to 500** unless the user passed `--limit` explicitly. Detection lives in `flagWasPassed` (string-scan over the raw args, since the `flag` package can't distinguish "default" from "explicitly default"). - **`parseInterspersed`** in `flags.go` lets users write `cmd --flag value`. The stdlib `flag` package stops at the first positional, so we shuffle flags before positionals before delegating. - **Downloads do NOT send `x-api-key` by default** — the Quartr `fileUrl` is publicly fetchable. `--with-api-key` is the opt-in. +- **`text` streams to stdout by default; `download` writes a file by default.** Both share `saveURL`. The asymmetry is deliberate: parsed Markdown is meant to be piped or redirected, while PDFs and audio are not. Don't unify them. +- **`/text` returns a link, not text.** `DocumentTextDto` is `{documentId, textUrl, updatedAt, createdAt}`; the Markdown lives at `textUrl` on the CDN. `--metadata` exposes the envelope. ## Lint config notes -- golangci-lint v2 syntax (config has `version: "2"` at top). v1 is built with Go 1.24 and rejects this repo's Go 1.26 target — never downgrade. +- golangci-lint v2 syntax (config has `version: "2"` at top). v1 is built with Go 1.24 and rejects this repo's Go 1.27 target — never downgrade. - `gomodguard` is referenced as `gomodguard_v2` after the v2.12 deprecation rename. - `gocritic.hugeParam` is intentionally disabled — passing `resource` (200B) by value is the design, not a perf bug. - `gosec G304/G602` excluded globally — file paths from CLI args and bounds-checked slice indexes are inherent to the tool. diff --git a/README.md b/README.md index 8d72a4c..3220e94 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ It is designed for API subscribers who want a terminal-friendly interface for co - Supports `table`, `json`, `csv`, and `raw` output. - Supports cursor pagination with `--all`. - Supports downloads from metadata URL fields. +- Fetches Quartr's parsed Markdown for reports and slide decks with `reports text` / `slides text`. - Includes a raw `request get` escape hatch for endpoints or parameters not wrapped yet. - Retries transient `429` and `5xx` responses with short backoff and honors `Retry-After` when present. - Uses only the Go standard library. @@ -188,6 +189,20 @@ List report pages: quartr reports pages 12345 --format csv ``` +Print the parsed Markdown of a report or slide deck (see [Parsed documents](#parsed-documents)): + +```bash +quartr reports text 105446 | head -50 +quartr slides text 152141 --output deck.md +``` + +Look a company up by OpenFIGI: + +```bash +quartr companies list --openfigis BBG000B9XRY4 +quartr companies resolve BBG000B9XRY4 +``` + Stream a live transcript JSONL URL to stdout: ```bash @@ -222,6 +237,7 @@ Most list commands support a shared set of filters where Quartr exposes them: --exchanges --isins --ciks +--openfigis companies only --start-date --end-date --updated-after @@ -354,7 +370,26 @@ hint: 403 means this endpoint is not included in your API tier, not that your ke (a rejected key returns 401). ... ``` -Endpoints observed gated this way: `events summary`, `audio list`, `live transcripts list`. A rejected key returns `401` and gets a hint pointing at `quartr auth show` instead. +Endpoints observed gated this way: `events summary`, `audio list`, `live transcripts list`, `companies segments` (a legacy dataset Quartr no longer opens to new partners), and `reports text` / `slides text` when the parsed documents package is not on the plan. A rejected key returns `401` and gets a hint pointing at `quartr auth show` instead. + +## Parsed documents + +Quartr sells the extracted text of reports and slide decks as a separate package: one Markdown file per document, with headings and tables preserved, meant for search indexes and LLM context windows. The API answers `/documents/reports/{id}/text` and `/documents/slides/{id}/text` with a CDN link rather than the text itself; `text` follows the link for you. + +```bash +quartr reports text 105446 # Markdown on stdout +quartr reports text 105446 --output apple.md # or to a file; `Saved apple.md` goes to stderr +quartr reports text 105446 --metadata # the envelope: documentId, textUrl, updatedAt +quartr slides text 152141 | head -40 +``` + +Unlike `download`, `text` writes to **stdout** by default: the content is text, and the file name Quartr gives it is a content hash. The markdown fetch does not send `x-api-key` unless you pass `--with-api-key`, the same as downloads. + +Without the package the endpoint returns `403`; the CLI's hint says so. Transcripts have no `text` endpoint because `transcripts download` already returns structured JSON. + +## Company segments + +`quartr companies segments ` lists segment breakdowns (revenue or operating income by business line or geography) from S&P 500 annual reports. Quartr marks the dataset legacy and does not open it to new partners, so expect `403` unless your plan predates that. ## Downloads diff --git a/go.mod b/go.mod index 924fa0b..862f143 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/TJC-LP/quartr-cli -go 1.26.2 +go 1.27.1 diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index eae758f..09bcfe4 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -589,3 +589,218 @@ func TestListAllFollowsPagination(t *testing.T) { t.Fatalf("expected both rows, got %s", out.String()) } } + +// textServer serves a parsed-text envelope for reports/1 whose textUrl points +// back at the same server, which answers with body. +func textServer(t *testing.T, body string) (*httptest.Server, *[]string) { + t.Helper() + paths := &[]string{} + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *paths = append(*paths, r.URL.Path) + switch r.URL.Path { + case "/documents/reports/1/text": + if got := r.Header.Get("x-api-key"); got != "secret" { + t.Errorf("expected x-api-key on the metadata request, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"documentId":1,"textUrl":"` + srv.URL + `/artifacts/abc.markdown?ref=x","updatedAt":"2026-08-05T22:36:36.000Z"}}`)) + case "/artifacts/abc.markdown": + if got := r.Header.Get("x-api-key"); got != "" { + t.Errorf("expected no x-api-key on the CDN fetch, got %q", got) + } + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write([]byte(body)) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + })) + return srv, paths +} + +func TestTextStreamsMarkdownToStdout(t *testing.T) { + const body = "# Apple Inc.\n\n|Net sales|64,040|\n" + srv, _ := textServer(t, body) + defer srv.Close() + + dir := t.TempDir() + t.Chdir(dir) + + var out, errOut bytes.Buffer + code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, + "reports", "text", "1"}, &out, &errOut) + if code != 0 { + t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String()) + } + // stdout is the Markdown, byte for byte, so `> report.md` captures it. + if out.String() != body { + t.Fatalf("expected the Markdown on stdout, got %q", out.String()) + } + if errOut.Len() != 0 { + t.Fatalf("expected nothing on stderr, got %q", errOut.String()) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("expected no files written, got %v", entries) + } +} + +func TestTextWritesFileWithOutput(t *testing.T) { + const body = "# Apple Inc.\n" + srv, _ := textServer(t, body) + defer srv.Close() + + dest := filepath.Join(t.TempDir(), "nested", "apple.md") + var out, errOut bytes.Buffer + code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, + "reports", "text", "1", "--output", dest}, &out, &errOut) + if code != 0 { + t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String()) + } + b, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if string(b) != body { + t.Fatalf("unexpected file body: %q", string(b)) + } + if out.Len() != 0 { + t.Fatalf("expected clean stdout, got %q", out.String()) + } + if !strings.Contains(errOut.String(), "Saved "+dest) { + t.Fatalf("expected the saved path on stderr, got %q", errOut.String()) + } +} + +func TestTextMetadataPrintsEnvelopeWithoutFetching(t *testing.T) { + srv, paths := textServer(t, "unused") + defer srv.Close() + + var out, errOut bytes.Buffer + code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, "--format", "json", + "reports", "text", "1", "--metadata"}, &out, &errOut) + if code != 0 { + t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String()) + } + if !strings.Contains(out.String(), "textUrl") || !strings.Contains(out.String(), "abc.markdown") { + t.Fatalf("expected the envelope on stdout, got %s", out.String()) + } + if len(*paths) != 1 { + t.Fatalf("expected only the metadata request, got %v", *paths) + } +} + +func TestTextUnavailableOnTranscripts(t *testing.T) { + var out, errOut bytes.Buffer + code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", "http://127.0.0.1:0", + "transcripts", "text", "1"}, &out, &errOut) + if code != 1 { + t.Fatalf("expected code 1, got %d; stderr=%s", code, errOut.String()) + } + if !strings.Contains(errOut.String(), "does not have a text endpoint") { + t.Fatalf("expected the missing-endpoint message, got %s", errOut.String()) + } +} + +func TestCompaniesSegmentsIsAChildList(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/companies/4742/segments" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("limit"); got != "5" { + t.Fatalf("expected limit=5, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"companyId":4742,"fiscalYear":2024,"segmentItem":"Revenue","total":187442}],"pagination":{"nextCursor":null}}`)) + })) + defer srv.Close() + + var out, errOut bytes.Buffer + code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, "--format", "json", + "companies", "segments", "4742", "--limit", "5"}, &out, &errOut) + if code != 0 { + t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String()) + } + if !strings.Contains(out.String(), "Revenue") { + t.Fatalf("expected segment rows, got %s", out.String()) + } +} + +func TestOpenfigisForwardedOnlyToCompanies(t *testing.T) { + var gotCompanies, gotEvents url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/companies": + gotCompanies = r.URL.Query() + case "/events": + gotEvents = r.URL.Query() + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + _, _ = w.Write([]byte(`{"data":[],"pagination":{"nextCursor":null}}`)) + })) + defer srv.Close() + + var out, errOut bytes.Buffer + if code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, + "companies", "list", "--openfigis", "BBG000B9XRY4,bbg000b9xry4,BBG001S5N8V8"}, &out, &errOut); code != 0 { + t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String()) + } + if got := gotCompanies.Get("openfigis"); got != "BBG000B9XRY4,BBG001S5N8V8" { + t.Fatalf("expected deduped openfigis, got %q", got) + } + if code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, + "events", "list", "--openfigis", "BBG000B9XRY4"}, &out, &errOut); code != 0 { + t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String()) + } + if _, ok := gotEvents["openfigis"]; ok { + t.Fatalf("expected openfigis to be dropped on /events, got %v", gotEvents) + } +} + +func TestCompaniesResolveAcceptsFIGI(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/companies" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("openfigis"); got != "BBG000B9XRY4" { + t.Fatalf("expected openfigis=BBG000B9XRY4, got %q", got) + } + if _, ok := r.URL.Query()["tickers"]; ok { + t.Fatalf("expected no tickers filter, got %v", r.URL.Query()) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":4742,"name":"Apple Inc","country":"US","tickers":[{"exchange":"NasdaqGS","ticker":"AAPL"}]}],"pagination":{"nextCursor":null}}`)) + })) + defer srv.Close() + + var out, errOut bytes.Buffer + code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, + "companies", "resolve", "BBG000B9XRY4"}, &out, &errOut) + if code != 0 { + t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String()) + } + if !strings.Contains(out.String(), "Apple Inc") || !strings.Contains(out.String(), "NasdaqGS:AAPL") { + t.Fatalf("expected the resolved company, got %s", out.String()) + } +} + +func TestLooksLikeFIGI(t *testing.T) { + for s, want := range map[string]bool{ + "BBG000B9XRY4": true, + "BBG001S5N8V8": true, + "AAPL": false, + "0000320193": false, + "BBG000B9XRY": false, // 11 chars + "BBg000B9XRY4": false, // lower case + "BBQ000B9XRY4": false, // no G in third position + } { + if got := looksLikeFIGI(s); got != want { + t.Errorf("looksLikeFIGI(%q) = %v, want %v", s, got, want) + } + } +} diff --git a/internal/cli/errors.go b/internal/cli/errors.go index 8941226..fdcddbc 100644 --- a/internal/cli/errors.go +++ b/internal/cli/errors.go @@ -36,7 +36,8 @@ func errorHint(err error) string { case http.StatusForbidden: return "hint: 403 means this endpoint is not included in your API tier, not that your key is wrong " + "(a rejected key returns 401). Every other endpoint keeps working with the same key. " + - "Endpoints seen gated this way: `events summary`, `audio list`, `live transcripts list`." + "Endpoints seen gated this way: `events summary`, `audio list`, `live transcripts list`, " + + "`companies segments`, and `reports text` / `slides text` (the parsed documents package)." case http.StatusUnauthorized: return "hint: 401 means the API key was rejected. Check `quartr auth show`, QUARTR_API_KEY, " + "and any --api-key flag." diff --git a/internal/cli/flags.go b/internal/cli/flags.go index a0ef1eb..c97ff19 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -80,6 +80,7 @@ type listFlags struct { tickers string isins string ciks string + openfigis string companyIDs string ids string startDate string @@ -107,6 +108,7 @@ func addListFlags(fs *flag.FlagSet, lf *listFlags) { fs.StringVar(&lf.tickers, "tickers", "", "comma-separated tickers; qualify with an exchange to avoid collisions, e.g. AAPL,NYSE:BLD") fs.StringVar(&lf.isins, "isins", "", "comma-separated ISINs") fs.StringVar(&lf.ciks, "ciks", "", "comma-separated SEC CIKs") + fs.StringVar(&lf.openfigis, "openfigis", "", "comma-separated OpenFIGI codes (figi, compositeFigi, or shareClassFigi); companies only") fs.StringVar(&lf.companyIDs, "company-ids", "", "comma-separated Quartr company IDs") fs.StringVar(&lf.ids, "ids", "", "comma-separated IDs for resources that support ids") fs.StringVar(&lf.startDate, "start-date", "", "ISO 8601 start date") @@ -127,7 +129,7 @@ func addListFlags(fs *flag.FlagSet, lf *listFlags) { // lists, and so the ones worth deduplicating. Scalars are left alone — // a cursor is an opaque token that may legitimately contain a comma. var listValuedParams = params( - "countries", "exchanges", "tickers", "isins", "ciks", "companyIds", "ids", + "countries", "exchanges", "tickers", "isins", "ciks", "openfigis", "companyIds", "ids", "typeIds", "eventIds", "documentGroupIds", "states", "levels", "expand", ) @@ -155,6 +157,7 @@ func (lf listFlags) toParams(allowed paramSet, companyEndpoint bool) url.Values add("tickers", lf.tickers) add("isins", lf.isins) add("ciks", lf.ciks) + add("openfigis", lf.openfigis) if companyEndpoint { // Both flags feed the same parameter here, so merge them; setting // them one after the other would silently drop --company-ids. diff --git a/internal/cli/handlers.go b/internal/cli/handlers.go index 2813c1a..a81d380 100644 --- a/internal/cli/handlers.go +++ b/internal/cli/handlers.go @@ -100,9 +100,13 @@ func (a *app) handleResource(r resource, args []string) error { case "summary", "summarize": return a.summaryResource(r, rest) case "pages": - return a.childListResource(r, r.pagesPath, params("limit", "cursor", "direction"), rest, "pages") + return a.childListResource(r, r.pagesPath, childListParams, rest, "pages") case "chapters": - return a.childListResource(r, r.chaptersPath, params("limit", "cursor", "direction", "levels"), rest, "chapters") + return a.childListResource(r, r.chaptersPath, mergeParams(childListParams, params("levels")), rest, "chapters") + case "segments": + return a.childListResource(r, r.segmentsPath, childListParams, rest, "segments") + case "text", "markdown": + return a.textResource(r, rest) case "download", "dl": return a.downloadResource(r, rest) case "stream": @@ -211,20 +215,23 @@ func (a *app) resolveResource(r resource, args []string) error { return err } if fs.NArg() != 1 { - return usagef("usage: quartr companies resolve (e.g. BLD, NYSE:BLD, 0001739445)") + return usagef("usage: quartr companies resolve (e.g. BLD, NYSE:BLD, 0001739445, BBG000B9XRY4)") } query := strings.TrimSpace(fs.Arg(0)) if query == "" || strings.ContainsAny(query, " \t") { - return usagef("the Quartr API has no company name search; pass a ticker (BLD or NYSE:BLD) or a CIK") + return usagef("the Quartr API has no company name search; pass a ticker (BLD or NYSE:BLD), a CIK, or an OpenFIGI") } ctx := context.Background() var companies []map[string]any var err error - if looksLikeCIK(query) { + switch { + case looksLikeCIK(query): companies, err = a.lookupCompanies(ctx, "ciks", query, nil) - } else { + case looksLikeFIGI(query): + companies, err = a.lookupCompanies(ctx, "openfigis", query, nil) + default: specs := parseTickerSpecs(query) companies, err = a.lookupCompanies(ctx, "tickers", bareTickerCSV(specs), specs) } @@ -335,24 +342,66 @@ func (a *app) downloadResource(r resource, args []string) error { return err } + dest := *outPath + if dest == "" { + dest = defaultFileName(r.name, id, downloadURL) + } + return a.saveURL(downloadURL, *withAPIKey, dest) +} + +// textResource implements `quartr reports text ` and `quartr slides text +// `. The endpoint returns a CDN link to the parsed Markdown rather than +// the text itself, so the command follows the link by default and prints the +// Markdown on stdout — that is what a `| head`, `> report.md`, or an LLM +// context wants. Unlike `download`, stdout is the default: the content is +// text, and the file name Quartr gives it is a content hash nobody wants. +// Pass --metadata to see the envelope (textUrl, updatedAt) instead. +func (a *app) textResource(r resource, args []string) error { + if r.textPath == "" { + return fmt.Errorf("%s does not have a text endpoint; parsed text exists for reports and slides only", r.name) + } + fs := newFlagSet(r.name+" text", a.errOut) + outPath := fs.String("output", "-", "write the Markdown to this path instead of stdout") + metadata := fs.Bool("metadata", false, "print the text metadata (textUrl, updatedAt) instead of the Markdown") + fields := fs.String("fields", "", "comma-separated output fields; only with --metadata") + withAPIKey := fs.Bool("with-api-key", false, "include x-api-key when fetching the text URL") + if err := parseInterspersed(fs, args); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("usage: quartr %s text [--output file] [--metadata]", r.name) + } + + path := strings.ReplaceAll(r.textPath, "{id}", url.PathEscape(fs.Arg(0))) + obj, _, err := a.client.GetJSON(context.Background(), path, nil) + if err != nil { + return err + } + if *metadata { + return output.Write(a.out, obj, output.Options{Format: a.cfg.Format(), Fields: parseCSV(*fields)}) + } + textURL, err := extractStringField(obj, "textUrl") + if err != nil { + return err + } + return a.saveURL(textURL, *withAPIKey, *outPath) +} + +// saveURL fetches downloadURL into dest. dest "-" streams the body to stdout +// so it can be piped or redirected; everything else this command prints goes +// to stderr, so `quartr transcripts download --output - > f.json` writes +// the document and nothing else. Any other dest is created (with parents) +// and confirmed with a `Saved ` line on stderr. +func (a *app) saveURL(downloadURL string, withAPIKey bool, dest string) error { apiKey := "" - if *withAPIKey { + if withAPIKey { apiKey = a.cfg.APIKey() } - - // `--output -` streams the document itself to stdout so it can be piped - // or redirected. Everything else this command prints goes to stderr, so - // `quartr transcripts download --output - > f.json` writes the - // document and nothing else. - if *outPath == "-" { + if dest == "-" { _, err := a.client.Download(context.Background(), downloadURL, apiKey, a.out) return err } - dest := *outPath - if dest == "" { - dest = defaultFileName(r.name, id, downloadURL) - } if dir := filepath.Dir(dest); dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return err diff --git a/internal/cli/help.go b/internal/cli/help.go index a659d1e..8f869a0 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -35,6 +35,7 @@ Examples: quartr events list --tickers AAPL --sort-by date --direction desc --limit 5 quartr transcripts list --tickers MSFT --expand event --limit 10 quartr transcripts download 432907 --output transcript.json + quartr reports text 105446 --output apple-10k.md quartr live transcripts stream 127537 --transcript-version 1.7 quartr request get /events --query tickers=AAPL --query limit=3 --format json `, quartr.Version, quartr.DefaultBaseURL, quartr.DefaultConfigPath(), strings.Join(cmds, ", ")) @@ -60,33 +61,31 @@ Examples: `) } -func (a *app) printResourceHelp(r resource) { +// resourceOps lists the operations a resource supports, in the order help +// shows them: the ones the resource map enables plus the companies-only +// resolve. +func resourceOps(r resource) []string { ops := []string{} - if r.listPath != "" { - ops = append(ops, "list") - } - if r.getPath != "" { - ops = append(ops, "get ") - } - if r.name == "companies" { - ops = append(ops, "resolve ") - } - if r.summaryPath != "" { - ops = append(ops, "summary ") - } - if r.pagesPath != "" { - ops = append(ops, "pages ") - } - if r.chaptersPath != "" { - ops = append(ops, "chapters ") - } - if r.downloadField != "" { - ops = append(ops, "download ") - } - if r.streamField != "" { - ops = append(ops, "stream ") + add := func(enabled bool, op string) { + if enabled { + ops = append(ops, op) + } } + add(r.listPath != "", "list") + add(r.getPath != "", "get ") + add(r.name == "companies", "resolve ") + add(r.summaryPath != "", "summary ") + add(r.pagesPath != "", "pages ") + add(r.textPath != "", "text ") + add(r.chaptersPath != "", "chapters ") + add(r.segmentsPath != "", "segments ") + add(r.downloadField != "", "download ") + add(r.streamField != "", "stream ") + return ops +} +func (a *app) printResourceHelp(r resource) { + ops := resourceOps(r) fmt.Fprintf(a.out, "Usage:\n quartr %s <%s> [flags]\n\nOperations:\n", r.name, strings.Join(ops, " | ")) for _, op := range ops { fmt.Fprintf(a.out, " %s\n", op) @@ -128,13 +127,25 @@ Downloads: download --output P writes P download --output - streams the document to stdout, nothing else `, r.name) + } + if r.textPath != "" { + fmt.Fprint(a.out, ` +Parsed text (Markdown, separate Quartr package; 403 without it): + text prints the parsed Markdown on stdout + text --output P writes P instead + text --metadata prints the envelope (textUrl, updatedAt) instead +`) } fmt.Fprint(a.out, ` Examples: `) switch r.name { case "companies": - fmt.Fprint(a.out, " quartr companies list --tickers AAPL\n quartr companies resolve BLD # every company using that ticker\n quartr companies get 4742 --format json\n") + fmt.Fprint(a.out, " quartr companies list --tickers AAPL\n quartr companies list --openfigis BBG000B9XRY4\n quartr companies resolve BLD # every company using that ticker\n quartr companies get 4742 --format json\n quartr companies segments 4742 --format json\n") + case "reports": + fmt.Fprint(a.out, " quartr reports list --tickers AAPL --type-ids 11 --limit 5\n quartr reports text 105446 | head -50\n quartr reports text 105446 --output apple-10k.md\n quartr reports download 105446 --output apple-10k.pdf\n") + case "slides": + fmt.Fprint(a.out, " quartr slides list --tickers AAPL --limit 5\n quartr slides text 152141 > deck.md\n quartr slides pages 152141 --format csv\n") case "events": fmt.Fprint(a.out, " quartr events list --tickers AAPL --sort-by date --direction desc\n quartr events summary 128301 --length long --plain\n") case "transcripts": diff --git a/internal/cli/resources.go b/internal/cli/resources.go index 6cae964..04a05f0 100644 --- a/internal/cli/resources.go +++ b/internal/cli/resources.go @@ -36,12 +36,18 @@ func (s paramSet) allows(name string) bool { } type resource struct { - name string - listPath string - getPath string - summaryPath string - pagesPath string - chaptersPath string + name string + listPath string + getPath string + summaryPath string + pagesPath string + chaptersPath string + // textPath is the parsed-document endpoint: it answers with a CDN link + // to the Markdown rendering of a report or slide deck, not the text + // itself. Only reports and slides have one. + textPath string + // segmentsPath is the company segments child list. + segmentsPath string downloadField string streamField string listParams paramSet @@ -78,7 +84,8 @@ var ( liveListParams = mergeParams(params("countries", "exchanges", "tickers", "isins", "ciks", "companyIds", "eventIds", "states", "startDate", "endDate", "updatedAfter", "updatedBefore", "limit", "cursor", "direction"), params("transcriptVersion")) liveAudioListParams = params("countries", "exchanges", "tickers", "isins", "ciks", "companyIds", "eventIds", "states", "startDate", "endDate", "updatedAfter", "updatedBefore", "limit", "cursor", "direction") simpleListParams = params("limit", "cursor", "direction") - companyListParams = params("countries", "exchanges", "tickers", "isins", "ciks", "ids", "updatedAfter", "updatedBefore", "limit", "cursor", "direction") + companyListParams = params("countries", "exchanges", "tickers", "isins", "ciks", "openfigis", "ids", "updatedAfter", "updatedBefore", "limit", "cursor", "direction") + childListParams = params("limit", "cursor", "direction") summaryParams = params("length", "plain") getExpandParams = params("expand") getLiveParams = params("transcriptVersion") @@ -91,10 +98,11 @@ var ( var resources = map[string]resource{ "companies": { - name: "companies", - listPath: "/companies", - getPath: "/companies/{id}", - listParams: companyListParams, + name: "companies", + listPath: "/companies", + getPath: "/companies/{id}", + segmentsPath: "/companies/{id}/segments", + listParams: companyListParams, }, "events": { name: "events", @@ -119,6 +127,7 @@ var resources = map[string]resource{ getPath: "/documents/reports/{id}", pagesPath: "/documents/reports/{id}/pages", summaryPath: "/documents/reports/{id}/summary", + textPath: "/documents/reports/{id}/text", downloadField: "fileUrl", listParams: docListParams, getParams: getExpandParams, @@ -130,6 +139,7 @@ var resources = map[string]resource{ getPath: "/documents/slides/{id}", pagesPath: "/documents/slides/{id}/pages", summaryPath: "/documents/slides/{id}/summary", + textPath: "/documents/slides/{id}/text", downloadField: "fileUrl", listParams: docListParams, getParams: getExpandParams, diff --git a/internal/cli/tickers.go b/internal/cli/tickers.go index f4f4ff8..77f12f3 100644 --- a/internal/cli/tickers.go +++ b/internal/cli/tickers.go @@ -224,6 +224,21 @@ func looksLikeCIK(s string) bool { return true } +// looksLikeFIGI reports whether a `companies resolve` argument is an OpenFIGI +// identifier: twelve upper-case alphanumerics with "G" in the third position +// (BBG000B9XRY4). No listed ticker has that shape. +func looksLikeFIGI(s string) bool { + if len(s) != 12 || s[2] != 'G' { + return false + } + for _, r := range s { + if (r < '0' || r > '9') && (r < 'A' || r > 'Z') { + return false + } + } + return true +} + // dedupeCSV removes repeated entries from a comma-separated filter value, // ignoring case. Quartr accepts duplicates, but they inflate the URL and make // `--tickers "$LIST"` fragile when the caller builds the list by hand.