DEV Community

Alexander Grun
Alexander Grun

Posted on

How I find dilution questions on X for 0.3 cents a ticker

Disclosure: this post earns me API credits from TwitterAPI.io, and the links to it are my referral link. I was a paying customer first, and the numbers below come from my own account.

I run DilutionLens, a site that tracks share dilution on the Australian stock market. Small ASX companies raise money all the time, and holders ask about it on X. "Is this placement priced below market?" "How long does the cash last?" A filing can answer those questions, if you can find them.

Searching by hand took half an hour most days and usually found nothing. X's official read access starts at about US$100 a month, which made no sense for a job this small. So I tried TwitterAPI.io, a third-party read API that charges per post returned: US$0.15 per 1,000 posts, with no monthly fee.

My first real run searched 5 tickers over 3 days for 1.1 cents.

The problem: most ticker search results are noise

The job sounds simple. Take the tickers that had news today, search each one, and keep the posts that ask a question.

Two things make it harder:

  • Most ASX small caps have no audience on X. A ticker can return nothing at all.
  • ASX tickers collide with crypto tickers, and crypto posts ask far more questions. In one sweep of 93 tickers, 19 had a "question". 13 of those 19 were collisions: $LIT was Lighter, $BTR was a USDT perp, and $POD and $IMU were Coinbase listings.

A version you can run

This needs requests and an API key in TWITTERAPI_IO_KEY.

import datetime as dt
import os
import sys
import time

import requests

API = "https://api.twitterapi.io/twitter/tweet/advanced_search"
KEY = os.environ["TWITTERAPI_IO_KEY"]
USD_PER_POST = 0.00015
CRYPTO = ("usdt", "usdc", "airdrop", "coinbase", "blockchain", "defi", "staking")


def search(query):
    for _ in range(3):
        r = requests.get(API, headers={"X-API-Key": KEY},
                         params={"query": query, "queryType": "Latest"}, timeout=30)
        if r.status_code == 429:
            time.sleep(float(r.headers.get("Retry-After") or 5))
            continue
        r.raise_for_status()
        return r.json().get("tweets") or []
    # A rate limit is a failure, never "nobody posted".
    raise RuntimeError(f"still rate limited: {query}")


since = (dt.date.today() - dt.timedelta(days=3)).isoformat()
total = 0
for ticker in sys.argv[1:]:
    posts = search(f'(${ticker} OR "{ticker}.AX") -filter:retweets since:{since}')
    total += len(posts)
    hits = [p for p in posts if "?" in p["text"]
            and not any(w in p["text"].lower() for w in CRYPTO)]
    # Print both counts: 0 of 40 and 0 of 0 are different findings.
    print(f"{ticker}: {len(hits)} questions in {len(posts)} posts")
    for p in hits:
        print("  ", p["text"][:120].replace("\n", " "))
    time.sleep(5)  # free tier: one call every 5 s

print(f"{total} posts, about US${total * USD_PER_POST:.4f}")
Enter fullscreen mode Exit fullscreen mode

Run it with python scan.py BHP LIT. I got this today:

BHP: 1 questions in 19 posts
LIT: 4 questions in 19 posts
38 posts, about US$0.0057
Enter fullscreen mode Exit fullscreen mode

Those 19 posts are one page, about 0.3 cents. The output also shows the limit of this filter. The LIT hits were still crypto chatter (one tags @Lighter_xyz), and none used a word on my list. A word list catches the obvious cases and no more. For my real scan I also match the company name and score each post on dilution words like "placement" and "runway". That score only ranks posts. It never drops one.

What bit me

A rate limit looks like silence. On trial credit I got one call every 5 seconds, and 4 of 5 back-to-back searches returned 429. If your code treats that as an empty result, you will conclude nobody is talking. Raise an error instead. Paid accounts get a much higher limit, so the sleep can come down after your first top-up.

A long OR group can return zero with no warning. This query returned 0 posts:

(dilution OR dillution OR dilutive OR diluting OR dilute OR dilutes) Tasmea since:...
Enter fullscreen mode Exit fullscreen mode

Cutting it to (dilution OR dilute) Tasmea returned 12. Nothing reported the problem. Keep OR groups short, and rerun a query you know has hits whenever you grow one.

An unanchored keyword is useless. A bare dilution search returned 20 posts and 0 about the ASX. Adding ASX to the query fixed that, at a lower yield.

Bots fill the first page. During earnings season, 20 of 20 results on one query were announcement bots. Exclude them by name with -from:handle.

Cost comes from pages, not calls. You pay per post returned, so a fat page count over many tickers is the only way to burn real money. My script prints its worst case first, and it stops at a US$0.50 cap per run.

What it costs now

The scan now runs on a schedule. Over the last 7 days it made about 2,600 calls for about US$2.30, or US$0.33 a day. That is roughly US$10 a month, against US$100 for the official entry tier. One downside: the US$10 minimum top-up is a bigger step than a job this size needs.

The result is that a scan that finds nothing now costs a few cents instead of half an hour. If you want to see the data the answers come from, DilutionLens is free to browse.

Top comments (0)