A local market terminal: Finviz-class screening over the whole US universe, the finance networks live beside the tape, Bloomberg-style chrome, no API keys, no vendor, no monthly bill.
pip install -r requirements.txt && python pilot.pySeven thousand symbols, screened locally in milliseconds. The strip along the bottom is the detail for the row under the cursor — share volume, how that compares with a normal day by this hour, dollar volume, and where the price sits in the day's range. Depth on demand, so the grid never has to grow a column for it.
The US finance networks on their own YouTube channels, and what the current headlines are about next to them. The green dots are read from each channel rather than assumed, so the wall knows who is actually on air — here four of seven, coming up on midnight Eastern — and opens on the busiest rather than on a dead player.
Needs Python 3.11 or newer. Nothing else, and no account anywhere.
Runs on Linux, macOS and Windows — the test suites run against all three
on every push. The terminal is a stdlib HTTP server and one HTML file, so
the browser does the work everywhere. Three conveniences are Windows-only
and each degrades to a printed line rather than an error: the tray icon, the
Ctrl+Alt+M hotkey, and --install-shortcut.
pip install -r requirements.txt
python pilot.pyThat's it — the browser opens on its own and the first run builds the universe snapshot in the background.
Two optional things:
- SEC filings. The 10-K/10-Q panel in the ticker view needs a contact
address, because SEC requires one in the User-Agent and returns 403
without it. Put your own email in
~/.quantpilot/config.json:{"sec_contact": "[email protected]"}. Everything else works without it. - Live ticks.
yfinance,websocketsandprotobufare inrequirements.txtand give you the streaming price feed. Leave them out and the terminal falls back to polling — slower, but it says so in the header rather than pretending.
All data lives in ~/.quantpilot. Delete that folder to reset; nothing
is written anywhere else.
python pilot.py # terminal at http://127.0.0.1:8900
python pilot.py --install-shortcut # Desktop + Start Menu shortcuts
python pilot.py --refresh # rebuild the universe snapshot, then exit
python pilot.py --refresh --limit 500 # top 500 by market cap (quick)
python pilot.py --port 9000 --no-browserFirst run builds the snapshot in the background and shows progress in the window. A full rebuild is about 7,000 symbols in 20–35 seconds.
On Windows it sits in the system tray. Ctrl+Alt+M summons it from anywhere; right-click the tray icon for Open / Refresh / Quit.
Screener (F2) — the whole listed universe in a dense sortable grid.
Fifteen Finviz-style dropdowns (sector, market cap, price, P/E, change,
relative volume, volume, 52-week range, moving averages, dividend,
earnings, extended hours, country, ADR, type) plus a command line that
takes free-text expressions. The grid virtualizes, so 7,000 rows scroll
like 40. Prices tick in place, green on an uptick and red on a
downtick.
Heatmap (F3) — squarified treemap grouped by sector, sized by market
cap, coloured by percent change.
Ticker detail (F4) — candles with SMA50/SMA200 and a volume
histogram, 1D through MAX, extended-hours sessions shaded, key stats, a
52-week range bar, recent 10-K/10-Q links from SEC EDGAR, and a news feed
underneath the chart.
Extended and overnight hours — pre-market, after-hours and the
overnight session reach the EXT% column and the filter bar, so "up 5%
after hours" is a screen. US equities trade on ATS venues from roughly
20:00 to 04:00 ET — the middle of the day across Asia — and measured at
00:10 ET, 16 of 30 large caps were actively trading. The header calls
that OVERNIGHT, not CLOSED.
The regular-session price is never merged with any of them. LAST is
always the regular close; an overnight print is a different fact about a
different session, and letting it overwrite LAST is how a terminal ends
up quietly showing one venue's price under another's label.
World map (F6) — 60 countries sized by market cap and coloured by
average change, as geographic bubbles or as equal tiles. Click a country
to screen it.
ADRs (F7) — the 276 foreign companies listed on US exchanges
through depositary receipts, with country as a first-class column.
Earnings (F8) — who reports over the next days, grouped by exchange
day, split into BMO (before the open) and AMC (after the close),
sorted by market cap. Plus an avoid earnings filter on the screener,
because plenty of setups are invalidated by a print you didn't know was
coming.
Market news (F9) — the whole market's news, ranked by how hot it
is. Yahoo's wire carries no tickers and their search takes one symbol at a
time, so the feed asks about whatever is moving hardest — which the
snapshot already knows for free — and merges the two. Hotness is recency
times the size of the move the story is about, so a 20-minute-old piece on
a stock down 17% outranks a fresh opinion column, and roundups tagged with
eight tickers are discounted. Every row carries the ticker it is about and
that ticker's move.
Live news (F10) — the US finance networks, embedded from their own
channels: Bloomberg Television, Yahoo Finance, Schwab Network, Benzinga,
Fox Business, CNBC, Reuters. Who is actually on air is read from each
channel rather than assumed, so the wall shows a green dot against the ones
broadcasting and opens on the busiest — never a dead player. Alongside it,
the tickers the current headlines are about, with their moves, each one a
click from its chart.
It reads the picture, not the sound. YouTube renders live captions inside
its own player but exposes no retrievable track: a live watch page carries
no caption list, and timedtext answers with zero bytes. Their own
documentation puts a downloadable transcript 12–24 hours after the
broadcast ends. So the terminal does not claim to know what is being
said — it shows what is being traded next to the picture, which it can
establish honestly from the snapshot it already holds. Company names in
headlines resolve against the 7,000-symbol universe, so "Nvidia" becomes
NVDA with a live price beside it, while "he was open to a deal" is
correctly not Opendoor.
The embed is the one thing in the terminal that talks to a third party. It
uses youtube-nocookie.com, which sets no tracking cookie unless you press
play, and the iframe is only built when you open the view and a channel is
live — the served page reaches nobody on its own. tests/test_server.py
enumerates every permitted external host so this cannot quietly widen.
Search — a box in the header for tickers and companies, ranked so the
US listing wins: Yahoo otherwise returns NVIDIA's Toronto CDR and
Frankfurt line above US names. / focuses it, arrows pick, Enter opens.
Company news — recent stories under every company's chart, with how long ago
each broke and which other tickers it is tagged with, so a roundup naming
eight companies is visibly not news about the one you are looking at.
Click one and it opens inside the terminal: the page itself cannot be
embedded (publishers send X-Frame-Options), so the server fetches it and
extracts the body text into a reader panel. The original is always one
click away.
Screen diffs — every saved screen records what it matched at each
scheduled refresh, so F5 shows what entered and left since the previous
run. The point of the append-only snapshot design, finally cashed in.
Related companies — every company that names this one in its own SEC annual report, with tickers, live prices and a link to the filing. A true supplier graph is a paid dataset; what EDGAR gives free is disclosed relationships — Apple surfaces Liquidmetal and Qualcomm, Tesla surfaces Aptiv and QuantumScape. It cannot tell a supplier from a customer from a competitor, so it says so, and marks same-sector names as peers.
Relative strength — rs is a stock's move minus its own sector's
cap-weighted move. "Up 3%" on a day the sector rose 3% is not strength.
Screenable: rs > 3 and mcap > 2b.
Compare on the chart — overlay another ticker, normalised to percent, with who is ahead and by how much.
Alerts — turn the bell on and the terminal notifies you when a symbol enters a saved screen, fired off the scheduled refresh so it works while you sleep through the US session.
Watchlist — press W on any row. Live prices, optional shares and
cost basis with P&L. Watched symbols stay on the tick stream even when
you navigate away.
Command line — NVDA, screen <expr>, news <sym>, watch <sym>,
save/load/del <name>, clear, refresh, and
scrn/heat/map/chart/adr/saved to switch view. F1–F10 switch
views, / focuses the prompt, ↑/↓ walk history.
Keyboard — ↑/↓ move the grid cursor, PgUp/PgDn, Home/End,
Enter opens the ticker, W watchlists. Your filters, sort, view and
layout persist across restarts.
The dropdowns and the command line are the same mechanism: the filter bar compiles to an expression string, so anything you can click you can type, save, and reload.
mcap > 10b and chg > 3 and relvol > 2
2 < pe < 30 and price / sma50 > 1.05
sector == "Energy" and div > 3
sector in ("Technology", "Finance") and from52high > -5
"semi" in industry and vol > 1m
- Suffixes
k m b twork anywhere a number does;3%is the same as3. relvolis time-adjusted: volume so far against what a normal session would have traded by now, not against the whole day. Without that, every momentum screen returns nothing before noon.relvolrawkeeps the unadjusted ratio.- Chained comparisons read as you'd expect.
- Text comparisons are case-insensitive.
inis membership on a field and substring search on a string. andornot, arithmetic between fields, parentheses.
Expressions are parsed with ast and walked under an allowlist. eval() is
never called on user text, and neither is DataFrame.query, which executes
attribute access and would happily run @__import__. Anything outside the
allowlist is rejected by name:
> screen __import__("os").system("dir")
call is not allowed in a screen
Field names, aliases and all of it: press F1.
| Source | What it gives | Cost | Freshness |
|---|---|---|---|
| Nasdaq screener | 7,000+ symbols with sector, industry, market cap | free, no key | one call, ~2.3s |
Yahoo /v7/quote |
83 fields per symbol, 200 per call | free, no key | near-real-time; needs a cookie+crumb pair |
| Yahoo websocket | streaming trades, incl. extended hours | free, no key | sub-second, but Yahoo throttles |
Yahoo /v8/chart |
OHLCV bars, any interval | free, no key | 3–25s behind the tape |
| SEC EDGAR | filings, XBRL fundamentals | free | wants a contact string, not a browser UA |
| Yahoo search | per-company news with related tickers | free, no key | minutes old |
| Cboe | option chains with greeks | free, no key | delayed · parked, see below |
| Yahoo futures | ES, NQ, CL, GC and friends | free, no key | ~10 min delayed (CME's free tier) |
Latency is measured, never assumed. Every quote carries the gap between
the venue's timestamp and the local clock, and the header renders whatever
comes back — LAG 4s, LAG 12m, CLOSED. The stream badge reads STREAM
only while ticks are genuinely arriving and falls back to POLL otherwise:
a grid that has silently stopped updating while still labelled live is worse
than one that admits it is polling. Options and futures panels say DELAYED
and mean it.
Real-time futures would need a CME subscription; the delayed feed is the free ceiling.
Three clocks, deliberately separate:
- Universe snapshot — the full rebuild.
app/clock.pyfires one at the open, mid-session and the close, driven by the exchange's ownmarketStaterather than a calendar, so holidays and half-days come out right with no table to maintain. Between rebuilds a rolling pass re-quotes the universe in slices so the snapshot never drifts far from the tape. This is what the screener filters against: locally, in pandas, with no network in the hot path. - Screen-time re-pricing — a screen mentioning
chgorrelvolis a question about now. The static half of the expression narrows the field first, then the survivors get live quotes before the price predicates run. The response says which prices it used —live,snapshotorstatic— and the count line repeats it. - Two-pass screening — the snapshot answer comes back in about 20ms and goes straight on screen; the live-priced answer follows a moment later and replaces it. Waiting for the accurate one means staring at nothing for over a second, so you get rows immediately and the count line says which prices you are looking at.
- Streaming ticks — the websocket pushes trades for whatever is on screen plus everything on your watchlist, and cells flash green or red in place. It never re-screens: a row that stops matching mid-session stays put, which is what you want when you're watching something move.
Snapshots are append-only, keyed by timestamp. Every refresh writes a new
generation instead of overwriting the last. That costs a few MB a day and buys
two things that are otherwise expensive: screen-membership diffs ("what
entered my screen, and when"), and genuine point-in-time history — the only
honest way to backtest a screen without look-ahead bias. Vendors charge real
money for point-in-time fundamentals; accumulating your own from day one is
free. store.prune_snapshots() keeps the most recent 90.
pilot.py launcher · --refresh · --install-shortcut
QuantPilot.spec PyInstaller
app/
config.py settings, ~/.quantpilot/, port 8900
net.py rate-limited, disk-cached HTTP, per-host User-Agent
store.py SQLite: snapshots, screens, bars, positions
universe.py Nasdaq list + Yahoo enrichment -> snapshot -> pandas
screen.py expression engine, filter specs, presets, live overlay
stream.py Yahoo websocket: viewport + pinned subscriptions
clock.py market-state scheduler and rolling re-quote
diffs.py screen membership, run over run
news.py market feed: wire + movers, scored for hotness
article.py in-terminal reader; fetch is keyed to served ids
options.py chain shaping and unusual-activity ranking (parked)
watchlist.py thin layer over the positions table
tray.py Win32 tray icon and Ctrl+Alt+M, pure ctypes
livenews.py which finance networks are on air, and who they name
server.py stdlib HTTP + SSE, 23 routes
web/index.html the entire terminal, self-contained
providers/ nasdaq · yahoo · cboe · sec, behind one Protocol
tools/make_icon.py generates the .ico with the stdlib alone
tests/ plain scripts, no pytest (plus one jsdom smoke test)
Data lives in ~/.quantpilot/ (market.db, httpcache.db, config.json).
Nothing is written inside the repo.
python tests/test_screen.py # expression engine, ADRs, country grouping
python tests/test_store.py # snapshots, screens, positions (temp database)
python tests/test_stream.py # subscriptions, overlay, backoff
python tests/test_options.py # chain shaping, unusual activity
python tests/test_clock.py # session transitions, replayed in milliseconds
python tests/test_server.py # every route, on an ephemeral port
python tests/test_article.py # the reader's address check, every hop of it
node tests/ui_smoke.js # boots the page in jsdomNetwork-dependent checks in test_server.py report as skipped, not failed,
when there is no connection. test_clock.py supplies both the market state
and the clock reading, because waiting until 4pm Eastern to find out whether
the close snapshot fires is not a test.
ui_smoke.js exists because node --check only finds syntax errors. The
failure that actually bites is a runtime throw at load: it kills every
handler registered after it, and the page then renders perfectly while doing
nothing. It borrows the jsdom install from OfflinePilotX/tests, so there is
no new dependency.
app/options.py and providers/cboe.py build full option chains with
delta, gamma, theta, vega, rho, IV and open interest, plus an
unusual-activity ranking — contracts trading above their entire open
interest. It works and tests/test_options.py covers it, but the view was
taken back out of the terminal as too much for now. Re-surfacing it is a
route and a view, not a rewrite.
The server binds 127.0.0.1 only, checks that the Host header names a
loopback address (so a DNS-rebinding page can't reach it), and requires a
per-launch token that is injected into the served page and echoed on every
API call. The SSE stream accepts the same token as a query parameter, because
EventSource cannot set request headers.
Found something? Please report it privately — SECURITY.md has the threat model and the advisory link, and says what is in scope.
The article reader takes a story id, never a URL. A route that fetched whatever address it was handed would be an open proxy running on your machine — usable to probe your own network. Only stories the server has already served as news can be fetched, and even then the host is checked against loopback and private ranges.
Issues and pull requests are welcome — see CONTRIBUTING.md
for the full version, and the
good first issues
for somewhere to start. The codebase is small enough to read in an
afternoon — ## Layout above is the map, and every module opens with a
docstring explaining why it exists rather than what it does.
By taking part you agree to the Code of Conduct; released versions are listed in the changelog.
Tests are plain scripts, no pytest:
python tests/test_screen.py # and test_server, test_store, test_stream, ...
node tests/ui_smoke.js # boots the whole UI in jsdom; needs `npm i jsdom`
pip install -r requirements-dev.txt && ruff check . # what CI lints withRun them before opening a PR. ui_smoke.js is the one that catches a
runtime throw at page load — the failure that leaves the UI rendering
perfectly while every handler after the bad line is dead.
Two house rules, both load-bearing:
- Never claim live over stale data. Lag is measured per response and shown. If a number might be old, the UI has to say so.
- A field we cannot compute stays null. Nothing is imputed, guessed or defaulted into looking like a fact.
Good first issues: un-parking the options view (app/options.py and
providers/cboe.py are written and tested, they just need a route and a
view), indicators inside the expression engine, and a tray icon for macOS
or Linux — the terminal itself already runs on both, so what is missing is
the convenience layer app/tray.py provides on Windows, not a port.
GNU AGPL-3.0 — see LICENSE.
Use it, read it, fork it, change it. The one condition: if you distribute a modified version, or run one as a service other people can reach over a network, you have to publish your source under the same licence. That network clause is the whole reason for choosing AGPL over MIT — it means nobody can take this closed and sell it back as a hosted product.
For ordinary use — running it on your own machine, hacking on it, sending a pull request — it asks nothing of you.
Yahoo's endpoints are undocumented and its terms prohibit automated access and
monetization. That is fine for a single-user terminal on your own machine, and
not fine as the basis of anything published or sold. Every upstream sits
behind providers.base.QuoteProvider, so the day one of them closes — or the
day this needs to be legitimate — only one file changes.


