Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ List companies by ticker:
quartr companies list --tickers AAPL
```

Find every company sharing a ticker:

```bash
quartr companies resolve BLD
```

List recent Apple events:

```bash
Expand Down Expand Up @@ -206,6 +212,42 @@ Endpoint-specific filters are also available where relevant:

Companies are the one common exception where Quartr uses `ids` instead of `companyIds`. This CLI maps `--company-ids` to `ids` for `companies list`.

## Tickers and exchange collisions

Quartr matches a ticker string across every exchange it knows, so a US symbol quietly pulls in foreign namesakes:

```bash
quartr companies resolve CE
```

```text
id name country matchedTickers
5977 Celanese Corporation US NYSE:CE
16679 Credito Emiliano S.p.A. IT BIT:CE
16930 Cortus Energy SE OM:CE
```

`companies resolve` accepts a ticker, an exchange-qualified ticker, or a CIK, and prints every candidate with the exchange pairs that matched. Use it whenever a symbol might be shared. (CIKs deserve the same caution — `companies resolve 0001061630` returns Blackstone Mortgage Trust, which is rarely what the caller expected.)

Once you know the exchange, qualify the ticker and the CLI does the disambiguation for you:

```bash
quartr events list --tickers NYSE:BLD --limit 4
```

`EXCHANGE:TICKER` is resolved to a `companyId` before the real request goes out:

```text
GET /companies?limit=500&tickers=BLD # resolve
GET /events?companyIds=11909&limit=4 # then query
```

Resolving up front rather than filtering the response matters: rows belonging to the other company would otherwise still count against `--limit`, so the company you asked for can be pushed off the page entirely.

Qualifiers are per entry, so `--tickers AAPL,NYSE:BLD` works; once any entry is qualified, all of them are resolved to ids. Duplicate tickers are collapsed case-insensitively.

The Quartr API has no company name search — there is no `search`, `query`, or `name` parameter on `/companies` — so `resolve` takes tickers and CIKs only.

## Expanding companies

Quartr has no server-side company expansion — `/events` rejects `expand` outright and the document endpoints accept only `expand=event`. Pass `--expand company` anyway and the CLI performs the join itself, batching the distinct `companyId` values into `/companies` calls of up to 100 ids:
Expand Down
123 changes: 123 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -261,6 +262,128 @@ func TestExpandCompanyFailureIsNonFatal(t *testing.T) {
}
}

// twoBLDCompanies serves the TopBuild / Boral ticker collision from #5.
func twoBLDCompanies() string {
return `{"data":[
{"id":11909,"name":"TopBuild Corp","country":"US","tickers":[{"exchange":"NYSE","ticker":"BLD"}]},
{"id":14573,"name":"Boral Limited","country":"AU","tickers":[{"exchange":"ASX","ticker":"BLD"}]}
],"pagination":{"nextCursor":null}}`
}

func TestQualifiedTickerResolvesToCompanyIDs(t *testing.T) {
var eventQuery 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":
if got := r.URL.Query().Get("tickers"); got != "BLD" {
t.Errorf("expected bare ticker BLD on the wire, got %q", got)
}
_, _ = w.Write([]byte(twoBLDCompanies()))
case "/events":
eventQuery = r.URL.Query()
_, _ = w.Write([]byte(`{"data":[{"id":1,"title":"Q4 2025","companyId":11909}],"pagination":{"nextCursor":null}}`))
default:
t.Errorf("unexpected path: %s", r.URL.Path)
}
}))
defer srv.Close()

var out, errOut bytes.Buffer
code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, "--format", "json",
"events", "list", "--tickers", "NYSE:BLD"}, &out, &errOut)
if code != 0 {
t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String())
}
// Resolving to an id beats filtering the page: rows belonging to the
// other company never consume the caller's --limit.
if got := eventQuery.Get("companyIds"); got != "11909" {
t.Fatalf("expected companyIds=11909, got %q", got)
}
if got := eventQuery.Get("tickers"); got != "" {
t.Fatalf("expected tickers to be replaced, got %q", got)
}
}

func TestUnqualifiedTickerIsNotResolved(t *testing.T) {
var tickers string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/events" {
t.Errorf("expected no company lookup, got %s", r.URL.Path)
}
tickers = r.URL.Query().Get("tickers")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[],"pagination":{"nextCursor":null}}`))
}))
defer srv.Close()

var out, errOut bytes.Buffer
code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL,
"events", "list", "--tickers", "AAPL,aapl,MSFT,AAPL"}, &out, &errOut)
if code != 0 {
t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String())
}
if tickers != "AAPL,MSFT" {
t.Fatalf("expected case-duplicates collapsed to AAPL,MSFT, got %q", tickers)
}
}

func TestQualifiedTickerWithNoMatchIsAUsageError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/companies" {
t.Errorf("expected no list request, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(twoBLDCompanies()))
}))
defer srv.Close()

var out, errOut bytes.Buffer
code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL,
"events", "list", "--tickers", "NASDAQ:BLD"}, &out, &errOut)
if code != 2 {
t.Fatalf("expected usage exit code 2, got %d; stderr=%s", code, errOut.String())
}
if !strings.Contains(errOut.String(), "companies resolve BLD") {
t.Fatalf("expected a pointer to `companies resolve`, got %s", errOut.String())
}
}

func TestCompaniesResolveListsEveryCandidate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/companies" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(twoBLDCompanies()))
}))
defer srv.Close()

var out, errOut bytes.Buffer
code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL,
"companies", "resolve", "BLD"}, &out, &errOut)
if code != 0 {
t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String())
}
for _, want := range []string{"TopBuild Corp", "NYSE:BLD", "Boral Limited", "ASX:BLD"} {
if !strings.Contains(out.String(), want) {
t.Fatalf("expected %q in resolve output, got %s", want, out.String())
}
}
}

func TestCompaniesResolveRejectsNameSearch(t *testing.T) {
var out, errOut bytes.Buffer
code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", "http://127.0.0.1:0",
"companies", "resolve", "Apple Inc"}, &out, &errOut)
if code != 2 {
t.Fatalf("expected usage exit code 2, got %d; stderr=%s", code, errOut.String())
}
if !strings.Contains(errOut.String(), "no company name search") {
t.Fatalf("expected an explanation of the missing capability, got %s", errOut.String())
}
}

func TestListAllFollowsPagination(t *testing.T) {
requests := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
25 changes: 18 additions & 7 deletions internal/cli/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func addListFlags(fs *flag.FlagSet, lf *listFlags) {
fs.StringVar(&lf.fields, "fields", "", "comma-separated output fields")
fs.StringVar(&lf.countries, "countries", "", "comma-separated ISO country codes")
fs.StringVar(&lf.exchanges, "exchanges", "", "comma-separated exchange symbols")
fs.StringVar(&lf.tickers, "tickers", "", "comma-separated tickers, e.g. AAPL,MSFT")
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.companyIDs, "company-ids", "", "comma-separated Quartr company IDs")
Expand All @@ -123,15 +123,27 @@ func addListFlags(fs *flag.FlagSet, lf *listFlags) {
fs.StringVar(&lf.levels, "levels", "", "comma-separated chapter levels")
}

// listValuedParams are the query parameters Quartr reads as comma-separated
// 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",
"typeIds", "eventIds", "documentGroupIds", "states", "levels", "expand",
)

func (lf listFlags) toParams(allowed paramSet, companyEndpoint bool) url.Values {
p := url.Values{}
add := func(apiName, val string) {
if strings.TrimSpace(val) == "" {
return
}
if allowed.allows(apiName) {
p.Set(apiName, val)
if !allowed.allows(apiName) {
return
}
if listValuedParams.allows(apiName) {
val = dedupeCSV(val)
}
p.Set(apiName, val)
}
if lf.limit > 0 && allowed.allows("limit") {
p.Set("limit", strconv.Itoa(lf.limit))
Expand All @@ -144,10 +156,9 @@ func (lf listFlags) toParams(allowed paramSet, companyEndpoint bool) url.Values
add("isins", lf.isins)
add("ciks", lf.ciks)
if companyEndpoint {
if lf.companyIDs != "" {
add("ids", lf.companyIDs)
}
add("ids", lf.ids)
// Both flags feed the same parameter here, so merge them; setting
// them one after the other would silently drop --company-ids.
add("ids", strings.Join(append(parseCSV(lf.companyIDs), parseCSV(lf.ids)...), ","))
} else {
add("companyIds", lf.companyIDs)
}
Expand Down
7 changes: 7 additions & 0 deletions internal/cli/flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ func TestListFlagsMapCompanyIDsPerEndpoint(t *testing.T) {
t.Fatalf("companies companyIds: expected empty, got %q", got)
}

// --company-ids and --ids feed the same parameter on this endpoint, so
// passing both has to merge rather than let one overwrite the other.
merged := listFlags{limit: 10, companyIDs: "4742", ids: "3694,4742"}.toParams(companies.listParams, true)
if got := merged.Get("ids"); got != "4742,3694" {
t.Fatalf("companies ids: expected merged 4742,3694, got %q", got)
}

events, ok := resourceByName("events")
if !ok {
t.Fatal("events resource not found")
Expand Down
51 changes: 51 additions & 0 deletions internal/cli/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ func (a *app) handleResource(r resource, args []string) error {
return a.listResource(r, rest)
case "get", "show":
return a.getResource(r, rest)
case "resolve":
return a.resolveResource(r, rest)
case "summary", "summarize":
return a.summaryResource(r, rest)
case "pages":
Expand Down Expand Up @@ -133,6 +135,9 @@ func (a *app) listResource(r resource, args []string) error {
return err
}
}
if err := a.applyQualifiedTickers(context.Background(), r, &lf); err != nil {
return err
}
if lf.all && !flagWasPassed(args, "limit") {
lf.limit = 500
}
Expand Down Expand Up @@ -187,6 +192,52 @@ func (a *app) getResource(r resource, args []string) error {
return output.Write(a.out, obj, output.Options{Format: a.cfg.Format(), Fields: parseCSV(*fields)})
}

// resolveResource implements `quartr companies resolve <ticker|cik>`: the one
// step that turns an ambiguous ticker into a companyId you can trust. Quartr
// matches tickers across every exchange, so this prints every candidate with
// the exchange pairs that matched rather than guessing which one was meant.
func (a *app) resolveResource(r resource, args []string) error {
if r.name != "companies" {
return usagef("`resolve` is only available on `quartr companies`")
}
fs := newFlagSet("companies resolve", a.errOut)
fields := fs.String("fields", "", "comma-separated output fields")
if err := parseInterspersed(fs, args); err != nil {
return err
}
if fs.NArg() != 1 {
return usagef("usage: quartr companies resolve <ticker|cik> (e.g. BLD, NYSE:BLD, 0001739445)")
}

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")
}

ctx := context.Background()
var companies []map[string]any
var err error
if looksLikeCIK(query) {
companies, err = a.lookupCompanies(ctx, "ciks", query, nil)
} else {
specs := parseTickerSpecs(query)
companies, err = a.lookupCompanies(ctx, "tickers", bareTickerCSV(specs), specs)
}
if err != nil {
return err
}
if len(companies) == 0 {
return fmt.Errorf("no company matches %q", query)
}

chosen := parseCSV(*fields)
if len(chosen) == 0 {
chosen = []string{"id", "name", "country", "matchedTickers"}
}
result := map[string]any{"data": companies, "count": len(companies)}
return output.Write(a.out, result, output.Options{Format: a.cfg.Format(), Fields: chosen})
}

func (a *app) summaryResource(r resource, args []string) error {
if r.summaryPath == "" {
return fmt.Errorf("%s does not have a summary endpoint", r.name)
Expand Down
9 changes: 7 additions & 2 deletions internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ func (a *app) printResourceHelp(r resource) {
if r.getPath != "" {
ops = append(ops, "get <id>")
}
if r.name == "companies" {
ops = append(ops, "resolve <ticker|cik>")
}
if r.summaryPath != "" {
ops = append(ops, "summary <id>")
}
Expand All @@ -90,7 +93,9 @@ func (a *app) printResourceHelp(r resource) {
}
fmt.Fprint(a.out, `
Common list flags:
--tickers AAPL,MSFT filter by tickers where supported
--tickers AAPL,MSFT filter by tickers where supported; a bare ticker
matches on every exchange, so qualify it as
NYSE:BLD when the symbol is shared
--company-ids 4742 filter by Quartr company IDs where supported
--start-date ISO content/event date lower bound where supported
--end-date ISO content/event date upper bound where supported
Expand All @@ -113,7 +118,7 @@ Examples:
`)
switch r.name {
case "companies":
fmt.Fprint(a.out, " quartr companies list --tickers AAPL\n quartr companies get 4742 --format json\n")
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")
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":
Expand Down
Loading
Loading