DEV Community

Mikhail Makeev
Mikhail Makeev

Posted on AI-assisted

The company you're researching doesn't have a ticker

Jane Street has no stock ticker. It still turns up in news about JPMorgan, Goldman Sachs, Bank of America and Citi.

That's an awkward place to start if your news API expects a ticker. You can search each bank separately, but first you have to know which banks matter. A research question about Jane Street becomes a guessing exercise about other companies.

Text search lets you begin with the name or wording you already have. It also creates a responsibility: the response needs to tell you whether it matched your words, broadened the query, or found nothing within its coverage.

We added text search to AlphAI on September 23. Here are three problems it solves, with Python examples you can run using alphai-sdk. They find public companies connected to an unlisted name, collect coverage across news categories, and show how to handle partial or empty results in a research script.

Run the examples

You'll need Python 3.10 or newer and an AlphAI API key. A Free key works for these examples.

python3 -m venv .venv
source .venv/bin/activate
python -m pip install "alphai-sdk==0.8.0"
Enter fullscreen mode Exit fullscreen mode

Version 0.8.0 adds client.news.search(). It handles authentication, returns typed Python objects, and keeps the search explanation alongside the results.

Create search_examples.py and put this setup at the top. Then append the Python blocks from each case in order. Run the file with python search_examples.py. It will ask for your key without displaying it, unless you've already set ALPHAI_API_KEY in your environment.

import os
from datetime import datetime, timedelta, timezone
from getpass import getpass

from alphai import Client, NewsCategory, NewsSearchPage

if not os.environ.get("ALPHAI_API_KEY"):
    os.environ["ALPHAI_API_KEY"] = getpass("AlphAI API key: ")

until = datetime.now(timezone.utc).replace(microsecond=0)
since = until - timedelta(days=29)


def show(page: NewsSearchPage, limit: int = 3) -> None:
    reading = page.query
    print(f"Mode: {reading.mode}; matched: {page.matched}")
    window_start = reading.window_from.isoformat() if reading.window_from else "unspecified"
    print(f"Window: {window_start} to {until.isoformat()}")
    print(reading.note)
    for article in page.results[:limit]:
        category = article.enrichment.category
        label = category.value if isinstance(category, NewsCategory) else category
        match = article.search_match
        print(f"\n{article.title}")
        print(", ".join(article.enrichment.tickers), "|", label)
        if match and match.context:
            print(match.context)
        if match and match.terms_matched is not None:
            print("Query terms matched:", match.terms_matched)
        print(article.original.url)
Enter fullscreen mode Exit fullscreen mode

The script fixes a 29-day UTC window when it starts. That fits within Free's rolling 30-day archive and leaves room for the cutoff to move while the script runs. show() prints a few results with their source links; the response itself contains up to 20 articles per page.

The outputs below were checked against production with this SDK on September 23, 2026, using August 25–September 23 as the window. Your counts will change as new coverage arrives. Each example disables automatic retries so a failed call surfaces immediately while you're trying it.

Problem 1: you know the company, but it has no ticker

A ticker filter works once you've identified the listed company. It can't directly represent a private firm such as Jane Street. Searching the firm's name lets you discover the public companies appearing in the same coverage before deciding which tickers to investigate.

We hit a more subtle version of this problem in our earlier MCP search: Jane Street could resolve to Janel Corp. The request succeeded, but the subject was wrong. The new text-search path keeps the query as text; a ticker narrows these SDK requests only when you explicitly pass a symbol filter.

with Client(max_retries=0) as client:
    jane = client.news.search(
        query="Jane Street", from_date=since, to_date=until, page_size=20
    )
show(jane)
Enter fullscreen mode Exit fullscreen mode

An excerpt from the output:

Mode: strict; matched: 24

Fed and BoE tighten scrutiny of banks’ trading-firm exposures after Jane Street losses
JPM, GS, BAC, C | regulation
Enter fullscreen mode Exit fullscreen mode

The Hedgeweek report, citing the Financial Times, described regulators asking banks about their exposure to trading firms following losses at Jane Street. The script found it without a symbol parameter.

That's a useful starting point for research. You can read the article, identify the relationships it actually describes, then follow the relevant banks. A private supplier and its listed customer have the same search problem: you may know the supplier's name before you know which stock is involved.

An article's tickers need interpretation. JPM beside a Jane Street story doesn't tell you the size of JPMorgan's exposure or whether it lost money. Nor does it establish that every bank in the article has the same relationship. The tags give you names to investigate; the reporting supplies the relationship.

Once JPMorgan is the company you want to follow, add an explicit filter:

with Client(max_retries=0) as client:
    bank = client.news.search(
        query="Jane Street", symbol="JPM",
        from_date=since, to_date=until, page_size=20,
    )
show(bank, limit=1)
Enter fullscreen mode Exit fullscreen mode

That narrowed the run to four matches. The text query finds the subject; symbol="JPM" restricts the results to articles tagged with JPMorgan. This is how the search becomes a watchlist query after helping you discover the watchlist.

The first request reported 24 matches but returned 20 articles on its first page. To fetch more, pass jane.next_cursor as cursor to client.news.search(), keeping the query and other filters unchanged. A null cursor means there are no further pages in that result set.

Problem 2: the wording cuts across your categories

Suppose you want to find companies discussing doubts about their ability to keep operating. Those stories can arrive as earnings coverage, an acquisition announcement, or a general company update. Choosing one news category at the start would miss some of them. A ticker list would require knowing the companies already.

Search for the wording instead:

with Client(max_retries=0) as client:
    distress = client.news.search(
        query='"going concern"', from_date=since, to_date=until, page_size=20
    )
show(distress)
Enter fullscreen mode Exit fullscreen mode

The quote characters inside the Python string are deliberate. '"going concern"' sends a phrase query, requiring the words in that order. "going concern" as an ordinary Python string would send two unquoted search words. These are different searches and can return different counts.

The phrase query returned 19 matches, including:

Company Business News category
Bally's Casinos other
Goliath Film & Media Film and media other
Einride Autonomous trucking corporate_actions
MDxHealth Diagnostics earnings

The table shows why a single category filter isn't enough. The shared wording connects stories that were classified for different reasons.

Bally's summary discussed substantial doubt about its ability to continue without new financing. The Einride result combined an acquisition with going-concern risks. In MDxHealth's case, the discussion appeared alongside interim results. For a researcher, those are useful leads into a common question about funding, despite the companies having little else in common.

The phrase itself isn't a distress signal. Financial reporting generally assumes a business will continue operating; the warning is about substantial doubt over that ability. An article can also discuss a concern that has since been resolved. The PCAOB's auditing standard distinguishes the assumption from doubts about it.

I would keep the original disclosure date alongside each company and check whether later financing changed the situation. Several articles may repeat one warning, and a new article may describe an old filing. The 19 matches are coverage to review, not 19 companies in trouble or 19 new warnings.

search_match.context, printed by show(), helps with this first pass. It's a highlighted fragment of the AI summary, not a quotation from the underlying filing. It can be absent when the match is only in the headline or extracted entity names. Use it to decide which source to open, then check the disclosure before making a claim about the company.

Problem 3: a result list hides whether your question was answered

The first two queries returned strict: every query term matched under the search rules. Longer queries can find too few strict matches and return broader coverage instead. If a script or an agent ignores that distinction, an adjacent story can end up cited as an answer to the original question.

Try a query with several constraints:

with Client(max_retries=0) as client:
    broader = client.news.search(
        query="tariff exemption semiconductor equipment imports",
        from_date=since, to_date=until, page_size=20,
    )
show(broader)
Enter fullscreen mode Exit fullscreen mode

This returned broadened, with 50 matches. The top result's search_match.terms_matched was 4, against five words in the query.

The detail that matters is what the result was about: a Delhi High Court customs ruling on oilfield equipment. It shared language about imports and exemptions, but it didn't answer the semiconductor-equipment question.

The response explained that the search had dropped common words after finding too few strict matches. Broadened results must still match at least two query terms, and each item reports its count. The note ends with:

Read the rows as leads, not matches.

That tells a research script what to do with the result. It can retain the customs ruling as related coverage while keeping it out of a list of confirmed semiconductor exemptions. The show() helper deliberately prints page.query.note alongside the articles so that explanation doesn't get lost.

A ranking score can't settle relevance on its own. It orders results within one response; it isn't the probability that an article answers the question. Scores from different queries shouldn't be compared. Even strict is a statement about matching words, not a guarantee of a useful answer.

An empty result needs its window attached

The opposite problem occurs when a script turns an empty list into a confident statement that nothing happened.

with Client(max_retries=0) as client:
    empty = client.news.search(
        query="quarterly zorbax filings", from_date=since, to_date=until, page_size=20
    )
show(empty)

reading = empty.query
if reading.mode == "no_match":
    print(
        "No matching coverage found in AlphAI between "
        f"{reading.window_from.isoformat()} and {until.isoformat()}."
    )
elif reading.mode == "no_terms":
    print("The query had no searchable words. Try different wording.")
Enter fullscreen mode Exit fullscreen mode

The deliberately unlikely query returned no_match, with zero results. The script then printed:

No matching coverage found in AlphAI between 2026-08-25T19:15:36+00:00 and 2026-09-23T19:15:36+00:00.
Enter fullscreen mode Exit fullscreen mode

The dates in that message come from the response and the requested upper bound. They will update when you run the script.

An empty result establishes that the search found no matching coverage within those bounds and filters. AlphAI searches headlines, summaries and extracted entities; it doesn't search the full body of every article or SEC filing. Someone reading the message can check a different name, widen the dates within their plan, or go directly to the filings.

The separate no_terms branch handles an input with no searchable words. That calls for different wording, rather than a report that no coverage exists. Authentication failures, rate limits and service errors raise SDK exceptions; the script doesn't turn them into empty results.

Start with the question, then add the filters

These searches are useful before you have a finished watchlist. A private company name can lead you to public businesses worth investigating. A phrase from a disclosure can reveal a set of companies that no single category would collect. The response's mode and date window help you decide what those results support.

All the examples use the same /api/news/search/ endpoint. The SDK exposes the explanation as page.query, articles as page.results, and the bounded match count as page.matched. That count stops at 200; it is not a total for the entire archive. Keep the explanation when saving or passing results to another program. A list of headlines alone loses the distinction between a strict match, a lead and a search that found nothing.

You can also try the queries in the browser. The page has period and source filters and shows the corresponding REST and MCP calls. Its default window is 180 days, with results grouped by story, so its counts can differ from this script's 29-day, ungrouped searches.

Start with Jane Street, or try "going concern" and read the surrounding text of a few results. Add a company filter when you have a reason to focus on that company.

Search the news and SEC filings on AlphAI.

Top comments (3)

Collapse
 
kaziava profile image
Hardcore Engineer •

Mikhail, the jane street → janel corp line is the sentence i underlined twice,
because it is my own failure wearing your coat. in our golden set a negative
test that "passes" because the retriever never surfaced the trap is a green
lie — the request succeeds, the answer is confident, and the subject is wrong.
you hit the identical shape one layer up: the search resolved, returned a
result, and quietly answered about a different company. "the request succeeded,
but the subject was wrong" is the cleanest description of that failure i have
read, and it is the same instinct that made us stop trusting a bare id as the
only anchor.

What i did not expect to find in a search post is my own third verdict, and you
built it properly. strict / broadened / no_match / no_terms is exactly the
"test did not run" state i fight for in eval — a green result that cannot tell
you whether the question was answered or the query was widened into
adjacency. the oilfield-instead-of-semiconductor example is the textbook case:
four of five terms matched, the row looked like an answer, and the note said
"read the rows as leads, not matches." that note is doing the work my ci
summary does — it refuses to let a soft match masquerade as a hard one. most
apis hide this inside a ranking score; you surfaced it as a mode, and that is
the right call.

And the context field made me laugh in recognition. you warn that
search_match.context is a fragment of the ai summary, not a quotation from the
filing, and that you should open the source before making a claim. i learned
that lesson from a different direction last week: i carried a phrase out of a
secondary summary of someone's post into quotation marks without checking the
origin, and the author caught me. same principle, three layers apart — your
summary, my citation, his memory: never trust the explanation layer as if it
were the fact layer.

One thing i want to return to you from your own earlier post, because it
applies here too: you said a null you can see beats a multiplier guessed on
your behalf. your matched count stopping at 200 is exactly that — a visible
cap instead of a fake total. and the quoted phrase query versus two loose
words is the same idea in syntax: the quotes are an anchor that stops the
query from drifting the way an unanchored chunk id drifts after a re-chunk.
you have been building the same discipline across three posts without naming
it, and i think it is worth naming: every result should carry the proof of
what it actually matched.

So the question i genuinely want your answer to, and it is a continuation of
your design rather than a suggestion: you split the modes for the human
reading the rows — but what stops an agent consuming the api from swallowing a
broadened result as an answer? in eval we solved the analogous problem by
asserting the precondition, not the conclusion: the run fails unless mode ==
strict, and a broadened row is a lead, not a verdict, until something
explicit promotes it. do you have that gate on the consumer side, or is the
note the only defence right now? asking because your modes are the raw
material for a search-layer negative test, and i suspect you are closer to it
than the post lets on.

Collapse
 
makeev profile image
Mikhail Makeev •

Thanks, this is the most interesting comment the post has had. No gate on the consumer side today, the mode field and the note are it. One mechanical opt-out exists already: a quoted phrase, a -word or an OR in the query switches broadening off, so the run either matches every word or comes back empty. I left a flag out on purpose, because a script that wants leads and one that wants verdicts hit the same endpoint and I did not want to pick for them. A broaden=false parameter that fails loudly is the cheap version of your precondition assert. If I add it, would you rather get a 4xx or an empty page in no_match?

Collapse
 
kaziava profile image
Hardcore Engineer •

Mikhail, thank you — and the opt-out you already shipped is the part I
underlined: a quoted phrase, a -word or an OR switching broadening off means
verdict-scripts can assert the precondition today, in query syntax, without
waiting for a parameter. That is the loud failure already living in your
grammar; it just needs a name in the docs so the scripts know to reach for it.

On your question: empty page in no_match, not a 4xx. The reason is the
distinction my whole eval is built on — status codes describe the request,
modes describe the world. A 4xx says "you asked wrong": bad auth, malformed
params, a contradiction I sent you. A no_match page says "you asked right, and
under your rules the answer is nothing here." If zero strict matches returns a
4xx, then every legitimate empty answer looks like an outage: my CI maps
non-2xx to "test did not run," retries it, and pages a human for what is
actually the corpus being honest. Meanwhile a real param bug hides in the same
bucket. Keep 4xx for requests that are genuinely wrong, and let the world's
silence be a verdict, not an error.

Two conditions make the empty page safe, and you already honor the first: it
must carry its window and its mode, so "nothing here" never detaches from
"where and under which rules" — your no_match branch does exactly this, and
auth, rate limits and service errors raise exceptions instead of empty pages,
which is the half most APIs get wrong. The second condition is the one I would
add with broaden=false: the empty page should say that broadening was off.
"Nothing matched every word" and "nothing matched anything" are different
claims, and a consumer asserting a strict precondition needs to see which one
it received. One boolean in the reading — broadened: false — is enough; the
mode enum stays small and the payload carries the proof.

And there is one narrow place where I would spend a 4xx gladly: contradictory
params. broaden=false together with an OR or a -word is a client sending two
opposite instructions, and a 422 there is a kindness — it fails at the border
instead of silently picking one of my intentions. Give 4xx that single clean
job and it never contaminates the verdict space.

Whichever way you land it, put the semantics in the mode enum and the payload,
and my golden set will assert on it the same week: one row per state — strict
match, broadened lead, no_match with broaden off, and the contradiction as the
one row that expects an exception. You asked which I would rather receive; the
honest answer is that I would rather receive a verdict I can stamp than an
error I have to guess about.