If you’ve ever tried to automate a Finviz Elite screener, you’ve run into the same problem: the c= query parameter takes a comma-separated list of integer column IDs, and the documentation for exactly what each ID means lives in the UI — not in an API spec. You end up with magic numbers in your URL and no way to know, six months later, why you picked c=1,65,61,63,64,66,67.
This post covers how I solved that with a JSON Schema catalog, and how it powers a pre-market gap scanner that feeds directly into a morning trading game plan.
The Finviz Elite Export Endpoint
The export endpoint is simple:
GET https://elite.finviz.com/export
?v=111
&f={comma-separated filter codes}
&ft=4
&c={comma-separated column IDs}
&auth={YOUR_API_KEY}
The response is a plain CSV with a header row — parse it with any standard CSV library. No pagination, no JSON envelope, no rate-limit headers to worry about. The tricky part is knowing which filters and columns to use.
Filter codes
Filters are human-readable strings like geo_usa, sh_price_o3, ta_gap_u5. The screener URL in the Finviz Elite web app encodes exactly these filter strings in its f= parameter, so you can build a screener visually, copy the URL, and read the filter codes directly.
For a pre-market sweet-spot gap screener ($3–$50, average volume > 500K, gap up > 3%):
f=geo_usa,sh_price_o3,sh_price_u50,sh_avgvol_o500,ta_gap_u3
Column IDs: the problem
Column IDs are integers from 0 to 150. The Finviz UI doesn’t expose them — you can only discover them by inspecting the CSV header names and matching them back to the screener column labels by hand.
The full catalog looks like this (selected trading-relevant columns):
| ID |
Column Name |
Category |
| 1 |
Ticker |
identity |
| 49 |
Average True Range |
technical |
| 61 |
Gap |
price-volume |
| 63 |
Average Volume |
price-volume |
| 64 |
Relative Volume |
price-volume |
| 65 |
Price |
price-volume |
| 66 |
Change |
price-volume |
| 67 |
Volume |
price-volume |
| 68 |
Earnings Date |
events |
| 71 |
After-Hours Close |
price-volume |
| 72 |
After-Hours Change |
price-volume |
| 81 |
Prev Close |
price-volume |
| 86 |
Open |
price-volume |
| 87 |
High |
price-volume |
| 88 |
Low |
price-volume |
There are 150+ columns spanning valuation, growth, ownership, profitability, technical indicators, intraday performance, and more.
Enter the JSON Schema
Rather than keeping these IDs as comments in a URL string, I modeled the full column catalog as a JSON Schema. The schema serves two purposes: it validates c= selections before the request fires, and it’s the canonical reference for what every ID means.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Finviz Export Column Selection",
"type": "object",
"required": ["columns"],
"properties": {
"columns": {
"type": "array",
"uniqueItems": true,
"items": {
"type": "integer",
"enum": [0, 1, 2, 3, ..., 150]
}
},
"asQueryString": {
"type": "string",
"pattern": "^c=\\d+(,\\d+)*$"
},
"profile": {
"type": "string"
}
},
"$defs": {
"columnDefinition": {
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"category": { "type": "string" },
"assetScope": { "type": "string", "enum": ["equity", "etf", "mixed"] }
}
},
"columnCatalog": {
"type": "array",
"items": { "$ref": "#/$defs/columnDefinition" },
"default": [
{ "id": 1, "name": "Ticker", "category": "identity", "assetScope": "mixed" },
{ "id": 61, "name": "Gap", "category": "price-volume", "assetScope": "mixed" },
{ "id": 63, "name": "Average Volume", "category": "price-volume", "assetScope": "mixed" },
{ "id": 64, "name": "Relative Volume", "category": "price-volume", "assetScope": "mixed" },
{ "id": 65, "name": "Price", "category": "price-volume", "assetScope": "mixed" },
{ "id": 66, "name": "Change", "category": "price-volume", "assetScope": "mixed" },
{ "id": 67, "name": "Volume", "category": "price-volume", "assetScope": "mixed" }
]
}
}
}
The columnCatalog default in $defs isn’t used for validation — it’s the reference. Any tool that reads the schema file knows exactly what c=1,65,61,63,64,66,67 means without a comment in the code.
Using It in a Pre-Market Scanner
The schema drives a finviz_screen() function in a Python scanner that runs each morning. The function:
- Builds the
c= parameter from the trading profile defined in the schema
- Fires the export request with the sweet-spot filter set
- Parses the CSV response with
csv.DictReader — column access by name, not by position
- Returns a list of tickers to pass downstream for z-score and Camarilla pivot analysis
import csv, io, urllib.request, ssl
FINVIZ_BASE = "https://elite.finviz.com/export"
FINVIZ_COLS = "1,65,61,63,64,66,67" # matches the schema profile
def finviz_screen(api_key: str, min_gap: float = 3.0) -> list:
filters = f"geo_usa,sh_price_o3,sh_price_u50,sh_avgvol_o500,ta_gap_u{int(min_gap)}"
url = f"{FINVIZ_BASE}?v=111&f={filters}&ft=4&c={FINVIZ_COLS}&auth={api_key}"
ctx = ssl.create_default_context()
req = urllib.request.Request(url, headers={"User-Agent": "phase2_scan/1.0"})
resp = urllib.request.urlopen(req, timeout=15, context=ctx)
content = resp.read().decode("utf-8")
reader = csv.DictReader(io.StringIO(content))
return [
{
"ticker": row["Ticker"].strip(),
"fv_price": row["Price"],
"fv_gap": row["Gap"],
"fv_change": row["Change"],
"fv_volume": row["Volume"],
"fv_rel_vol": row["Relative Volume"],
}
for row in reader if row.get("Ticker", "").strip()
]
Column access by name (row["Gap"], row["Relative Volume"]) means the code is readable without the schema open in another tab. If Finviz ever adds a column or reorders the output, DictReader handles it transparently.
The Full Pipeline
The screener output feeds directly into a z-score and Camarilla pivot stage:
Finviz --screen (gap >3%, $3-$50, vol >500K)
→ ticker list
→ Polygon 22-day OHLCV per ticker
→ 20-day z-score classification (NORMAL / EXTENDED / EXTREME)
→ Camarilla H3/H4/L3/L4 from prior session
→ 5-day ATR proxy
→ setup signal (FL Long watch / Caution / Offsides)
→ formatted table + JSON for Notion
The scanner supports three modes:
# Auto-discover via Finviz
python3 phase2_scan.py --screen
# Strong gappers only
python3 phase2_scan.py --screen --min-gap 5
# News-first: manual tickers (bypass screener)
python3 phase2_scan.py LUNR SOUN MU
# Combine: Finviz + specific news catalyst tickers
python3 phase2_scan.py --screen LUNR
Why Schema First?
The schema adds two things that a comment in a URL string can’t:
Toolability. Any agent, script, or IDE that reads FinvizColumnSchema.json can enumerate valid columns, build a c= string, and validate selections without touching the Finviz UI or documentation. The catalog is the documentation.
Durability. When you come back to this code in six months and want to add Earnings Date (68) or After-Hours Close (71) to the screener, you look up the ID in the schema rather than re-reverse-engineering the API. The schema accumulates knowledge; comments rot.
For a trading workflow that runs every morning before the bell, those two properties matter.