Build an Unusual Options Activity Scanner with Python (FlashAlpha Flow Signals API)
Working Python unusual options activity scanner in under 150 lines. Hits the FlashAlpha Flow Signals API, filters by score, ships audit-able Slack alerts.
Working Python unusual options activity scanner in under 150 lines. Hits the FlashAlpha Flow Signals API, filters by score, ships audit-able Slack alerts.
If you want to build an unusual options activity scanner, you have two choices. You can buy a packaged alert product from a vendor that picks the strategy for you and charges per ticker. Or you can call a transparent flow API yourself and write the scanner you actually want. This article walks through the second route end to end: every line of Python, every parameter, every alert format, all hitting the FlashAlpha Flow Signals API.
The underlying feed scores every block-sized print on a 0-100 scale with six documented components. You filter, rank, render, and alert on top of that. By the end of this article you will have a single-file Python scanner you can run locally, schedule with cron, or wire into a Slack channel.
requestsrequests for HTTP. Optionally rich for pretty terminal output.pip install requests rich
export FLASHALPHA_API_KEY=your_key_here
One function, one HTTP call, one parsed JSON response. The endpoint is GET /v1/flow/signals/{symbol}; the query parameters narrow the result without re-running the pipeline server-side.
import os
import requests
API_BASE = "https://lab.flashalpha.com"
API_KEY = os.environ["FLASHALPHA_API_KEY"]
def fetch_signals(
symbol: str,
window_minutes: int = 240,
min_score: int = 0,
intent: str | None = None,
structure: str | None = None,
limit: int = 50,
expiry: str | None = None,
):
"""One call to /v1/flow/signals/{symbol}."""
params = {
"windowMinutes": window_minutes,
"minScore": min_score,
"limit": limit,
}
if intent: params["intent"] = intent
if structure: params["structure"] = structure
if expiry: params["expiry"] = expiry
resp = requests.get(
f"{API_BASE}/v1/flow/signals/{symbol}",
headers={"X-Api-Key": API_KEY},
params=params,
timeout=10,
)
resp.raise_for_status()
return resp.json()
The response shape: a top-level metadata wrapper plus a signals array. Each signal carries the trade fields, classification, score, breakdown, and the chain context.
The point of a transparent feed is that you can read the per-component score, not just the composite. Render the breakdown alongside the signal so a human triaging the feed can see why each rank fired.
from datetime import datetime
def render_signal(s: dict) -> str:
ts = datetime.fromisoformat(s["ts"].replace("Z", "+00:00"))
bd = s["score_breakdown"]
bd_str = " ".join(
f"{k[:3]}={v:>2}"
for k, v in bd.items()
)
tags = ",".join(s["tags"]) if s["tags"] else "-"
return (
f"{ts:%H:%M:%S} "
f"{s['expiry']} {s['strike']:>6}{s['right']} "
f"{s['side']:>4} "
f"size={s['size']:>5} "
f"${s['premium']:>10,.0f} "
f"score={s['score']:>3} ({s['conviction']:>7}) "
f"{s['intent']:>7}/{s['open_close_bias']:>13} "
f"[{bd_str}] "
f"tags={tags}"
)
def scan_and_print(symbol: str, **kwargs):
data = fetch_signals(symbol, **kwargs)
print(f"\n=== {symbol} - {data['count']} signals (window={data['window_minutes']}m) ===")
print(f"chain: call_wall={data['chain']['call_wall']} "
f"put_wall={data['chain']['put_wall']} "
f"flip={data['chain']['gamma_flip']}")
for s in data["signals"]:
print(render_signal(s))
if __name__ == "__main__":
scan_and_print("SPY", min_score=70, structure="sweep", window_minutes=60)
Run it and you get one line per qualifying signal with every score component, the intent, the open/close bias, the chain context, and the tags. No interpretation layer, no opaque rank.
The summary endpoint (GET /v1/flow/signals/{symbol}/summary) returns the directional roll-up in one call per ticker. Concurrent fetches let you sweep a watchlist in under a second.
from concurrent.futures import ThreadPoolExecutor
WATCHLIST = ["SPY", "QQQ", "IWM", "NVDA", "TSLA", "AAPL", "META", "AMZN"]
def fetch_summary(symbol: str, window_minutes: int = 60):
resp = requests.get(
f"{API_BASE}/v1/flow/signals/{symbol}/summary",
headers={"X-Api-Key": API_KEY},
params={"windowMinutes": window_minutes},
timeout=10,
)
resp.raise_for_status()
return symbol, resp.json()
def watchlist_card(watchlist=WATCHLIST, window_minutes: int = 60):
print(f"\n=== Watchlist Direction Card (window={window_minutes}m) ===")
print(f"{'SYM':6} {'DIR':8} {'NET PREMIUM':>16} "
f"{'OPEN':>14} {'CLOSE':>14} {'SIGNALS':>8}")
with ThreadPoolExecutor(max_workers=8) as pool:
for sym, d in pool.map(
lambda s: fetch_summary(s, window_minutes), watchlist
):
net = d["net_directional_premium"]
direction = "BULLISH" if net > 0 else "BEARISH" if net < 0 else "NEUTRAL"
print(
f"{sym:6} {direction:8} "
f"${net:>14,.0f} "
f"${d['opening_premium']:>12,.0f} "
f"${d['closing_premium']:>12,.0f} "
f"{d['signal_count']:>8}"
)
Output, on a busy session, looks like this:
=== Watchlist Direction Card (window=60m) ===
SYM DIR NET PREMIUM OPEN CLOSE SIGNALS
SPY BULLISH $ 4,820,000 $ 6,420,000 $ 1,600,000 42
QQQ BULLISH $ 1,140,000 $ 2,810,000 $ 1,670,000 28
IWM NEUTRAL $ 0 $ 180,000 $ 180,000 5
NVDA BEARISH $ -2,250,000 $ 410,000 $ 2,660,000 17
TSLA BULLISH $ 3,100,000 $ 3,420,000 $ 320,000 12
AAPL NEUTRAL $ -45,000 $ 80,000 $ 125,000 3
META BULLISH $ 820,000 $ 910,000 $ 90,000 6
AMZN NEUTRAL $ 0 $ 0 $ 0 0
Reading: SPY is bullish with mostly fresh positioning (open > close). NVDA is bearish but the closing premium dominates the opening premium - it is mostly position unwinding, not fresh bearish positioning. AMZN has no qualifying signals at all. This is a dashboard, not a black box.
The opening / closing split is doing real work here. The summary endpoint computes opening_premium and closing_premium separately because direction without the open/close split is misleading - and most UOA feeds publish only the net. The Opening vs Closing Bias article covers why this split is the highest-value field on the summary endpoint.
Compose the filter parameters for the specific pattern you care about. Whale (premium ≥ $1M) sweeps with high conviction:
def whale_sweeps(symbol: str, window_minutes: int = 60):
data = fetch_signals(
symbol,
window_minutes=window_minutes,
structure="sweep",
min_score=70,
limit=50,
)
return [
s for s in data["signals"]
if "whale" in s["tags"]
]
for sym in WATCHLIST:
for s in whale_sweeps(sym):
print(f"{sym} {render_signal(s)}")
The whale tag fires when the trade's premium is at or above $1,000,000. Combine with structure=sweep for the cross-venue urgency signal and min_score=70 for the medium-conviction floor.
The golden tag fires on signals in the top decile of the current result set and at or above 70 score - a dual gate that prevents quiet sessions from promoting weak signals. Use it as your primary triage filter when you want quality without volume.
def golden_signals(symbol: str, window_minutes: int = 240):
data = fetch_signals(symbol, window_minutes=window_minutes)
return [s for s in data["signals"] if "golden" in s["tags"]]
for sym in WATCHLIST:
for s in golden_signals(sym):
print(f"{sym} {render_signal(s)}")
Most directional bets you want to surface are opening flows, not closing flows. The signals endpoint does not expose a ?bias= filter today, but the open_close_bias field is in every record.
def opening_bullish(symbol: str, window_minutes: int = 240, min_score: int = 60):
data = fetch_signals(
symbol,
window_minutes=window_minutes,
intent="bullish",
min_score=min_score,
)
return [
s for s in data["signals"]
if s["open_close_bias"] == "opening_bias"
]
The intent=bullish server-side filter already drops every closing trade (closing flows collapse to Neutral intent - see the Opening vs Closing Bias article). The client-side open_close_bias == "opening_bias" check is the explicit double-gate when you want to be sure.
Wire any of the filters above into a Slack incoming webhook. Send a formatted block per signal, including the score breakdown so the human reading the alert can see why it fired without leaving Slack.
SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL")
def slack_block(symbol: str, s: dict) -> dict:
bd = s["score_breakdown"]
color = (
"#22c55e" if s["intent"] == "bullish"
else "#ef4444" if s["intent"] == "bearish"
else "#a1a1aa"
)
return {
"attachments": [{
"color": color,
"title": (
f"{symbol} {s['expiry']} {s['strike']}{s['right']} "
f"{s['side'].upper()} score={s['score']} ({s['conviction']})"
),
"text": (
f"size: {s['size']:,} "
f"premium: ${s['premium']:,.0f} "
f"intent: {s['intent']}\n"
f"bias: {s['open_close_bias']} "
f"structure: {s['structure']} "
f"tags: {', '.join(s['tags']) or '-'}\n"
f"breakdown: prem={bd['premium']} sz={bd['size_vs_oi']} "
f"aggr={bd['aggressor']} swp={bd['sweep']} "
f"open={bd['opening_bias']} ten={bd['tenor']}"
),
}]
}
def slack_post(symbol: str, s: dict):
if not SLACK_WEBHOOK_URL: return
requests.post(SLACK_WEBHOOK_URL, json=slack_block(symbol, s), timeout=5)
# Wiring example
for sym in WATCHLIST:
for s in golden_signals(sym, window_minutes=15):
slack_post(sym, s)
Replace golden_signals with whichever filter matches your alert taste. The breakdown is in every alert by design - it is the audit trail.
If you poll every minute, the same signal will appear in consecutive responses until it ages out of the window. Hash the signal's natural key and remember what you have already alerted on.
import hashlib
_alerted: set[str] = set()
def signal_key(symbol: str, s: dict) -> str:
raw = f"{symbol}|{s['ts']}|{s['expiry']}|{s['strike']}|{s['right']}|{s['size']}"
return hashlib.sha1(raw.encode()).hexdigest()[:16]
def alert_once(symbol: str, s: dict):
k = signal_key(symbol, s)
if k in _alerted: return
_alerted.add(k)
slack_post(symbol, s)
The natural key combines symbol, timestamp, contract, side, and size. Coalesced sweeps come through with their group's last-print timestamp and combined size, so the key is stable for the duration of the window. For long-running daemons, periodically prune _alerted entries older than your window.
Putting it all together: a small daemon that polls every minute, alerts on golden bullish openings, and never duplicates.
import time
from datetime import datetime, timezone
def loop_forever(
watchlist=WATCHLIST,
poll_seconds: int = 60,
window_minutes: int = 30,
min_score: int = 70,
):
print(f"UOA daemon starting on {watchlist}, polling every {poll_seconds}s")
while True:
try:
for sym in watchlist:
for s in opening_bullish(sym, window_minutes, min_score):
if "golden" in s["tags"]:
alert_once(sym, s)
except requests.HTTPError as e:
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
print(f"{now} HTTP {e.response.status_code} - retrying next tick")
except Exception as e:
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
print(f"{now} {type(e).__name__}: {e} - retrying next tick")
time.sleep(poll_seconds)
if __name__ == "__main__":
loop_forever()
That is the whole scanner. Less than 150 lines, no dependencies beyond requests, alerts to Slack with full breakdown, deterministic de-dup, swappable filters. Wire opening_bullish + "golden" in s["tags"] for your taste; change the watchlist and the poll cadence at the call site.
Five natural extensions you can drop in:
Pass expiry=<today's ET date> to filter the chain to today's expiry. Combine with structure=sweep for intraday 0DTE momentum signals.
For each signal, compare its strike to the response's chain.call_wall / chain.put_wall / chain.gamma_flip. A sweep through a wall is structurally different from one in no-man's-land.
Aggregate signals across the watchlist by ticker, summing premium and weighting by score. Publish the daily top-5 to a Discord channel at 4:05 PM ET.
Combine with a call to /v1/flow/summary/{symbol}. Alert only on signals where the directional intent agrees with the live GEX regime shift (flow_direction=amplifying + bullish intent, for example).
Drop each alerted signal into Postgres or DuckDB keyed by signal_key. Build your own residual analysis: how often did "golden bullish opening sweep" signals precede a meaningful move? The score breakdown makes per-component backtests trivial.
GET /v1/flow/signals/{symbol}) with a score and intent filter, render the per-component breakdown, and post matches to Slack. A working scanner is under 150 lines of Python with only requests as a dependency. The full code is in this article./v1/flow/signals/{symbol} and /v1/flow/signals/{symbol}/summary) require the Alpha plan or higher. Alpha is unlimited request volume and includes raw flow data, advanced volatility analytics, VRP analytics, and the live OI simulator state. See the pricing page or open the playground to verify access./v1/flow/summary for the dealer flow direction, with /v1/flow/pin-risk for the 0-100 pin score, with /v1/vrp/{symbol} for the volatility regime, or with /v1/adv_volatility/{symbol} for the SVI surface. The article's extension list shows specific patterns.premium, size_vs_oi, aggressor, sweep, opening_bias, tenor. Each value is the integer post-weight contribution to the composite score, with the six summing to the composite (within rounding). The methodology paper documents each formula. The breakdown is in every signal response - the audit trail is the differentiator.requests call - no SDK lock-in, no per-vendor field schema to learn. The scanner in this article runs against any optionable US ticker on one Alpha key./v1/flow/signals/{symbol} and sort by premium. Set min_score=0 to disable filtering and limit=500 to get the full notable-trade ring (capped at 512 most recent block prints). Or use the whale tag, which fires automatically at $1M+ premium: filter signals by "whale" in s["tags"]. For a cross-symbol view, the /v1/flow/options/leaderboard and /v1/flow/options/outliers endpoints rank buyers/sellers across the universe in one call.score_breakdown, then POST to a Discord webhook URL. Discord webhooks accept {"content": "...", "embeds": [...]} - replace the slack_block function with a Discord-shaped variant (Discord uses embeds[].fields[] with name/value pairs instead of the Slack attachments shape). The de-duplication, filtering, and polling loop stay identical. The flow signals API does not care about your alert destination.structure field actually meansopen_close_bias field means and why closing trades are NeutralThe Flow Signals endpoints are gated to the Alpha plan. Same key unlocks raw flow, SVI surfaces, VRP analytics, and the live OI simulator state. View pricing, or open the playground to try the endpoints in the browser before subscribing.
by Tomasz Dobrowolski
by Tomasz Dobrowolski
by Tomasz Dobrowolski
Get tick-by-tick visibility into market shifts with full-chain analytics streaming in real time.
Screen millions of option pairs per second using your custom EV rules, filters, and setups.
Instantly send structured orders to Interactive Brokers right from your scan results.