diff --git a/README.md b/README.md index a0c8788..b72004d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index cfd9163..20b127c 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -4,6 +4,7 @@ import ( "bytes" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "strings" @@ -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) { diff --git a/internal/cli/flags.go b/internal/cli/flags.go index edcd293..a0ef1eb 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -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") @@ -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)) @@ -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) } diff --git a/internal/cli/flags_test.go b/internal/cli/flags_test.go index c841c33..32b0387 100644 --- a/internal/cli/flags_test.go +++ b/internal/cli/flags_test.go @@ -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") diff --git a/internal/cli/handlers.go b/internal/cli/handlers.go index 7942bf7..ecbb71a 100644 --- a/internal/cli/handlers.go +++ b/internal/cli/handlers.go @@ -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": @@ -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 } @@ -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 `: 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 (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) diff --git a/internal/cli/help.go b/internal/cli/help.go index 5427e62..fbdde9b 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -68,6 +68,9 @@ func (a *app) printResourceHelp(r resource) { if r.getPath != "" { ops = append(ops, "get ") } + if r.name == "companies" { + ops = append(ops, "resolve ") + } if r.summaryPath != "" { ops = append(ops, "summary ") } @@ -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 @@ -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": diff --git a/internal/cli/tickers.go b/internal/cli/tickers.go new file mode 100644 index 0000000..f4f4ff8 --- /dev/null +++ b/internal/cli/tickers.go @@ -0,0 +1,242 @@ +package cli + +import ( + "context" + "fmt" + "net/url" + "strings" +) + +// companyLookupLimit is the page size used when resolving tickers to +// companies. A ticker matches a handful of companies at most. +const companyLookupLimit = "500" + +// tickerSpec is one entry of --tickers. Quartr matches a ticker string across +// every exchange it knows, so "CE" returns both Celanese and Credito +// Emiliano. An entry may be qualified with the exchange it has to come from: +// "NYSE:BLD" instead of "BLD". +type tickerSpec struct { + exchange string + ticker string +} + +func (s tickerSpec) String() string { + if s.exchange == "" { + return s.ticker + } + return s.exchange + ":" + s.ticker +} + +// tickerEntries reads a company's ticker list, which Quartr shapes as +// [{"exchange": "NYSE", "ticker": "BLD"}, ...]. +func tickerEntries(company map[string]any) []map[string]any { + raw, ok := company["tickers"].([]any) + if !ok { + return nil + } + out := make([]map[string]any, 0, len(raw)) + for _, item := range raw { + if m, ok := item.(map[string]any); ok { + out = append(out, m) + } + } + return out +} + +// parseTickerSpecs splits a --tickers value into specs, dropping duplicates +// that differ only in case. +func parseTickerSpecs(s string) []tickerSpec { + seen := map[string]bool{} + var specs []tickerSpec + for _, raw := range parseCSV(s) { + spec := tickerSpec{ticker: raw} + if exchange, ticker, ok := strings.Cut(raw, ":"); ok { + spec = tickerSpec{exchange: strings.TrimSpace(exchange), ticker: strings.TrimSpace(ticker)} + } + if spec.ticker == "" { + continue + } + key := strings.ToUpper(spec.String()) + if seen[key] { + continue + } + seen[key] = true + specs = append(specs, spec) + } + return specs +} + +func anyQualified(specs []tickerSpec) bool { + for _, s := range specs { + if s.exchange != "" { + return true + } + } + return false +} + +// bareTickerCSV is the value to send to the API: exchange qualifiers are a +// CLI concept, Quartr only understands the ticker string. +func bareTickerCSV(specs []tickerSpec) string { + seen := map[string]bool{} + out := make([]string, 0, len(specs)) + for _, s := range specs { + key := strings.ToUpper(s.ticker) + if seen[key] { + continue + } + seen[key] = true + out = append(out, s.ticker) + } + return strings.Join(out, ",") +} + +// matchedTickers renders the "EXCHANGE:TICKER" pairs on company that satisfy +// any of specs. With no specs it renders every pair the company has. +func matchedTickers(company map[string]any, specs []tickerSpec) []string { + var hits []string + seen := map[string]bool{} + for _, entry := range tickerEntries(company) { + exchange, ticker := idKey(entry["exchange"]), idKey(entry["ticker"]) + if ticker == "" { + continue + } + if len(specs) > 0 && !matchesAny(specs, exchange, ticker) { + continue + } + pair := ticker + if exchange != "" { + pair = exchange + ":" + ticker + } + if seen[pair] { + continue + } + seen[pair] = true + hits = append(hits, pair) + } + return hits +} + +func matchesAny(specs []tickerSpec, exchange, ticker string) bool { + for _, s := range specs { + if !strings.EqualFold(s.ticker, ticker) { + continue + } + if s.exchange == "" || strings.EqualFold(s.exchange, exchange) { + return true + } + } + return false +} + +// lookupCompanies fetches company records by ticker or by CIK and annotates +// each with the "EXCHANGE:TICKER" pairs that matched, which is the field a +// human needs to tell two same-ticker companies apart. +func (a *app) lookupCompanies(ctx context.Context, param, value string, specs []tickerSpec) ([]map[string]any, error) { + params := url.Values{} + params.Set(param, value) + params.Set("limit", companyLookupLimit) + obj, _, err := a.client.GetJSON(ctx, "/companies", params) + if err != nil { + return nil, err + } + + matched := make([]map[string]any, 0, len(dataRows(obj))) + for _, company := range dataRows(obj) { + hits := matchedTickers(company, specs) + if len(specs) > 0 && len(hits) == 0 { + continue + } + company["matchedTickers"] = strings.Join(hits, ",") + matched = append(matched, company) + } + return matched, nil +} + +// resolveQualifiedTickers turns exchange-qualified --tickers into an explicit +// companyIds filter. Filtering the returned page client-side would be wrong: +// the rows the wrong company occupies still count against --limit, so the +// company you asked for can be pushed off the page entirely. Resolving first +// and filtering server-side by id is the recipe the issue describes, done in +// one command instead of two. +func (a *app) resolveQualifiedTickers(ctx context.Context, specs []tickerSpec) ([]string, error) { + companies, err := a.lookupCompanies(ctx, "tickers", bareTickerCSV(specs), specs) + if err != nil { + return nil, fmt.Errorf("resolve %s: %w", specList(specs), err) + } + ids := make([]string, 0, len(companies)) + for _, company := range companies { + if id := idKey(company["id"]); id != "" { + ids = append(ids, id) + } + } + if len(ids) == 0 { + return nil, usagef("no company matches %s; run `quartr companies resolve %s` to see the candidates", + specList(specs), specs[0].ticker) + } + return ids, nil +} + +// applyQualifiedTickers rewrites --tickers into an explicit companyIds filter +// when any entry names an exchange, and collapses case-duplicate tickers +// either way. +func (a *app) applyQualifiedTickers(ctx context.Context, r resource, lf *listFlags) error { + specs := parseTickerSpecs(lf.tickers) + if len(specs) == 0 { + return nil + } + if !anyQualified(specs) { + lf.tickers = bareTickerCSV(specs) + return nil + } + if !r.listParams.allows("tickers") { + return usagef("--tickers is not supported by `quartr %s list`", r.name) + } + + ids, err := a.resolveQualifiedTickers(ctx, specs) + if err != nil { + return err + } + lf.tickers = "" + lf.companyIDs = strings.Join(append(parseCSV(lf.companyIDs), ids...), ",") + return nil +} + +func specList(specs []tickerSpec) string { + out := make([]string, 0, len(specs)) + for _, s := range specs { + out = append(out, s.String()) + } + return strings.Join(out, ",") +} + +// looksLikeCIK reports whether a `companies resolve` argument should be +// treated as a SEC CIK rather than a ticker. +func looksLikeCIK(s string) bool { + if len(s) < 6 { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + 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. +func dedupeCSV(s string) string { + seen := map[string]bool{} + out := make([]string, 0, 8) + for _, v := range parseCSV(s) { + key := strings.ToUpper(v) + if seen[key] { + continue + } + seen[key] = true + out = append(out, v) + } + return strings.Join(out, ",") +}