# scanr.ai API documentation > Every page of https://api-docs.scanr.ai, inlined. The index is https://api-docs.scanr.ai/llms.txt; the OpenAPI spec is https://api-docs.scanr.ai/openapi.json. # scanr.ai API The scanr.ai API puts the scanner behind [scanr.ai](https://scanr.ai) in your code and your AI agents. Describe a screen once (price, volume, 100+ technical studies, fundamentals, relative strength, candlestick and chart patterns) and get back every U.S.-listed stock that matches, in one request. It is built for two kinds of caller: **programs** that run screens on a schedule and act on the results, and **AI agents** that turn a question like *"which semiconductor stocks are breaking out on heavy volume?"* into a scan and read the answer. **Connect your agent:** Paste this prompt into your agent. It connects the [MCP server](https://api-docs.scanr.ai/mcp-server.md) or the REST API for you and runs a first scan. ```text Read https://api-docs.scanr.ai/connect.md and follow the instructions to connect me to scanr.ai. ``` - [Quick Start](https://api-docs.scanr.ai/quickstart.md): Create a token and run your first scan in about two minutes. - [How Scanning Works](https://api-docs.scanr.ai/scanning.md): The scan model: universe, filters, groups, timeframes and results. - [MCP Server](https://api-docs.scanr.ai/mcp-server.md): Give Claude, Cursor or any MCP client the scanner as a tool. - [API Reference](https://api-docs.scanr.ai/api): Every endpoint, with request and response schemas. ## How a scan works Every scan, from a script, a notebook or an AI agent, follows the same three steps: 1. **Get a scan payload.** A payload is the JSON that describes a screen. Describe the screen in English and [`POST /scan/natural`](https://api-docs.scanr.ai/natural-language-scans.md) writes the payload for you, or build the scan in the [scanr.ai scanner](https://scanr.ai/scan) and use **Export filters**. Either way you get the same JSON, so you don't need to learn the payload format to start. 2. **Run it.** Send the payload to [`POST /scan`](https://api-docs.scanr.ai/scanning.md) as `instructions`. You get back every matching ticker. 3. **Use the results.** Rank the matches, enrich them with the [data endpoints](https://api-docs.scanr.ai/datasets.md), or [save the scan](https://api-docs.scanr.ai/saved-scans.md) so scanr.ai runs it for you and alerts on new matches. Keep the payload from step 1. Running it again costs one request and no AI credits. The examples in these docs show payloads written out in full so you can read exactly what they screen; you can get the same ones from a sentence or from the scanner. ## What you can do **Scan the market.** The core of the API. One request screens the whole U.S. market and returns every match: - **[Run scans](https://api-docs.scanr.ai/scanning.md)** with any combination of stock, technical, indicator, fundamental and pattern filters, on intraday, daily, weekly or monthly timeframes. - **[Describe a scan in English](https://api-docs.scanr.ai/natural-language-scans.md)** and get a runnable payload back, with the reasoning behind each filter, then refine it in follow-up sentences. - **[Save scans](https://api-docs.scanr.ai/saved-scans.md)** so scanr.ai runs them continuously, then read their alerts, history and forward-return performance. **Read the data behind the scanner.** The same datasets a scan filters on, served directly (see [Datasets](https://api-docs.scanr.ai/datasets.md)): - **Relative strength**: 1–99 percentile ranks against the market, sector and industry, and ranked lists of leaders. - **Chart patterns** and **supply/demand zones** detected per ticker. - **Market themes**: correlation-based groups of stocks, and their daily performance. - **Technical indicators**: 80+ studies computed server-side for any ticker, one at a time or in bulk. - **Reference data**: security search, sectors, industries, peers, ETF holdings and market status. ## Built for agents Every page of these docs is available as plain markdown for AI agents. Add `.md` to any URL, or start from the index at [/llms.txt](https://api-docs.scanr.ai/llms.txt). There is also an installable [Agent Skill](https://api-docs.scanr.ai/agents.md#agent-skill) and a normalized [OpenAPI spec](https://api-docs.scanr.ai/openapi.json). See [Docs for Agents](https://api-docs.scanr.ai/agents.md). ## Base URL ```text https://scanr.ai/api/v2 ``` All endpoints are versioned under `/api/v2`. Requests and responses are JSON; send `Content-Type: application/json` with every request body. Authenticate with a token in the `Authorization` header (see [Authentication](https://api-docs.scanr.ai/authentication.md)). ## Plans API access is a paid add-on with three plans. Every plan gets the full scanner; higher plans add request volume and more datasets: | Plan | Built for | Adds | | --- | --- | --- | | **Starter** | Scanning | The full scanner, natural-language scans, saved scans, reference data, the MCP server | | **Power User** | Signals | Relative strength, stock indicators, chart patterns, peers, ETF holdings, technical indicators, scan performance | | **Business** | Alpha | Supply/demand zones, market themes and theme performance, regime indicators | Limits and the endpoint-by-plan table are on [Plans & Rate Limits](https://api-docs.scanr.ai/rate-limits.md); prices are on [API pricing](https://scanr.ai/api-pricing). ## Versioning & stability The current version is `v2`. Breaking changes ship under a new version path. Additive changes (new endpoints, new response fields, new filters in the scan catalog) can land on `v2` at any time, so treat unknown fields as forward-compatible. ## Status & support - Service status: [status.scanr.ai](https://status.scanr.ai/) - Questions and bugs: [contact@bagsbydata.com](mailto:contact@bagsbydata.com) --- # Quick Start Run your first scan against the whole U.S. market in about two minutes. Every scan takes two calls. First you **get a scan payload**, the JSON that describes the screen. Then you **run it**. You don't write the payload by hand: describe the screen in English and the API writes it, or export a scan you already built in the scanr.ai scanner. ## 1. Get a token 1. Pick an API plan on [API pricing](https://scanr.ai/api-pricing). Every plan includes the full scanner. 2. Create a token at **[Account → API Tokens](https://scanr.ai/account/api-tokens)** with the `read` scope, and copy it. It is shown once. 3. Put it in an environment variable, which every example in these docs reads: ```bash export SCANR_API_TOKEN="scanr_..." ``` Check it works: ```bash curl https://scanr.ai/api/v2/misc/market-status \ -H "Authorization: Bearer $SCANR_API_TOKEN" ``` A JSON body with `"status": {"market": "open" | "closed" | "extended-hours", ...}` means you're in. The cURL examples below use [`jq`](https://jqlang.org) to move JSON between calls. ## 2. Get a scan payload A scan payload says which stocks are eligible and which filters they must pass. There are two ways to get one, and both produce the same JSON. ### Describe it in English `POST /api/v2/scan/natural` turns a sentence into a payload. It returns the payload and an explanation of each filter. It does not run the scan. ```bash curl -s https://scanr.ai/api/v2/scan/natural \ -H "Authorization: Bearer $SCANR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"query": "stocks worth at least $2B, above their 50-day moving average, with relative strength of 80 or more"}' \ > natural.json jq '.reasoning.summary' natural.json # what will be screened, in one sentence jq '.generated_payload' natural.json > payload.json # the payload itself ``` Check `reasoning.summary` before you run anything: it is the quickest way to catch a misread. Generation takes a few seconds and spends [AI credits](https://api-docs.scanr.ai/rate-limits.md#ai-credits); running the payload afterwards doesn't. [Natural-Language Scans](https://api-docs.scanr.ai/natural-language-scans.md) covers the full response. ### Or export it from the scanner If you already screen in the [scanr.ai scanner](https://scanr.ai/scan), use the scan you have: 1. Open the scan, or build one. Presets work too. 2. Click **Filters**, then **Export filters** (or press Ctrl+Shift+E). 3. Click **Download JSON**, or **Copy to clipboard**, and save it as `payload.json`. The export is the same payload `/scan/natural` returns, and costs no AI credits. Filters you've switched off in the scanner stay in the export so it imports back intact; the API skips them when it runs the scan. Scans you've saved can also be read from code with [`POST /scan/myscans`](https://api-docs.scanr.ai/saved-scans.md#read-your-saved-scans). **No credits to spare? Use this payload** This is the screen from the sentence above, written out. Save it as `payload.json`: ```json { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "Stock": { "MarketCap": [ { "State": { "Value": { "Lower": { "Operator": "gte", "Value": 2000000000 } } } } ] }, "Technical": { "SMA": [ { "Timeframe": "1d", "Inputs": ["bar_close"], "Params": { "Period": { "Value": 50 } }, "Returns": { "Want": { "SMA": { "Price": { "Comparison": { "LessThan": true } } } } } } ] }, "Indicator": { "RelativeStrengthMkt": [ { "Returns": { "Want": { "Value": { "GreaterThanOrEqual": 80 } } } } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } ``` ## 3. Run it Send the payload to `POST /api/v2/scan` as `instructions`. The payload goes in unchanged; only the wrapper is new: cURL: ```bash jq '{instructions: .}' payload.json \ | curl https://scanr.ai/api/v2/scan \ -H "Authorization: Bearer $SCANR_API_TOKEN" \ -H "Content-Type: application/json" \ -d @- ``` Python: ```python import json import os import requests API = "https://scanr.ai/api/v2" HEADERS = {"Authorization": f"Bearer {os.environ['SCANR_API_TOKEN']}"} # 1. Get a payload: from a sentence here. For an exported scan instead: # payload = json.load(open("payload.json")) natural = requests.post( f"{API}/scan/natural", headers=HEADERS, json={"query": "stocks worth at least $2B, above their 50-day moving average, with relative strength of 80 or more"}, timeout=120, ).json() if not natural["conversion_success"]: raise SystemExit(natural.get("validation_errors")) print("Screening for:", natural["reasoning"]["summary"]) payload = natural["generated_payload"] # 2. Run it. response = requests.post(f"{API}/scan", headers=HEADERS, json={"instructions": payload}, timeout=60) response.raise_for_status() result = response.json() print(f"{result['count']} matches:", ", ".join(result["tickers"] or [])) ``` JavaScript: ```javascript const API = "https://scanr.ai/api/v2"; const headers = { Authorization: `Bearer ${process.env.SCANR_API_TOKEN}`, "Content-Type": "application/json", }; async function post(path, body) { const response = await fetch(API + path, { method: "POST", headers, body: JSON.stringify(body) }); if (!response.ok) throw new Error(`${response.status} ${await response.text()}`); return response.json(); } // 1. Get a payload: from a sentence here. For an exported scan instead: // const payload = JSON.parse(await readFile("payload.json", "utf8")); const natural = await post("/scan/natural", { query: "stocks worth at least $2B, above their 50-day moving average, with relative strength of 80 or more", }); if (!natural.conversion_success) throw new Error(JSON.stringify(natural.validation_errors)); console.log("Screening for:", natural.reasoning?.summary); const payload = natural.generated_payload; // 2. Run it. const result = await post("/scan", { instructions: payload }); console.log(`${result.count} matches:`, (result.tickers ?? []).join(", ")); ``` The response lists every matching ticker, largest first: ```json { "tickers": ["NVDA", "AVGO", "ORCL", "PLTR", "ANET"], "count": 5, "cached": false, "end_time": "2026-09-29T20:05:12.418Z", "scan_id": 918273 } ``` (The tickers and count depend on the market when you run it.) > **Keep the payload:** `payload.json` is now a reusable screen. Run it again tomorrow, from a cron job or from your agent, for one request and no AI credits. Generating it again from the sentence costs credits and can come back slightly different. ## 4. Change the question To change the screen, **refine** the payload in English. `POST /api/v2/scan/refine` applies your change and keeps every filter you didn't mention: ```bash jq '{existing_payload: ., refinement_query: "only healthcare stocks, and nothing under $10"}' payload.json \ | curl -s https://scanr.ai/api/v2/scan/refine \ -H "Authorization: Bearer $SCANR_API_TOKEN" \ -H "Content-Type: application/json" \ -d @- \ | jq '.refined_payload' > refined.json ``` Run `refined.json` exactly as in step 3. Refinement spends AI credits too. You can also edit the JSON yourself. Every filter is a small object inside the `All` group: **Oversold instead of strong: RSI(14) below 30** Replace the `Technical` block with: ```json "Technical": { "RSI": [ { "Timeframe": "1d", "Inputs": ["bar_close"], "Params": { "Period": { "Value": 14 } }, "Returns": { "Want": { "RSI": { "Value": { "LessThan": 30 } } } } } ] } ``` and drop the `Indicator` block. **Only recent golden crosses** Replace the `Technical` block with an `SMA` 50 that `CrossesAbove` the `SMA` 200 `Within` 5 bars. The full payload is in [Building Scan Payloads](https://api-docs.scanr.ai/scan-payloads.md#compare-with-another-study). **Only one sector** Add a sector filter next to `MarketCap`: ```json "Sector": [{ "State": { "Value": ["Healthcare"] } }] ``` The allowed names come from `GET /api/v2/stocks/sectors`. ## 5. Next steps - [How Scanning Works](https://api-docs.scanr.ai/scanning.md): The model behind the payload you just ran. - [Natural-Language Scans](https://api-docs.scanr.ai/natural-language-scans.md): Generate and refine payloads, and write queries the model reads well. - [Run an End-of-Day Screen](https://api-docs.scanr.ai/guides/end-of-day-screen.md): Run a saved payload every evening and export the matches. - [MCP Server](https://api-docs.scanr.ai/mcp-server.md): Let Claude or Cursor build and run scans for you. --- # Authentication Every scanr.ai API request is authenticated with an **API token**. You create tokens in your account dashboard and send one with every request. > **API access is a paid add-on:** You need an active API plan (Starter, Power User or Business) before you can create a token. If you don't see the API Tokens page, pick a plan on [API pricing](https://scanr.ai/api-pricing). > > Tokens work only while that plan is active. If it is canceled or expires, or a payment fails, every token answers `403` with a message saying the API plan is inactive. The tokens aren't deleted: renew the plan and they work again. ## Create a token 1. Sign in to [scanr.ai](https://scanr.ai). 2. Go to **[Account → API Tokens](https://scanr.ai/account/api-tokens)**. 3. Click **Create token**, give it a name you'll recognize later (`nightly-screen`, `research-agent`), pick its scopes, and optionally set an expiry date. 4. Copy the token. **It is shown once.** scanr.ai stores only a hash, so a lost token cannot be recovered; create a new one instead. Tokens look like `scanr_` followed by 64 hex characters: ```text scanr_4f9c2b...e71a ``` ### Scopes | Scope | Grants | | ------- | ---------------------------------------------------------------------------- | | `read` | Every read endpoint: scans, market data, technicals, reference data. | | `write` | Endpoints that change your account: saving and renaming scans. | A new token gets `read` unless you ask for more. Give each integration only the scopes it uses. ## Send the token Send the token in the `Authorization` header as a bearer token. The REST API also accepts it in an `X-API-Key` header; the [MCP server](https://api-docs.scanr.ai/mcp-server.md) accepts only `Authorization: Bearer`. cURL: ```bash curl https://scanr.ai/api/v2/misc/market-status \ -H "Authorization: Bearer $SCANR_API_TOKEN" ``` Python: ```python import os import requests response = requests.get( "https://scanr.ai/api/v2/misc/market-status", headers={"Authorization": f"Bearer {os.environ['SCANR_API_TOKEN']}"}, ) response.raise_for_status() print(response.json()) ``` JavaScript: ```javascript const response = await fetch("https://scanr.ai/api/v2/misc/market-status", { headers: { Authorization: `Bearer ${process.env.SCANR_API_TOKEN}` }, }); if (!response.ok) throw new Error(`${response.status} ${await response.text()}`); console.log(await response.json()); ``` X-API-Key: ```bash curl https://scanr.ai/api/v2/misc/market-status \ -H "X-API-Key: $SCANR_API_TOKEN" ``` Every example in these docs reads the token from a `SCANR_API_TOKEN` environment variable: ```bash export SCANR_API_TOKEN="scanr_..." ``` ## Personal data access Some endpoints read or change data that belongs to your account: running a scan (each run is recorded in your scan history), saved scans, their performance and alerts. You can switch off API-token access to that data under account settings. While it is off, those endpoints answer `403` with a message saying personal data access is disabled, and the [MCP server](https://api-docs.scanr.ai/mcp-server.md)'s `scan` tool returns the same message as a tool error; market data and reference endpoints, and the other MCP tools, keep working. ## Manage tokens From **[Account → API Tokens](https://scanr.ai/account/api-tokens)** you can: - **List** tokens with their prefix, scopes, status, last-used time and expiry. - **Rename** a token. - **Revoke** a token. It stops working on its next request; the record and its usage history are kept. - **Delete** a token permanently. Usage per token is under **[Account → API Usage](https://scanr.ai/account/api-usage)**. ## Security - **Never commit a token** to source control or paste it into a shared prompt. Use an environment variable or a secrets manager. - **One token per integration**, so a leak means rotating one integration, not all of them. - **Set an expiry** on experimental or short-lived tokens. - **Scope down.** An integration that only reads data doesn't need `write`. - **Revoke immediately** if a token appears in logs, a screenshot or a repository. - **Give agents their own token.** An AI agent with a token can spend your rate limits and AI credits; a dedicated token makes its usage visible and easy to cut off. ## Next - [Quick Start](https://api-docs.scanr.ai/quickstart.md): Run your first scan with the token. - [Plans & Rate Limits](https://api-docs.scanr.ai/rate-limits.md): What each plan allows, and how limits are counted. - [Errors](https://api-docs.scanr.ai/errors.md): What a 401, 403 or 429 means and what to do about it. --- # Plans & Rate Limits API access comes in three plans: **Starter**, **Power User** and **Business**. They differ in two ways: how many requests you can make, and which datasets you can read. They do **not** differ in what a scan can do. Every plan runs the full scanner on every timeframe, universe and filter category, with no delay and no cap on results. Prices are on [API pricing](https://scanr.ai/api-pricing). ## Request limits | Plan | Per minute | Per hour | Per day | | --- | --- | --- | --- | | Starter | 30 | 500 | 5,000 | | Power User | 60 | 2,000 | 20,000 | | Business | 120 | 5,000 | 50,000 | How requests are counted: - **Per account, per endpoint.** Each endpoint has its own counters, shared by all of your tokens. Running 30 scans in a minute on Starter doesn't use up the minute's budget for `/api/v2/stocks/search-securities`, and a second token doesn't add capacity. - **All three windows apply at once.** A request must fit under the minute, hour and day limits. - **MCP tools count separately.** Each [MCP](https://api-docs.scanr.ai/mcp-server.md) tool has its own counters at the same limits, independent of the REST endpoints. - **Windows are fixed.** A window opens with your first request to an endpoint and resets a full minute, hour or day later, however many requests you make in between. A request refused with `429` doesn't move the reset, so retrying doesn't lock you out for longer, but it can't succeed before the reset either. The `429` carries a `Retry-After` header with the seconds to wait, and its message gives the reset time. Live usage per token is under **[Account → API Usage](https://scanr.ai/account/api-usage)**. ### Rate-limit headers Every response to an API-token request carries these headers: | Header | Meaning | | --- | --- | | `RateLimit-Limit` | The request limit of the window closest to running out. | | `RateLimit-Remaining` | Requests left in that window after this one. | | `RateLimit-Reset` | Seconds until that window resets. It's a number of seconds, not a timestamp. | | `RateLimit-Policy` | Every window that applies to the endpoint, as `limit;w=seconds`, for example `30;w=60, 500;w=3600, 5000;w=86400`. | | `Retry-After` | Only on a `429`: seconds to wait before retrying. | "Closest to running out" means the window with the fewest requests left. On a `429` it's the window that refused the request. ```http HTTP/1.1 200 OK RateLimit-Limit: 30 RateLimit-Remaining: 27 RateLimit-Reset: 41 RateLimit-Policy: 30;w=60, 500;w=3600, 5000;w=86400 ``` When `RateLimit-Remaining` reaches `0`, the next request to that endpoint gets a `429` until `RateLimit-Reset` seconds have passed. The AI credit budget below is separate: its `429` is a JSON body with a `resetAt` time, and it has no `Retry-After` header. ### Staying inside the limits - **Scan once, then enrich.** One scan returns every match. Enrich the matches with a single batched call (most data endpoints take a `tickers` list) instead of one call per ticker. - **Cache reference data.** The scan catalog, sectors, industries, themes and countries change rarely. Fetch them once a day. - **Throttle loops.** On Starter, one request every 2 seconds to any one endpoint stays under the per-minute limit. - **Don't re-run to poll.** An identical scan within two minutes may be answered from cache (`"cached": true`, window set by [`cache_ttl_seconds`](https://api-docs.scanr.ai/scanning.md#caching)), and it still counts as a request. To follow a scan through the day, [save it](https://api-docs.scanr.ai/saved-scans.md) and read its alerts instead. ## AI credits Natural-language scanning (`POST /api/v2/scan/natural`, `POST /api/v2/scan/refine`, and the `natural_scan` and `refine_payload` MCP tools) runs a language model, so it also draws on a daily **AI credit** budget: | Plan | AI credits per day | | --- | --- | | Starter | 45,000 | | Power User | 180,000 | | Business | 900,000 | - Credits are weighted by the model work a request needs, so a long or complicated description costs more than a short one. - The budget is shared by the REST endpoints and the MCP tools, and is separate from the AI allowance you use in the scanr.ai app. - It resets at **00:00 UTC**. - A request that starts under budget completes even if it crosses it. The next one gets a `429` with `"code": "api_ai_quota_exceeded"` and the reset time (see [Errors](https://api-docs.scanr.ai/errors.md)). Running a scan (`POST /api/v2/scan`) uses no AI credits, including a scan of a payload the AI generated. Generate a payload once and run it as often as you like. ## Endpoints by plan Each endpoint requires a minimum plan. A token below it gets `403` with the plan it needs. | Area | Endpoints | Plan | | --- | --- | --- | | Scanning | `POST /scan`, `/scan/natural`, `/scan/refine`, `GET /scan/instructions`, `/scan/universe-themes`, `/scan/universe-countries`, `/scan/metrics-popularity` | Starter | | Saved scans | `POST /scan/save-filters`, `/scan/rename-saved-filter`, `/scan/myscans`, `/scan/myscans/scans-for-ticker`, `/scan/paginated-scan-events`, `GET /scan/myscans/scan-history` | Starter | | Reference | `GET /misc/market-status`, `/stocks/search-securities`, `/stocks/sectors`, `/stocks/industries`, `/stocks/ticker-types`, `/stocks/hot-tickers` | Starter | | Signals | `POST /stocks/relative-strength`, `/stocks/get-stock-indicators`, `/stocks/get-chart-patterns`, `GET /stocks/peers` | Power User | | Concept research | `GET /stocks/concept-research/relate`, `/stocks/concept-research/find` | Power User | | ETFs | `GET /stocks/etf/{ticker}/holdings`, `/stocks/etf/{ticker}/holdings/breakdown` | Power User | | Technical indicators | `POST /technicals/technical/*`, including `/technicals/technical/bulk` | Power User | | Scan performance | `/scan/myscans/*-performance*`, `/scan/myscans/*top-n*`, `/scan/myscans/ticker-trade-outcomes` | Power User | | Zones & themes | `POST /stocks/get-supply-demand-zones`, `GET /stocks/themes`, `/stocks/themes/performance/*` | Business | Paths are relative to `https://scanr.ai/api/v2`. > **Regime indicators are Business-only:** The relative-strength and stock-indicator endpoints also return regime fields (`trend_efficiency`, `regime_extension`, `regime_divergence`, `regime_divergence_pct`, `group_strength`, `market_strength`, `market_efficiency`). On a Business token they hold real values. On Power User they are `null` and named in the response's `withheld_fields`. ## Scan limits These hold on every plan: | Limit | Value | | --- | --- | | Filter rows per payload | 200 | | Request body | 1 MiB | | Results per scan | Unlimited: every match is returned | | Timeframes, universes, filter categories | All | --- # Errors The scanr.ai API uses standard HTTP status codes. A `2xx` is success; anything else carries a message saying what went wrong. ## Error bodies Read the **status code** first; it tells you what kind of failure you have. Every error body is JSON, with the message in `error.message`: ```json { "error": { "statusCode": 429, "message": "Rate limit exceeded for minutely requests. Limit: 30 requests per minute. Resets at: 2026-09-29T20:14:00.000Z" } } ``` `message` is written for a person and says what failed: the field that didn't validate, the plan a feature needs, the window that ran out. Don't parse it; branch on the status code. A `5xx` message is for a support request, not for your code to act on. A scan the scanner refuses (`422` from `POST /api/v2/scan`) adds the scanner's `code` and `params`, and `causes` when several refusals applied at once: ```json { "error": { "statusCode": 422, "message": "Custom indicator \"My RSI\" has been switched off.", "code": "scan.custom_indicator.disabled", "params": { "indicator_id": "42", "indicator_name": "My RSI", "version": 3 } } } ``` New scan refusal codes appear without notice, so treat an unknown `code` as "show `message`", never as a failure to parse. One scan `422` is not about the payload alone: `request.query_timeout` means the scan ran past the scanner's time limit. It depends on load as well as on what the scan asks for, so narrow the scan (fewer tickers, filters or lookback), or retry it later with backoff. Concept research refusals add a stable `code` and `retryable`, because one status can mean different things there (a `503` is either "not available yet" or "try again later"). Branch on `code` for those endpoints; see [Research a Concept](https://api-docs.scanr.ai/guides/concept-research.md#refusals). The AI credit budget's `429` adds machine-readable fields: ```json { "error": { "code": "api_ai_quota_exceeded", "message": "Daily AI credit budget of 45,000 reached for the Starter API tier. Upgrade your API subscription to continue using AI features.", "budget": 45000, "used": 45210.4, "usedPct": 100, "resetAt": "2026-09-30T00:00:00.000Z", "tier": "Starter" } } ``` Reading the message takes one line. The fallback covers a response that never reached the API, such as a proxy's error page: Python: ```python import requests def scanr_error(response: requests.Response) -> str: """The message from a failed scanr.ai response.""" try: return response.json()["error"]["message"] except (ValueError, KeyError, TypeError): return response.text ``` JavaScript: ```javascript async function scanrError(response) { const text = await response.text(); try { return JSON.parse(text).error?.message ?? text; } catch { return text; } } ``` ## Status codes | Status | Meaning | What to do | | ------ | ------- | ---------- | | `400 Bad Request` | The request body or query failed validation, or a scan payload is not valid (an unknown filter, an impossible value). The message names the field. | Fix the request. Don't retry it unchanged. | | `401 Unauthorized` | No token, a malformed token, or a token that is expired or revoked. | Check the header and the token. See [Authentication](https://api-docs.scanr.ai/authentication.md). | | `403 Forbidden` | The token is valid but not allowed here: the account's API plan is inactive, the endpoint needs a higher plan, the token lacks a scope (`write`), or personal data access for API tokens is switched off. | Read the message. Renew or upgrade the plan, add the scope, or re-enable data access. | | `404 Not Found` | The resource doesn't exist: an unknown saved scan, a ticker with no data for that endpoint, or a path the API doesn't have. | Check the identifier and the path. | | `413 Payload Too Large` | The request body is over the endpoint's size limit (1 MiB for scans). | Send a smaller payload. | | `422 Unprocessable Entity` | A scan payload is valid but the scanner won't run it: a custom indicator you can't use or that was switched off, a pattern model that needs retraining, too many models or scripts in one scan, a filter value the scanner can't take. `code` names the refusal. With `code` `request.query_timeout`, the scan ran past the scanner's time limit instead. | Change the payload. Don't retry it unchanged, except a `request.query_timeout`, which can succeed later. | | `429 Too Many Requests` | A per-minute, per-hour or per-day request limit, or the daily AI credit budget, is used up. The message says which. | Wait the seconds in the `Retry-After` header (or until `resetAt`, for the AI credit budget) before retrying. See [Plans & Rate Limits](https://api-docs.scanr.ai/rate-limits.md). | | `500 Internal Server Error` | Something failed on our side. | Retry with backoff. If it persists, contact us with the time and endpoint. | | `502` / `503` / `504` | An upstream data service was unavailable or slow, or (`503`) your token's plan couldn't be checked. | Retry with backoff. | ## Retrying Retry only what can succeed on a second attempt: - **Retry** `429` after the seconds in its `Retry-After` header, and `500`, `502`, `503` and `504` with exponential backoff (for example 1 s, 2 s, 4 s, then give up). - **Don't retry** `400`, `401`, `403`, `404`, `413` or `422`. The same request will fail the same way. The exception is a scan `422` with `code` `request.query_timeout`: narrowing the scan is the reliable fix, but the same scan can also succeed later, so it may be retried with backoff. - A retry before the reset time is refused again. It doesn't extend the window, but it wastes a call, so wait for the reset instead of looping. Python: ```python import time import requests RETRYABLE = {429, 500, 502, 503, 504} def request_with_retry(method, url, *, attempts=4, **kwargs): for attempt in range(attempts): response = requests.request(method, url, timeout=60, **kwargs) if response.status_code not in RETRYABLE or attempt == attempts - 1: return response # A 429 says how long to wait; 5xx are usually brief. if response.status_code == 429: time.sleep(int(response.headers.get("Retry-After", 60))) else: time.sleep(2**attempt) return response ``` JavaScript: ```javascript const RETRYABLE = new Set([429, 500, 502, 503, 504]); async function requestWithRetry(url, init, attempts = 4) { for (let attempt = 0; ; attempt++) { const response = await fetch(url, init); if (!RETRYABLE.has(response.status) || attempt === attempts - 1) { return response; } // A 429 says how long to wait; 5xx are usually brief. const delayMs = response.status === 429 ? Number(response.headers.get("Retry-After") ?? 60) * 1000 : 2 ** attempt * 1000; await new Promise((resolve) => setTimeout(resolve, delayMs)); } } ``` ## MCP errors The [MCP server](https://api-docs.scanr.ai/mcp-server.md) reports errors differently. Authentication failures are an HTTP error before any tool runs, with the reason in `error_description`: `401` for a missing, malformed, revoked or expired token, `403` for a missing `read` scope or an inactive API plan, `503` when the plan couldn't be checked. Everything else (a rate limit, the AI budget, personal data access being off, an invalid scan payload, a scan the scanner refused) comes back as a normal tool result with `isError: true` and the message as text, so the assistant can read it and adjust. A refused scan's text ends with `Refusal code: `, the same `code` the REST `422` carries. ```json { "error": "invalid_token", "error_description": "Token has expired" } ``` ```json { "isError": true, "content": [ { "type": "text", "text": "Rate limit exceeded for minutely requests. Limit: 30 requests per minute. Resets at: 2026-09-29T20:14:00.000Z" } ] } ``` --- # How Scanning Works A scan asks one question of the whole market: *which stocks match these conditions right now?* The conditions are a **scan payload**. You get a payload, send it to `POST /api/v2/scan`, and get back every matching ticker in a single response. There is no pagination and no per-result cost: one scan is one request, whether it matches 3 stocks or 3,000. Scanning is the core of the scanr.ai API. Everything else (relative strength, patterns, themes, indicators) either feeds a scan as a filter or enriches its results. ## Get a payload Start from a payload someone, or something, already wrote. In order of how often you'll want them: 1. **From a sentence.** `POST /api/v2/scan/natural` (the `natural_scan` tool over [MCP](https://api-docs.scanr.ai/mcp-server.md)) turns *"large caps above the 200-day with RSI under 40"* into a payload, with an explanation of each filter. Change it with follow-up sentences through `POST /api/v2/scan/refine`. See [Natural-Language Scans](https://api-docs.scanr.ai/natural-language-scans.md). 2. **From the scanr.ai scanner.** Build or open a scan in the [scanner](https://scanr.ai/scan), click **Filters → Export filters** (Ctrl+Shift+E), and copy or download the JSON. For a scan you've saved, `POST /api/v2/scan/myscans` returns the same payload to code. See [Saved Scans](https://api-docs.scanr.ai/saved-scans.md#read-your-saved-scans). 3. **By hand**, from [Building Scan Payloads](https://api-docs.scanr.ai/scan-payloads.md) and the [catalog](#discovering-filters). Best for small edits to a payload you already have, or when a screen must never drift. All three produce the same JSON, and you can move between them: generate a payload from a sentence, tweak it by hand, save it, open it in the scanner, export it again. Store the payloads you use. Running a stored payload costs one request and no AI credits; generating it again costs credits and can come back slightly different. Generated and exported payloads are the bare payload, the object with `Universe` and `All`. To run one, send it as `instructions`: ```text POST /api/v2/scan {"instructions": } ``` ## The request The body has one required key, `instructions`, which holds the scan payload. Written out, a payload looks like this: ```json { "instructions": { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "Stock": { "MarketCap": [ { "State": { "Value": { "Lower": { "Operator": "gte", "Value": 2000000000 } } } } ] }, "Indicator": { "RelativeStrengthMkt": [ { "Returns": { "Want": { "Value": { "GreaterThanOrEqual": 80 } } } } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } } ``` That payload reads: *of the common stocks and ADRs listed on the major U.S. exchanges, return every company worth at least $2B whose relative strength against the market is in the top 20%, largest first.* A payload has four parts: | Key | What it does | | --- | --- | | `Universe` | Which securities are eligible at all: exchanges, security types, countries, themes. | | `All`, `Any`, `Exclude` | Groups of filters, and how their matches combine. | | `Sort` | The order of the returned tickers. | | `instructions` siblings | `save_args` attaches the run to one of your [saved scans](https://api-docs.scanr.ai/saved-scans.md). | ## Universe The universe narrows the market before any filter runs. Within one key, values are alternatives (`nasdaq` **or** `nyse`); across keys, they all apply (an exchange **and** a type). | Key | Values | | --- | --- | | `Exchange` | `nasdaq`, `nyse`, `amex`, `arca`, `bats`, `bx`, `otc` | | `Type` | Security types: `cs` common stock, `os` ordinary shares, `adrc` ADR (common), `etf`, `etn`, `etv`, `fund`, `pfd` preferred, `warrant`, `right`, `unit`, and more. See the `UniverseFilter` schema in the [API Reference](https://api-docs.scanr.ai/api). | | `Country` | Country of domicile, as lower-case ISO codes (`us`, `ca`, `il`), plus `unknown`. List them with `GET /api/v2/scan/universe-countries`. | | `Theme` | Market themes by key, for example `silver-miners`. List them with `GET /api/v2/scan/universe-themes`. See [Track Theme Rotation](https://api-docs.scanr.ai/guides/theme-rotation.md). | | `ThemeMaxTier` | How deep into each theme to go: `1` core members only, `2` core and satellite members (the default), `3` every member. | > **Always set Exchange and Type:** An empty `Exchange` admits OTC listings, and an empty `Type` falls back to a default that includes ETFs. Unless you want those, send the explicit list shown above. Every scanr.ai preset does. Sector, industry and market cap are not universe keys; they are `Stock` filters (see [Building Scan Payloads](https://api-docs.scanr.ai/scan-payloads.md#stock-filters)). ## Filters and groups Filters live inside **groups**, and each group sits in one of three arrays: - **`All`**: a stock matches the group when it passes *every* filter in it. - **`Any`**: a stock matches the group when it passes *at least one* filter in it. - **`Exclude`**: stocks that match the group are removed from the result. The final result is: ```text (matches of the All groups) ∪ (matches of the Any groups) − (matches of the Exclude groups) ``` Note the union: an `All` group and an `Any` group at the same level widen the result rather than narrow it. Most scans need a single `All` group, optionally with an `Exclude` group. Inside a group, filters are organized by **category**. Each category key holds a map of filter name → list of filter rows: | Category | Screens on | Example filters | | --- | --- | --- | | `Stock` | Quote and profile data | `MarketCap`, `PrevDayClose`, `PrevDayVolume`, `ChangePercent`, `Sector`, `Industry`, `Week52High`, `Earnings` | | `Technical` | 100+ price and volume studies on any timeframe | `SMA`, `EMA`, `RSI`, `MACD`, `ATR`, `BBANDS`, `ADX`, `RELVOL_STDDEV` | | `Indicator` | scanr.ai's pre-computed scores | `RelativeStrengthMkt`, `RelativeStrengthSector`, `RelativeStrengthIndustry`, `GroupStrength`, `TrendEfficiency`, `RegimeExtension` | | `Fundamental` | 300+ metrics from quarterly and annual filings | `Epsgrowth`, `Revenuegrowth`, `Dilutedeps`, `Netincome` | | `CandlestickPattern` | 60+ candlestick patterns | `CDLENGULFING`, `CDLHAMMER`, `CDLMORNINGSTAR` | | `ChartPattern` | 17 chart patterns | `CupAndHandle`, `BullFlag`, `AscendingTriangle`, `DoubleBottom` | | `Zone` | Supply and demand zones | `Inside` a demand or supply zone | | `InsiderTransactions`, `Short` | Insider activity and short data | | The full, current list of every filter, with its parameters, defaults and allowed values, is served by `GET /api/v2/scan/instructions` ([below](#discovering-filters)). [Building Scan Payloads](https://api-docs.scanr.ai/scan-payloads.md) shows the shape of each category with worked examples. ## Timeframes Technical studies, candlestick patterns and chart patterns run on a **timeframe**, which is required on each of those filters: | Kind | Values | | --- | --- | | Intraday | `1m` `2m` `3m` `4m` `5m` `10m` `15m` `20m` `30m` `1hr` `2hr` `4hr` | | Daily and longer | `1d` `2d` `3d` `4d` `1wk` `1mo` | The values are exact: `1w` or `1h` is a `400`. Filters in one scan can use different timeframes, so "daily trend up, 15-minute RSI oversold" is a single payload. ## The response ```json { "tickers": ["NVDA", "AVGO", "ANET", "APP"], "count": 4, "cached": false, "end_time": "2026-09-29T20:05:12.418Z", "scan_id": 918273, "universe_size": 5874 } ``` | Field | Meaning | | --- | --- | | `tickers` | Every matching ticker, in `Sort` order. `null` when nothing matched. | | `count` | How many tickers matched. | | `cached` | `true` when an identical payload ran recently and its result was reused (see [Caching](#caching)). | | `scan_id` | The run's scan record. For a run attached to a saved scan, that scan's id. | | `universe_size` | How many securities were evaluated after the universe was applied. | ### Caching An identical payload run again within **two minutes** is answered from cache, with `"cached": true`. To choose that window, send `cache_ttl_seconds` in the request body next to `instructions`: `0` always recomputes, and the most is `900` (15 minutes). During regular market hours a scan on live data is never reused for more than 5 minutes, whatever you ask for. A cached answer still counts as a request. A scan returns **tickers, not rows of data**. To show why each stock matched, or to rank the matches, enrich them with the data endpoints, for example `POST /api/v2/stocks/get-stock-indicators` for relative-strength scores. [Run an End-of-Day Screen](https://api-docs.scanr.ai/guides/end-of-day-screen.md) does exactly that. ## When data updates A scan reads the latest data at the moment it runs: - **Price and volume studies** include the current session's in-progress bar during market hours. A daily RSI at 11:00 is computed from today's partial candle; the same scan after the close sees the settled day. - **Relative strength and regime scores** are recomputed about once a minute during the session and settle after the close. - **Fundamentals** reflect the most recent filing. Run the same scan at 16:30 ET and at 11:00 ET the next morning and you may get different results; both are correct for their moment. For a stable daily list, run after the close. See [Coverage & Freshness](https://api-docs.scanr.ai/coverage.md) for each dataset's schedule. ## Discovering filters Three read-only endpoints describe what a payload can contain. The catalog and the country list answer without a token, so you can explore before you subscribe; the theme list needs one. | Endpoint | Returns | | --- | --- | | `GET /api/v2/scan/instructions` | The full filter catalog: every category, filter, parameter (with default, min and max), input series, output plot name, timeframe and operator. | | `GET /api/v2/scan/universe-themes` | Every scannable theme, with its `key`, label and member counts. | | `GET /api/v2/scan/universe-countries` | Every selectable country code. | The catalog is large (hundreds of KB). Fetch it once and cache it; it changes when new filters ship, not between requests. cURL: ```bash curl -s https://scanr.ai/api/v2/scan/instructions \ | jq '.Technical.RSI[0] | {Params, Returns, Inputs}' ``` Python: ```python import requests catalog = requests.get("https://scanr.ai/api/v2/scan/instructions").json() rsi = catalog["Technical"]["RSI"][0] print(rsi["Params"]) # {'Period': {'Value': 14, 'Min': 0, 'Max': 200, ...}} print(rsi["Returns"]) # {'Plot': {'RSI': {...}}}: the key a filter compares on ``` ## Limits - **Every API plan gets the full scanner:** every timeframe, OTC and ETF universes, themes and every filter category. Plans differ in request volume, not in what a scan may contain. See [Plans & Rate Limits](https://api-docs.scanr.ai/rate-limits.md). - A payload may hold up to **200 filter rows** and **1 MiB** of JSON. - Every scan counts as one request against your rate limits and is recorded in your scan history for 30 days. --- # Building Scan Payloads This page builds scan payloads one filter category at a time. Every example is a complete request body for `POST /api/v2/scan` and runs as-is. Read [How Scanning Works](https://api-docs.scanr.ai/scanning.md) first for the model: universe, groups, filters and timeframes. > **You rarely start here:** Most payloads begin as a sentence sent to [`/scan/natural`](https://api-docs.scanr.ai/natural-language-scans.md), or as **Export filters** from the [scanr.ai scanner](https://scanr.ai/scan) (see [Get a payload](https://api-docs.scanr.ai/scanning.md#get-a-payload)). Use this page to read what those produce, check it, and make small edits by hand. An exported or generated payload is the object inside `instructions` in the examples below. All examples use the same universe (common stocks, ordinary shares and ADRs on the major U.S. exchanges) and sort largest-first, so only the filters change between them. ## The skeleton ```json { "instructions": { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "Stock": { "PrevDayClose": [ { "State": { "Value": { "Lower": { "Operator": "gte", "Value": 5 } } } } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } } ``` Three rules hold everywhere: - A category (`Stock`, `Technical`, …) maps a **filter name** to a **list of rows**. Two rows under one name are two separate conditions, and both must hold in an `All` group. - Send numbers as plain JSON numbers. - The catalog (`GET /api/v2/scan/instructions`) echoes descriptive fields such as `DisplayName`, `Description`, `Type` and `InstructionType`. Leave them out of your payload; the one exception is `DisplayName` on chart patterns ([below](#chart-patterns)). ## Stock filters `Stock` filters screen on quote and profile data: `PrevDayClose`, `PrevDayVolume`, `TodaysVolume`, `Last`, `Change`, `ChangePercent`, `Gap`, `MarketCap`, `SharesFloat`, `SharesOutstanding`, `Week52High`, `Week52Low`, `Sector`, `Industry`, `Earnings` and more. **Numeric ranges** take a `Lower` bound (`gt` or `gte`), an `Upper` bound (`lt` or `lte`), or both. Market cap is in dollars: ```json { "instructions": { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "Stock": { "MarketCap": [ { "State": { "Value": { "Lower": { "Operator": "gte", "Value": 300000000 }, "Upper": { "Operator": "lt", "Value": 2000000000 } } } } ], "PrevDayVolume": [ { "State": { "Value": { "Lower": { "Operator": "gte", "Value": 500000 } } } } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } } ``` **Price comparisons** compare a level with the current price. `Week52High` with `EqualTo` and a `WithinPercent` tolerance finds stocks trading within 15% of their 52-week high: ```json { "instructions": { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "Stock": { "Week52High": [ { "State": { "Price": { "Comparison": { "EqualTo": true }, "WithinPercent": 15 } } } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } } ``` **Sector and industry** take a list of names. The allowed names are in the catalog (`Stock.Sector[0].State.Options`) and at `GET /api/v2/stocks/sectors` and `GET /api/v2/stocks/industries`: ```json { "instructions": { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "Stock": { "Sector": [{ "State": { "Value": ["Healthcare"] } }], "MarketCap": [ { "State": { "Value": { "Lower": { "Operator": "gte", "Value": 1000000000 } } } } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } } ``` ## Technical studies `Technical` holds 100+ studies: moving averages (`SMA`, `EMA`, `WMA`, `KAMA`, …), momentum (`RSI`, `MACD`, `STOCH`, `ADX`, `CCI`, `MFI`, …), volatility (`ATR`, `NATR`, `BBANDS`), volume (`OBV`, `AD`, `RELVOL_STDDEV`) and statistics (`LINEARREG_SLOPE`, `CORREL`, `STDDEV`). Each row has: | Field | Meaning | | --- | --- | | `Timeframe` | **Required.** `1d`, `1wk`, `15m`, … (see [Timeframes](https://api-docs.scanr.ai/scanning.md#timeframes)). | | `Inputs` | The series the study runs on, such as `["bar_close"]` or `["bar_high", "bar_low", "bar_close"]`. Use the study's default from the catalog unless you mean otherwise. | | `Params` | The study's parameters, each as `{ "Value": n }`: `{ "Period": { "Value": 50 } }`. Omitted parameters use their defaults. | | `Returns.Want` | The condition. Its key is the study's **output plot name**, and its value says what to compare the output with. | | `Within` | Optional. The condition may hold on any of the last *N* bars instead of only the latest. | | `BarsAgo` | Optional. Evaluate the condition *N* bars back instead of on the latest bar. | ### Plot names The key inside `Want` is an output name from the catalog's `Returns.Plot`. For most studies it is the study's own name, but not always: | Study | Plot names | | --- | --- | | `SMA`, `EMA`, `RSI`, `ATR`, `ADX` | Same as the study: `SMA`, `EMA`, `RSI`, `ATR`, `ADX` | | `MACD` | `MACD`, `Signal`, `MACDHist` | | `BBANDS` | `Upper`, `Middle`, `Lower` | | `STOCH` | `SlowK`, `SlowD` | | `RELVOL_STDDEV` | `VALUE` | ### Compare with a number RSI(14) below 30 on the daily chart: ```json { "instructions": { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "Technical": { "RSI": [ { "Timeframe": "1d", "Inputs": ["bar_close"], "Params": { "Period": { "Value": 14 } }, "Returns": { "Want": { "RSI": { "Value": { "LessThan": 30 } } } } } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } } ``` The comparators are `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`, `EqualTo` and `NotEqualTo`. ### Compare with the price `Price` compares the study with the current price, and reads *study* `` *price*. "Price above its 200-day SMA" is therefore `SMA` `LessThan` price: ```json { "instructions": { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "Technical": { "SMA": [ { "Timeframe": "1d", "Inputs": ["bar_close"], "Params": { "Period": { "Value": 200 } }, "Returns": { "Want": { "SMA": { "Price": { "Comparison": { "LessThan": true } } } } } } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } } ``` ### Compare with another study `Study` compares one study's output with another's. The inner study marks the output it contributes with `"PlotChild": true`. Add `CrossesAbove` or `CrossesUnder` to ask for a cross rather than a state, and `Within` to say how recent. A golden cross, the 50-day SMA crossing above the 200-day SMA in the last 5 sessions: ```json { "instructions": { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "Technical": { "SMA": [ { "Timeframe": "1d", "Inputs": ["bar_close"], "Params": { "Period": { "Value": 50 } }, "Within": 5, "Returns": { "Want": { "SMA": { "Study": { "CrossesAbove": { "SMA": [ { "Timeframe": "1d", "Inputs": ["bar_close"], "Params": { "Period": { "Value": 200 } }, "Returns": { "Want": { "SMA": { "PlotChild": true } } } } ] } } } } } } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } } ``` For "the 50-day is above the 200-day" (a state, not a cross), use `GreaterThan` and drop `Within`. ### Volume and liquidity `RELVOL_STDDEV` measures today's volume in standard deviations from its recent average; its plot name is `VALUE`. Pair it with an `SMA` of `bar_dollar_volume` to skip illiquid names. This is scanr.ai's Unusual Volume screen: ```json { "instructions": { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "Technical": { "RELVOL_STDDEV": [ { "Timeframe": "1d", "Inputs": ["bar_volume"], "Params": { "Length": { "Value": 60 } }, "Returns": { "Want": { "VALUE": { "Value": { "GreaterThan": 3 } } } } } ], "SMA": [ { "Timeframe": "1d", "Inputs": ["bar_dollar_volume"], "Params": { "Period": { "Value": 50 } }, "Returns": { "Want": { "SMA": { "Value": { "GreaterThan": 10000000 } } } } } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } } ``` The input series a study can read (`bar_close`, `bar_volume`, `bar_dollar_volume`, and more) are listed under `Reference.ValidStudyInputs` in the catalog. ## Indicator filters `Indicator` filters read scanr.ai's pre-computed scores for each stock: | Filter | Range | Meaning | | --- | --- | --- | | `RelativeStrengthMkt` | 1–99 | Percentile of trailing performance against the whole market. 99 is the strongest. | | `RelativeStrengthSector` | 1–99 | The same, against the stock's sector. | | `RelativeStrengthIndustry` | 1–99 | The same, against the stock's industry. | | `TrendEfficiency` | 0–100 | How cleanly the stock is trending rather than chopping. | | `RegimeExtension` | −100–100 | How far price is stretched from its adaptive mean, in ATR units ×10. | | `RegimeDivergence`, `RegimeDivergencePct` | −200–200, 1–99 | Extension relative to the stock's peer group, raw and ranked. | | `GroupStrength` | −100–100 | The extension of the stock's sector or thematic ETF. Positive means the group itself is strong. | | `MarketStrength`, `MarketEfficiency` | −100–100, 0–100 | The same readings for the broad market; identical for every stock. | | `PETTM` | 0 and up | Trailing P/E. Loss-making companies have none and never match. | Each row compares the score's `Value`. Leaders in strong groups (RS 80+ and a positive group): ```json { "instructions": { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "Indicator": { "RelativeStrengthMkt": [ { "Returns": { "Want": { "Value": { "GreaterThanOrEqual": 80 } } } } ], "GroupStrength": [ { "Returns": { "Want": { "Value": { "GreaterThanOrEqual": 10 } } } } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } } ``` ## Fundamental filters `Fundamental` filters read 300+ metrics from company filings: growth rates (`Epsgrowth`, `Revenuegrowth`, `Revenueqoqgrowth`), per-share figures (`Dilutedeps`), income, balance-sheet and cash-flow lines, and valuation ratios. Each row's `State` says which filing to read and what to compare: | Field | Meaning | | --- | --- | | `FilingType` | `10q` (quarterly) or `10k` (annual). | | `Within` | How many recent filings to consider. | | `Lag` | How many filings back to start: `0` is the latest, `4` is the same quarter a year earlier. | | `WantsValue` | Compare with a number: `SingleValue` with one comparator set to `true` and a `Value`, or `RangeValue` with `Lower` and `Upper`. | | `WantsOtherFundamental` | Compare with another fundamental row, under `Comp`. | Growth rates are fractions: `0.25` means 25%. Annual EPS growth of at least 25%, with the latest quarter's diluted EPS above the same quarter last year: ```json { "instructions": { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "Fundamental": { "Epsgrowth": [ { "State": { "FilingType": "10k", "WantsValue": { "SingleValue": { "GreaterThanOrEqual": true, "Value": 0.25 } } } } ], "Dilutedeps": [ { "State": { "FilingType": "10q", "Within": 1, "Lag": 0, "WantsOtherFundamental": { "GreaterThan": true, "Comp": { "Dilutedeps": [ { "State": { "FilingType": "10q", "Within": 1, "Lag": 4 } } ] } } } } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } } ``` The metric names are the keys of the catalog's `Fundamental` section. ## Candlestick patterns `CandlestickPattern` holds 60+ patterns named after their TA-Lib functions (`CDLHAMMER`, `CDLMORNINGSTAR`, `CDLENGULFING`, `CDL3WHITESOLDIERS`, …). Each row sets a `Timeframe`, the `Inputs` the pattern reads, and `Within` for how many recent bars may contain it. A hammer in the last 3 sessions, with RSI still below 40: ```json { "instructions": { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "CandlestickPattern": { "CDLHAMMER": [ { "Timeframe": "1d", "Inputs": ["bar_open", "bar_high", "bar_low", "bar_close"], "Within": 3 } ] }, "Technical": { "RSI": [ { "Timeframe": "1d", "Inputs": ["bar_close"], "Params": { "Period": { "Value": 14 } }, "Returns": { "Want": { "RSI": { "Value": { "LessThan": 40 } } } } } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } } ``` > **Two-sided patterns:** Some patterns (`CDLENGULFING`, `CDLHARAMI`) fire in both directions. Set `Direction` on the row to keep one side: `"bullish"`, `"bearish"`, or `"any"` (the default). A bullish engulfing in the last 5 sessions is `"CDLENGULFING": [{ "Timeframe": "1d", "Inputs": ["bar_open", "bar_high", "bar_low", "bar_close"], "Within": 5, "Direction": "bullish" }]`. > > `SupportedDirections` on each pattern in `GET /api/v2/scan/instructions` lists the sides it can fire. A side a pattern never fires, such as `"bearish"` on `CDLHAMMER`, is rejected. `Sentiment` is a descriptive label and does not filter. ## Chart patterns `ChartPattern` finds 17 detected formations: `CupAndHandle`, `BullFlag`, `BearFlag`, `AscendingTriangle`, `DescendingTriangle`, `SymmetricalTriangle`, `DoubleBottom`, `DoubleTop`, `HeadAndShoulders`, `InverseHeadAndShoulders`, `RisingWedge`, `FallingWedge`, `ChannelUp`, `ChannelDown`, `HorizontalChannel`, `MultipleTop` and `MultipleBottom`. Each row carries the pattern's `DisplayName` from the catalog (it identifies the pattern and must be kept), its direction, a `Timeframe` (`5m`, `1hr` or `1d`) and how far back to look: ```json { "instructions": { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "ChartPattern": { "CupAndHandle": [ { "DisplayName": "Cup and Handle", "BullishBearish": "BULLISH", "Timeframe": "1d", "LookbackBars": 100 } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } } ``` Copy each pattern's row from the catalog's `ChartPattern` section and change only `Timeframe` and `LookbackBars`. ## Excluding stocks An `Exclude` group removes its matches from the result. Large caps above their 50-day average, except financials: ```json { "instructions": { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "Stock": { "MarketCap": [ { "State": { "Value": { "Lower": { "Operator": "gte", "Value": 10000000000 } } } } ] }, "Technical": { "SMA": [ { "Timeframe": "1d", "Inputs": ["bar_close"], "Params": { "Period": { "Value": 50 } }, "Returns": { "Want": { "SMA": { "Price": { "Comparison": { "LessThan": true } } } } } } ] } } ], "Exclude": [ { "Stock": { "Sector": [{ "State": { "Value": ["Financial", "Finance, Insurance, And Real Estate"] } }] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } } ``` ## Universe restrictions Countries and themes narrow the universe before any filter runs. U.S. and Canadian companies in the `silver-miners` theme with RS above 70 (get real theme keys from `GET /api/v2/scan/universe-themes`): ```json { "instructions": { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"], "Country": ["us", "ca"], "Theme": ["silver-miners"], "ThemeMaxTier": 2 }, "All": [ { "Indicator": { "RelativeStrengthMkt": [ { "Returns": { "Want": { "Value": { "GreaterThanOrEqual": 70 } } } } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } } ``` An unknown theme key matches nothing rather than failing, so check keys against the theme list. ## Sorting `Sort.field` is one of `market_cap`, `shares_out`, `shares_float` or `ticker`; `Sort.direction` is `asc` or `desc`. Without a `Sort`, the order is unspecified. ## Common mistakes | Symptom | Cause | | --- | --- | | `400 … Timeframe is required for technical study` | Every `Technical` row needs a `Timeframe`. | | `400 … Invalid option` on `Timeframe` | Use the exact values: `1wk`, not `1w`; `1hr`, not `1h`. | | `400` on `Country` | Country codes are lower-case: `us`, not `US`. | | A study filter matches nothing | The `Want` key isn't the study's plot name (`VALUE` for `RELVOL_STDDEV`, `SlowK` for `STOCH`). | | ETFs or OTC names in the result | `Universe.Type` or `Universe.Exchange` was left empty. | | Fewer matches than expected with `All` + `Any` | They don't intersect; results are unioned. Put everything in one `All` group. | ## Next - [Natural-Language Scans](https://api-docs.scanr.ai/natural-language-scans.md): Have the API write the payload from a sentence. - [Run an End-of-Day Screen](https://api-docs.scanr.ai/guides/end-of-day-screen.md): Run a payload every day and export the matches. - [Find Relative-Strength Leaders](https://api-docs.scanr.ai/guides/relative-strength-leaders.md): RS-driven screens, step by step. - [API Reference](https://api-docs.scanr.ai/api): The full `ScannerPayload` schema. --- # Natural-Language Scans Writing a scan payload by hand means knowing the filter names, plot names and comparators. Two endpoints do that part for you: - **`POST /api/v2/scan/natural`** turns a sentence into a scan payload. - **`POST /api/v2/scan/refine`** changes an existing payload with a follow-up sentence, leaving the filters you didn't mention alone. Neither endpoint runs the scan. They return a payload, with an explanation of each filter, and you send it to `POST /api/v2/scan` when you're happy with it. That split is deliberate: you can check, store and re-run the payload without paying for the language model again. This is the usual first step of any scan: **sentence → payload → scan**. The other way to get a payload is to export one from the [scanr.ai scanner](https://scanr.ai/scan) (**Filters → Export filters**). An exported payload can be refined here exactly like a generated one. See [Get a payload](https://api-docs.scanr.ai/scanning.md#get-a-payload). ## Generate a payload cURL: ```bash curl https://scanr.ai/api/v2/scan/natural \ -H "Authorization: Bearer $SCANR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"query": "profitable mid-cap stocks above their 200-day moving average with RSI under 40"}' ``` Python: ```python import os import requests API = "https://scanr.ai/api/v2" HEADERS = {"Authorization": f"Bearer {os.environ['SCANR_API_TOKEN']}"} natural = requests.post( f"{API}/scan/natural", headers=HEADERS, json={"query": "profitable mid-cap stocks above their 200-day moving average with RSI under 40"}, timeout=120, ).json() if not natural["conversion_success"]: raise SystemExit(natural.get("validation_errors")) reasoning = natural.get("reasoning") or {} print(reasoning.get("summary")) for item in reasoning.get("filters") or []: print(f"- {item['filter']}: {item['reason']}") payload = natural["generated_payload"] ``` JavaScript: ```javascript const API = "https://scanr.ai/api/v2"; const headers = { Authorization: `Bearer ${process.env.SCANR_API_TOKEN}`, "Content-Type": "application/json", }; const natural = await fetch(`${API}/scan/natural`, { method: "POST", headers, body: JSON.stringify({ query: "profitable mid-cap stocks above their 200-day moving average with RSI under 40", }), }).then((r) => r.json()); if (!natural.conversion_success) throw new Error(JSON.stringify(natural.validation_errors)); console.log(natural.reasoning?.summary); const payload = natural.generated_payload; ``` The response looks like this (abridged; the wording of the reasoning varies from run to run): ```json { "conversion_success": true, "generated_payload": { "Universe": { "...": "..." }, "All": [ { "...": "..." } ] }, "reasoning": { "summary": "Profitable mid-cap companies in a long-term uptrend that have pulled back to a low RSI.", "filters": [ { "filter": "Market cap $2B–$10B", "reason": "\"Mid-cap\" is conventionally $2B to $10B." }, { "filter": "Net income above 0", "reason": "\"Profitable\" means positive earnings in the latest filing." }, { "filter": "Price above SMA(200)", "reason": "Stated directly: above the 200-day moving average." }, { "filter": "RSI(14) below 40", "reason": "Stated directly: RSI under 40, on the default 14-day period." } ] }, "retry_count": 0, "conversion_time_taken_ms": 5210 } ``` | Field | Meaning | | --- | --- | | `conversion_success` | Whether a valid payload was produced. When `false`, read `validation_errors`. | | `generated_payload` | The scan payload. Send it as `instructions` to `/api/v2/scan`. | | `reasoning.summary` | One sentence describing the screen as built. Show it to your user before running. | | `reasoning.filters` | One entry per filter, in payload order: how a person would name it, and why it follows from your query. | | `retrieved_examples` | Similar known scans the model drew on. | | `validation_errors` | Why a payload was rejected, when it was. | ## Refine it Refinement edits a payload in place. Send the payload and the change you want: ```json { "existing_payload": { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "Technical": { "RSI": [ { "Timeframe": "1d", "Inputs": ["bar_close"], "Params": { "Period": { "Value": 14 } }, "Returns": { "Want": { "RSI": { "Value": { "LessThan": 40 } } } } } ] } } ] }, "refinement_query": "only stocks with relative strength above 70, and drop anything under $10" } ``` The response has the same shape as `/scan/natural`, with `refinement_success` and `refined_payload` in place of `conversion_success` and `generated_payload`. The RSI filter above survives; an RS filter and a price floor are added. Refinement is the right tool for conversations: *"now only large caps"*, *"make it weekly"*, *"exclude biotech"*. It keeps the parts of the screen the user already agreed to. ## Run it Send the payload to the scan endpoint as `instructions`: cURL: ```bash # natural.json is the saved /scan/natural response jq '{instructions: .generated_payload}' natural.json \ | curl https://scanr.ai/api/v2/scan \ -H "Authorization: Bearer $SCANR_API_TOKEN" \ -H "Content-Type: application/json" \ -d @- ``` Python: ```python scan = requests.post( f"{API}/scan", headers=HEADERS, json={"instructions": payload}, timeout=60, ).json() print(scan["count"], scan["tickers"]) ``` JavaScript: ```javascript const scan = await fetch(`${API}/scan`, { method: "POST", headers, body: JSON.stringify({ instructions: payload }), }).then((r) => r.json()); console.log(scan.count, scan.tickers); ``` Store the payload if you'll run the screen again. Re-running a stored payload costs one request and no AI credits; regenerating it from the sentence costs credits and may produce a slightly different payload. ## Writing good queries The model maps words to filters, so concrete words produce precise payloads: | Instead of | Say | | --- | --- | | "strong stocks" | "relative strength above 80" | | "cheap" | "P/E under 15" or "price under $20" | | "big volume today" | "relative volume above 3 standard deviations" | | "breaking out" | "within 5% of the 52-week high" or "crossed above the 50-day moving average in the last 3 days" | | "recently" | "in the last 5 trading days" | Name the timeframe when it matters ("weekly RSI", "15-minute chart"); daily is the default. Put hard requirements in the first request and use refinement for adjustments. The query can be up to 20,000 characters. ## Cost Both endpoints draw on your daily AI credit budget (see [Plans & Rate Limits](https://api-docs.scanr.ai/rate-limits.md#ai-credits)); running the resulting payload doesn't. Generation usually takes a few seconds, so use a timeout of a minute or more. ## Next - [Building Scan Payloads](https://api-docs.scanr.ai/scan-payloads.md): Read and edit the payloads the model writes. - [Build a Scanning Agent](https://api-docs.scanr.ai/guides/scanning-agent.md): The natural → refine → scan loop inside an AI agent. - [Saved Scans & Performance](https://api-docs.scanr.ai/saved-scans.md): Keep a payload and let scanr.ai run it for you. --- # Saved Scans & Performance A one-off scan answers *what matches now*. A **saved scan** keeps answering it: scanr.ai re-runs it in the background, records when stocks enter the result, and tracks how those stocks performed afterwards. The saved scans you create from the API are the same ones you see in the scanr.ai app, and the other way round. ## Save a scan `POST /api/v2/scan/save-filters` stores a payload under a name. It needs a token with the `write` scope. Saving under an existing name updates that scan. cURL: ```bash curl https://scanr.ai/api/v2/scan/save-filters \ -H "Authorization: Bearer $SCANR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "scanData": { "name": "RS leaders above the 50-day", "group": null, "favorite": false, "leviathan_id": null, "payload": { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "Indicator": { "RelativeStrengthMkt": [ { "Returns": { "Want": { "Value": { "GreaterThanOrEqual": 85 } } } } ] }, "Technical": { "SMA": [ { "Timeframe": "1d", "Inputs": ["bar_close"], "Params": { "Period": { "Value": 50 } }, "Returns": { "Want": { "SMA": { "Price": { "Comparison": { "LessThan": true } } } } } } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } } }' ``` Python: ```python import os import requests API = "https://scanr.ai/api/v2" HEADERS = {"Authorization": f"Bearer {os.environ['SCANR_API_TOKEN']}"} saved = requests.post( f"{API}/scan/save-filters", headers=HEADERS, json={ "scanData": { "name": "RS leaders above the 50-day", "group": None, "favorite": False, "leviathan_id": None, "payload": payload, # any scan payload } }, timeout=60, ).json() scan_id = saved["leviathan_id"] ``` | Field | Meaning | | --- | --- | | `name` | The scan's name, unique within your account. | | `payload` | The scan payload, exactly as you would send it to `/api/v2/scan` as `instructions`. | | `group`, `favorite` | How the scan is filed in the app. Required keys; `null` and `false` are fine. | | `leviathan_id` | Leave `null`. scanr.ai assigns the scan's id. | | `notes` | Optional free text. | The response is the saved scan. Its `leviathan_id` is the scan's **id**, which the performance and alert endpoints below take as `scan_id`. To rename a scan, use `POST /api/v2/scan/rename-saved-filter` with `{"filterName": "old", "newName": "new"}`; a name that's already taken returns `409`. ## Read your saved scans `POST /api/v2/scan/myscans` lists your saved scans, 10 per page by default (up to 100): ```bash curl https://scanr.ai/api/v2/scan/myscans \ -H "Authorization: Bearer $SCANR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"savedFiltersPage": 0, "pageSize": 50, "sortBy": "created_at", "sortDir": "desc"}' ``` Each entry has the scan's `name`, `payload` and `leviathan_id`. This is also how you move a scan from the app into code: build it visually in the scanr.ai scanner, save it, then read its `payload` here and run it with `/api/v2/scan`. ## Run a saved scan Run the saved payload like any other, and pass the scan's id in `save_args` to record the run against the saved scan instead of as a one-off: ```json { "instructions": { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "Indicator": { "RelativeStrengthMkt": [ { "Returns": { "Want": { "Value": { "GreaterThanOrEqual": 85 } } } } ] } } ] }, "save_args": { "scan_id": 918273 } } ``` The run is attached only when the payload matches the saved scan's definition; otherwise it's recorded as a one-off. Either way, the scan runs and you get the results. ## Alerts: when stocks enter a scan While a saved scan runs in the background, every change to its result is recorded as an **event**. `POST /api/v2/scan/paginated-scan-events` returns them newest first: ```bash curl https://scanr.ai/api/v2/scan/paginated-scan-events \ -H "Authorization: Bearer $SCANR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"limit": 20, "scan_id": 918273}' ``` ```json { "results": [ { "event_id": 55120, "scan_id": 918273, "event_time": "2026-09-29T14:35:00Z", "event_type": "notification", "tickers": ["CRDO"], "all_tickers": ["NVDA", "AVGO", "ANET", "CRDO"], "saved_scan_info": { "name": "RS leaders above the 50-day", "group": null, "favorite": false } } ], "count": 1, "next_page": { "event_id_less_than": 55120 } } ``` `tickers` holds the stocks that triggered the event (the new entries); `all_tickers` is the whole result at that moment. For the next page, send `next_page` back as-is. Leave out `scan_id` to get events across all of your saved scans. An event with `event_type: "disarmed"` means the scan stopped refreshing in the background because its filters can no longer run, for example because a custom indicator or pattern model it uses was deleted. It has no tickers (`tickers` and `all_tickers` are `null`). Its `detail` says why, as a code with the values the message is built from: ```json { "event_id": 55188, "scan_id": 918273, "event_time": "2026-10-03T14:35:00Z", "event_type": "disarmed", "tickers": null, "all_tickers": null, "detail": { "code": "scan.custom_indicator.not_found", "params": { "indicator_id": "42", "indicator_name": "My RSI", "version": 3 } } } ``` New codes can appear without notice, so treat a code you do not recognize as "this scan can no longer run". Fix the scan's filters and save it again under the same name (`save-filters`) to restart background refreshes. To limit the time range, add `after` and/or `before`. Both filter on `event_time` and include the boundary. Each takes an ISO 8601 date-time with a zone, such as `"2026-09-29T13:30:00Z"` or `"2026-09-29T09:30:00-04:00"`. A bare date or a time with no zone returns `400`. `event_type` returns one kind of event only, such as `"notification"`. ```bash curl https://scanr.ai/api/v2/scan/paginated-scan-events \ -H "Authorization: Bearer $SCANR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"scan_id": 918273, "after": "2026-09-29T13:30:00Z", "before": "2026-09-29T20:00:00Z"}' ``` This is the efficient way to follow a scan through the day: poll the events every few minutes instead of re-running the scan. Set `after` to the newest `event_time` you have seen. Since `after` includes the boundary, that event comes back again, so skip any `event_id` you already hold. ## Scan history `GET /api/v2/scan/myscans/scan-history` lists the scans you ran (from the API, the MCP server or the app) in the last 30 days, with each run's payload and results. Filter it with `scanName`, and page with `page` (starting at 1) and `pageSize`: ```bash curl "https://scanr.ai/api/v2/scan/myscans/scan-history?page=1&pageSize=20" \ -H "Authorization: Bearer $SCANR_API_TOKEN" ``` ## Performance *Power User and Business plans.* scanr.ai measures every saved scan as if you had bought each stock when it entered the scan and sold it after a fixed holding period. The holding periods are **time horizons**: | Horizon | `1d` | `3d` | `5d` | `10d` | `1M` | `3M` | `6M` | `1y` | `ytd` | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | Holding period | 1 day | 3 days | 5 days | 10 days | 1 month | 3 months | 6 months | 1 year | to year end | The values are case-sensitive: `1M` is a month, and there is no `1m` horizon. For each horizon you get the statistics of those simulated trades: `win_rate`, `avg_percent_return`, `expectancy`, `sharpe_ratio`, `sortino_ratio`, `calmar_ratio`, `avg_max_drawdown`, `total_trades` and more. Percentages are fractions (`0.0784` is 7.84%). `insufficient_history` is `true` when a scan hasn't run long enough to judge. The main endpoints: | Endpoint | Returns | | --- | --- | | `POST /scan/myscans/most-recent-scan-performances` | The latest statistics for your scans, grouped by horizon. Body: `{"scan_ids": [918273], "universe": "listed"}`. `scan_ids: null` means all of your scans. | | `POST /scan/myscans/ticker-trade-outcomes` | Every simulated trade for one ticker in one scan. Body: `{"scan_id": 918273, "ticker": "NVDA", "time_horizon": "1M"}`. | | `POST /scan/myscans/scans-for-ticker` | Which of your scans have traded a ticker. Body: `{"ticker": "NVDA"}`. | | `POST /scan/myscans/top-n-scans` | Your best scans by a chosen statistic. | `universe` selects which trades count: `all` (the default), `listed` (exchange-listed only) or `otc`. [Measure a Scan's Edge](https://api-docs.scanr.ai/guides/backtest-a-scan.md) walks through reading these numbers without fooling yourself. ## Next - [Measure a Scan's Edge](https://api-docs.scanr.ai/guides/backtest-a-scan.md): Judge a saved scan by its forward returns. - [Run an End-of-Day Screen](https://api-docs.scanr.ai/guides/end-of-day-screen.md): Scheduled scanning from your own code. - [Errors](https://api-docs.scanr.ai/errors.md): Including the `403` you get when personal data access is off. --- # Datasets Every dataset the scanr.ai API serves, what it contains, and how it connects to scanning. Most of them are also **scan filters**: you can read relative strength for a list of tickers, or scan the whole market for stocks where it's above 90. The table's last column says which filter reads the same data. | Dataset | Endpoint | Plan | As a scan filter | | --- | --- | --- | --- | | [Scans](#scans) | `POST /scan` | Starter | | | [Relative strength](#relative-strength) | `POST /stocks/relative-strength`, `POST /stocks/get-stock-indicators` | Power User | `Indicator.RelativeStrengthMkt`, `…Sector`, `…Industry` | | [Regime indicators](#regime-indicators) | `POST /stocks/get-stock-indicators` | Business | `Indicator.TrendEfficiency`, `RegimeExtension`, `GroupStrength`, … | | [Chart patterns](#chart-patterns) | `POST /stocks/get-chart-patterns` | Power User | `ChartPattern.*` | | [Supply & demand zones](#supply-and-demand-zones) | `POST /stocks/get-supply-demand-zones` | Business | `Zone.Inside`, `Zone.Outside` | | [Market themes](#market-themes) | `GET /stocks/themes`, `GET /stocks/themes/performance/*` | Business | `Universe.Theme` | | [Technical indicators](#technical-indicators) | `POST /technicals/technical/{category}/{name}`, `POST /technicals/technical/bulk` | Power User | `Technical.*` | | [Peers](#peers) | `GET /stocks/peers` | Power User | | | [Concept research](#concept-research) | `GET /stocks/concept-research/relate`, `GET /stocks/concept-research/find` | Power User | | | [ETF holdings](#etf-holdings) | `GET /stocks/etf/{ticker}/holdings`, `…/holdings/breakdown` | Power User | | | [Reference data](#reference-data) | `GET /stocks/search-securities`, `/stocks/sectors`, `/stocks/industries`, `/stocks/ticker-types`, `/stocks/hot-tickers`, `/misc/market-status` | Starter | `Stock.Sector`, `Stock.Industry` | Paths are relative to `https://scanr.ai/api/v2`. Full request and response schemas are in the [API Reference](https://api-docs.scanr.ai/api). ## Scans The headline dataset: every U.S.-listed security that matches a payload of your design, computed on demand against current data. See [How Scanning Works](https://api-docs.scanr.ai/scanning.md). - **Returns:** the matching tickers, their count, and the run's id. - **Filters on:** quote and profile data, 100+ technical studies on 18 timeframes, 300+ fundamental metrics, relative strength and regime scores, 60+ candlestick patterns, 17 chart patterns, supply/demand zones, insider and short data. - **Freshness:** live. Price-based filters include the current session's bar. ## Relative strength A 1–99 percentile rank of each stock's trailing performance against a comparison group. 99 means the stock has outperformed 99% of its group. There are three groups: the whole market (`rs_score`), the stock's sector (`rs_sector_score`) and its industry (`rs_industry_score`). **Ranked lists**, strongest first. `POST /stocks/relative-strength`: ```json { "scope": "sector", "value": "Healthcare", "limit": 25 } ``` `scope` is `market`, `sector` or `industry`; `value` names the sector or industry (required unless `scope` is `market`); `ascending: true` returns the weakest instead; `limit` defaults to 20. **Scores for your tickers.** `POST /stocks/get-stock-indicators` returns the scores (and the regime indicators below) for a list of tickers: ```json { "tickers": ["NVDA", "AMD", "INTC"], "limit": 100 } ``` Pass `from` and `to` (Unix milliseconds) for history, and follow `next_cursor_date` / `next_cursor_ticker` until `is_last_page` is `true`. Each row carries a `date`, a `realtime` flag, and the time it was `updated`. - **Freshness:** recomputed about once a minute during the session (`realtime: true`), settled after the close. - **Scan filter:** `Indicator.RelativeStrengthMkt`, `RelativeStrengthSector`, `RelativeStrengthIndustry`. See [Find Relative-Strength Leaders](https://api-docs.scanr.ai/guides/relative-strength-leaders.md). ## Regime indicators *Business plan.* Where a stock sits in its trend, beyond how far it has moved: | Field | Meaning | | --- | --- | | `trend_efficiency` | 0–100. How cleanly the stock is trending rather than chopping (Kaufman efficiency ratio ×100): 100 is a straight line, 0 is noise. | | `regime_extension` | −100–100. How far price is stretched from its adaptive mean (KAMA), in ATR units ×10: 25 means 2.5 ATR above. | | `regime_divergence` | The stock's extension minus its industry's median (sector if the industry is too small). Positive means it is holding up while its peers pull back. | | `regime_divergence_pct` | `regime_divergence` ranked 1–99 within the same peer group. | | `group_strength` | The `regime_extension` of the stock's sector or thematic ETF (`group_ticker`): is the group itself strong? Settled at the end of each day. | | `market_strength`, `market_efficiency` | The extension and trend efficiency of the broad market, identical for every ticker on a date. | They come back from `POST /stocks/get-stock-indicators` and `POST /stocks/relative-strength` alongside RS (`market_strength` and `market_efficiency` from `get-stock-indicators` only). On Power User the keys are still there, but their values are `null` and the response lists them in `withheld_fields`. That list is empty on Business. Check it before you store a regime value: a `null` whose key is not in `withheld_fields` means the stock has no reading, not that your plan hides it. ```json { "withheld_fields": ["trend_efficiency", "regime_extension", "regime_divergence", "regime_divergence_pct", "group_strength"], "results": [{ "ticker": "NVDA", "rs_score": 97, "trend_efficiency": null, "group_ticker": "SMH" }] } ``` ## Chart patterns Detected classical formations, per ticker and timeframe: head and shoulders, double tops and bottoms, triangles, wedges, flags, channels, and cup and handle (17 in all). `POST /stocks/get-chart-patterns` takes `tickers`, optional `from` / `to` (Unix milliseconds, on the detection time) and `limit`, and pages with `cursor_id` / `cursor_time`. Each row has the `pattern` name and its stable `pattern_key`, the `timeframe`, the pattern's pivot `points`, and when it was `detected_at` and `computed_at`. - **Scan filter:** `ChartPattern.`, for example `ChartPattern.CupAndHandle` (see [Building Scan Payloads](https://api-docs.scanr.ai/scan-payloads.md#chart-patterns)). ## Supply and demand zones *Business plan.* Price bands where the stock has found buyers (demand) or sellers (supply) before, each weighted by the volume traded there. `POST /stocks/get-supply-demand-zones` takes the same `tickers` / `from` / `to` / `limit` body as chart patterns. Each zone has a `zone_type`, `top_price`, `bottom_price`, the highest-volume `node_price` inside it, a `volume_weight`, and the bar range it formed over (`bars_from`, `bars_to`). - **Scan filter:** `Zone.Inside` / `Zone.Outside`, for "price is inside a daily demand zone". ## Market themes *Business plan.* scanr.ai groups stocks into **themes** by how their prices actually move together (correlation), not by their industry codes, and gives each group a label and a stable key, such as `silver-miners`. Themes are re-partitioned weekly. | Endpoint | Returns | | --- | --- | | `GET /stocks/themes` | Every theme: `theme_key`, `label`, `description`, member `tickers` (each with its membership `tier`: core members first, then satellites and looser members), and cohesion statistics. | | `GET /stocks/themes/performance/latest` | Today's performance of every theme, its average RS, volume and member returns. | | `GET /stocks/themes/performance/by-date?date=YYYY-MM-DD` | The same, for a past session. | | `GET /stocks/themes/performance/history?days=90` | A time series of theme performance over the last *N* calendar days. | - **Freshness:** live during the session (`is_realtime` marks today's entry); memberships change weekly. - **Scan filter:** `Universe.Theme` restricts any scan to a theme's members. See [Track Theme Rotation](https://api-docs.scanr.ai/guides/theme-rotation.md). ## Technical indicators 80+ studies (moving averages, momentum oscillators, volatility, volume, statistics, cycles) computed server-side on scanr.ai's bars. You send tickers and parameters, not price data. `POST /technicals/technical/{category}/{name}` computes one study, for example `/technicals/technical/momentum/rsi`: ```json { "tickers": ["AAPL", "MSFT"], "timeframe": "1d", "params": { "period": 14 }, "within": 5 } ``` `within: 1` (the default) returns the latest value per ticker; a larger `within` returns that many trailing values, oldest first. `POST /technicals/technical/bulk` computes many studies over one set of tickers in a single request. Values always end at the latest bar; there is no date range. - **Scan filter:** `Technical.*` runs the same studies inside a scan, across the whole market. See [Compute Technical Indicators](https://api-docs.scanr.ai/guides/technical-indicators.md). ## Peers `GET /stocks/peers?ticker=NVDA` returns the stocks whose prices move most like the given one, each with its `correlation`. Funds (ETFs, ETNs) have no peers and return `404`. ## Concept research Whether a stock relates to a free-text concept (a theme, place, product or event: *"rare earths"*, *"GLP-1"*), or which stocks do, judged from SEC filings, news and company profiles. `GET /stocks/concept-research/relate?ticker=XMPL&concept=rare%20earths` answers one pair as `related`, `not_related` or `unknown`; `GET /stocks/concept-research/find?concept=rare%20earths` ranks the stocks that relate. Every positive answer carries the passage it rests on and a link. `unknown` is not a no, and a `partial` list is not a complete one: [Research a Concept](https://api-docs.scanr.ai/guides/concept-research.md) explains how to read each answer. ## ETF holdings `GET /stocks/etf/{ticker}/holdings` returns a sector or thematic ETF's constituents with their `weight` (as a fraction), shares and market value; `GET /stocks/etf/{ticker}/holdings/breakdown?top_n=15` groups the weights by industry. - **Coverage:** sector and thematic ETFs. Broad-market, factor, leveraged, inverse and defined-outcome funds (SPY, QQQ, IWM) are not covered, and the response says so in `coverage` (`covered`, `pending_ingest` or `out_of_universe`). - **Freshness:** a periodic snapshot; `as_of` and `as_of_age_days` say how old it is. `weight_covered` is the share of the fund's weight the listed holdings account for, usually below 1. ## Reference data | Endpoint | Returns | | --- | --- | | `GET /stocks/search-securities?q=nvidia&l=10` | Securities matching a ticker, company name or description (`query_description=true`). Add `include_otc=true` to include OTC listings. | | `GET /stocks/sectors`, `GET /stocks/industries` | Every sector and industry name, for `Stock.Sector` and `Stock.Industry` filters. | | `GET /stocks/ticker-types` | Security type codes (`cs`, `etf`, `adrc`, …) with descriptions, for `Universe.Type`. | | `GET /stocks/hot-tickers?l=10` | Currently trending stocks: ticker, name, exchange, sector, price and % change. | | `GET /misc/market-status` | Whether U.S. markets are `open`, `closed` or in `extended-hours`, per exchange, with the server time. | ## Next - [Coverage & Freshness](https://api-docs.scanr.ai/coverage.md): Which securities are covered and when each dataset updates. - [Plans & Rate Limits](https://api-docs.scanr.ai/rate-limits.md): The plan each endpoint needs. - [API Reference](https://api-docs.scanr.ai/api): Full schemas for every endpoint. --- # Coverage & Freshness What scanr.ai covers, where the data comes from, and when each dataset changes. Read this before you schedule a scan or compare two results: the same payload can legitimately return different stocks at 10:00 and at 16:30. ## Markets scanr.ai covers **U.S.-listed securities** across every major venue and OTC: | `Universe.Exchange` | Venue | | --- | --- | | `nyse` | New York Stock Exchange | | `nasdaq` | Nasdaq | | `amex` | NYSE American | | `arca` | NYSE Arca | | `bats` | Cboe BZX | | `bx` | Nasdaq BX | | `otc` | OTC markets | Security types include common stock (`cs`), ordinary shares (`os`), ADRs (`adrc`, `adrp`, `adrr`), ETFs (`etf`), ETNs (`etn`), exchange-traded vehicles (`etv`), funds (`fund`), preferred stock (`pfd`), warrants, rights and units. `GET /api/v2/stocks/ticker-types` lists every code. Every API plan can scan all of them, including OTC and ETFs. The defaults are not "everything", though: see [Always set Exchange and Type](https://api-docs.scanr.ai/scanning.md#universe). **Country of domicile** (`Universe.Country`) is recorded for operating companies. Many securities, including every ETF, have none and appear as `unknown`, so a country filter also removes all funds. **Not covered:** non-U.S. listings, options, futures, forex and crypto. ## Where the data comes from | Data | Source | | --- | --- | | Prices, volume and quotes | Market data from every U.S. exchange and OTC. | | Fundamentals | Company filings with the SEC: quarterly (10-Q) and annual (10-K) reports. | | Insider transactions | SEC Form 4 filings. | | Short data | FINRA daily short volume and bi-monthly short interest. | | Relative strength, regime indicators, chart patterns, supply/demand zones, themes, peers | Computed by scanr.ai from the price and volume data above. | | Concept research | A scanr.ai classifier's reading of each company's SEC filings, recent news and company profile. Every positive answer cites the passage it read. | | ETF holdings | Periodic snapshots of fund holdings for sector and thematic ETFs. | Everything in the second half of that table is scanr.ai's own analysis. The methods are described in the [scanr.ai knowledge base](https://feedback.scanr.ai/en/help). ## Freshness | Dataset | Updates | During market hours | | --- | --- | --- | | Scans | On every request | Price and volume studies include the current session's in-progress bar. | | Technical indicators | On every request | Same as scans: the latest bar may be in progress. | | Relative strength | About once a minute during the session | Rows carry `realtime: true` until the session settles after the close. | | Regime indicators | About once a minute; `group_strength` once a day | `group_strength` reflects the previous close during the day. | | Theme performance | Live during the session | Today's entry carries `is_realtime: true`. | | Theme membership | Weekly | | | Chart patterns, zones | As they are detected | Each row carries `detected_at` / `computed_at`. | | Fundamentals | When companies file | A scan uses the latest filing unless you set `Lag`. | | ETF holdings | Periodic snapshots | `as_of` and `as_of_age_days` say how old the snapshot is. | | Concept research | Indexed concepts are swept across the market; a pair nobody has asked is classified live on first ask, then stored | An answer for a past `as_of` comes from the index only, never live, so it can be `unknown`. `coverage` says which. | | Market status | On every request | | API tokens get this data without delay on every plan. ## Timing a scan Because scans read the in-progress bar, *when* you run one is part of its definition: - **After the close**, daily studies read the settled session. This is the right time for an end-of-day list you'll act on tomorrow; leave a margin after 16:00 ET, for example by running at 16:30. See [Run an End-of-Day Screen](https://api-docs.scanr.ai/guides/end-of-day-screen.md). - **During the session**, a daily RSI or moving-average cross is provisional: it can appear at 11:00 and be gone by the close. Use `GET /api/v2/misc/market-status` to decide in code whether the market is `open`, `closed` or in `extended-hours`. A scan answers for the moment it runs; it is not a backtest. To see how a screen would have done over time, save it and read its [performance](https://api-docs.scanr.ai/saved-scans.md#performance). ## Accuracy Market data may be delayed and is not guaranteed to be accurate, complete or timely. scanr.ai provides data and tools, not investment advice. Report a data problem to [contact@bagsbydata.com](mailto:contact@bagsbydata.com) with the ticker, endpoint and time. --- # MCP Server The scanr.ai **MCP server** gives an AI assistant the scanner as a set of tools. Connect Claude, Cursor, VS Code or any other [Model Context Protocol](https://modelcontextprotocol.io) client, and it can turn *"find me beaten-down large caps that are starting to turn"* into a scan, run it, and look up what it found. It uses your API token and plan, with the same limits and billing as the REST API. ```text https://mcp.scanr.ai ``` **Let your agent set it up:** Paste this into Claude Code, Cursor, Codex or any agent that can read a URL. It asks which client you use, walks you through creating a token, connects the server and runs a first scan. ```text Read https://api-docs.scanr.ai/connect.md and follow the instructions to connect me to scanr.ai. ``` ## Before you start You need an API plan (every plan includes MCP) and a token with the `read` scope from **[Account → API Tokens](https://scanr.ai/account/api-tokens)**. Give the assistant its own token so its usage is easy to see and to revoke. See [Authentication](https://api-docs.scanr.ai/authentication.md). > **Bearer header only:** The MCP server authenticates with an `Authorization: Bearer scanr_...` header. It does not accept `X-API-Key`, and it has no OAuth sign-in, so connectors that only support OAuth (claude.ai and ChatGPT custom connectors) can't connect yet. Use one of the clients below. ## Connect your client Claude Code: ```bash claude mcp add --transport http scanr https://mcp.scanr.ai \ --header "Authorization: Bearer $SCANR_API_TOKEN" # Then, inside Claude Code, check it's connected: # /mcp ``` Claude Desktop: ```json { "mcpServers": { "scanr": { "command": "npx", "args": [ "-y", "mcp-remote", "https://mcp.scanr.ai", "--header", "Authorization:${SCANR_AUTH}" ], "env": { "SCANR_AUTH": "Bearer scanr_..." } } } } ``` Cursor: ```json { "mcpServers": { "scanr": { "url": "https://mcp.scanr.ai", "headers": { "Authorization": "Bearer scanr_..." } } } } ``` VS Code: ```json { "servers": { "scanr": { "type": "http", "url": "https://mcp.scanr.ai", "headers": { "Authorization": "Bearer ${input:scanr-token}" } } }, "inputs": [ { "type": "promptString", "id": "scanr-token", "description": "scanr.ai API token", "password": true } ] } ``` Codex: ```toml # ~/.codex/config.toml [mcp_servers.scanr] url = "https://mcp.scanr.ai" bearer_token_env_var = "SCANR_API_TOKEN" ``` Where each configuration goes: - **Claude Code:** the command saves it to your Claude Code config. Add `--scope project` to share it through the repository's `.mcp.json` (then use an environment variable, not a pasted token). - **Claude Desktop:** Settings → Developer → Edit Config opens `claude_desktop_config.json`. Claude Desktop connects to remote servers with headers through the [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) bridge, which needs Node.js. Restart Claude after saving. - **Cursor:** `~/.cursor/mcp.json`, or `.cursor/mcp.json` in a project. - **VS Code:** `.vscode/mcp.json` in the workspace. VS Code asks for the token once and stores it securely. - **Codex:** `~/.codex/config.toml`; export `SCANR_API_TOKEN` in the shell that starts Codex. - **Any other client:** point it at `https://mcp.scanr.ai` over Streamable HTTP with the `Authorization` header. ## Tools | Tool | What it does | Arguments | AI credits | | --- | --- | --- | --- | | `natural_scan` | Turns a plain-English description into a scan payload, with reasoning. Doesn't run it. | `query` (up to 20,000 characters) | Yes | | `refine_payload` | Changes an existing payload with a follow-up instruction, keeping the other filters. Doesn't run it. | `existing_payload`, `refinement_query` (up to 20,000 characters) | Yes | | `scan` | Runs a scan payload over the whole market and returns every matching ticker. | `instructions`, optional `save_args` | No | | `search_securities` | Resolves a ticker, company name or description to securities. | `q`, `l` (1–100, default 20), `include_otc`, `n` (offset), `query_description` | No | | `market_status` | Whether U.S. markets are open, closed or in extended hours, per exchange. | none | No | | `get_news` | Recent news, newest first, with publisher, link and per-ticker sentiment. Pages back with the cursor it returns. | `ticker`, `from_time`, `to_time`, `limit` (1–100, default 20), `from_id` and `page` (a page cursor) | No | | `hot_tickers` | Trending stocks: ticker, name, exchange, sector, price, % change. | `l` (1–100, default 10) | No | | `concept_research` | Whether a ticker relates to a concept (a theme, place, product or event), or which tickers do, each answer with its provenance and a cited source. Power User and up. | `concept`, `ticker` (omit to find), `as_of`, `limit` (1–25, default 15) | No | | `ping` | Checks the connection and token. | `message` | No | Every tool is available on every plan except `concept_research`, which needs Power User or Business (and is rolling out account by account). Each result is a single text block of JSON; very large numbers arrive as strings. `get_news` returns a cursor with each page. For the next, older page, call it again with the same `ticker`, `page: "prev"`, `from_time` set to the result's `prev_time` and `from_id` set to its `ceiling_id`. An empty `news` list means there are no more. `concept_research` answers *"does XMPL relate to rare earths?"* with `ticker`, and *"which stocks relate to rare earths?"* without it. `unknown` is not a no, and an empty `partial` list means "none among the candidates asked", not "none"; each result's `meaning` says which, and a refusal comes back as a result with `ok: false` and a `code`. See [Research a Concept](https://api-docs.scanr.ai/guides/concept-research.md). The MCP tools cover scanning, reference data, news and concept research. For relative strength, chart patterns, themes, zones and technical indicators, call the [REST API](https://api-docs.scanr.ai/datasets.md), which serves them to the same token. ## How an assistant should scan Scan payloads are detailed JSON, and a model writing one from scratch tends to get plot names and comparators wrong. So the assistant shouldn't write one. It gets a payload, then runs it: 1. **Get a payload**, one of two ways: - **`natural_scan`** with the user's description. Show the user `reasoning.summary` and the filter list, so they can see what will be screened. - **An exported scan.** If the user already has the screen in the [scanr.ai scanner](https://scanr.ai/scan), they click **Filters → Export filters → Copy to clipboard** and paste the JSON into the chat. Use it as it is. It costs no AI credits and is exactly the screen they already trust. 2. **`refine_payload`** for each correction the user asks for (*"only large caps"*, *"use the weekly chart"*). It keeps the filters that are already right, and works on an exported payload as well as a generated one. 3. **`scan`** with the final payload as `instructions`. 4. **Enrich, then answer.** Use `search_securities` or `get_news` on the top matches, or REST data endpoints for relative strength and patterns, before summarizing. Keep the final payload. Re-running it with `scan` costs one request and no AI credits, while calling `natural_scan` again costs credits and may produce a slightly different screen. The payloads in these docs' guides are written out in full so you can see what they screen. To run one through an assistant, give it the guide's sentence and let `natural_scan` build the payload, or paste the JSON and ask it to call `scan` with it unchanged. If the assistant writes a payload itself, point it at [Building Scan Payloads](https://api-docs.scanr.ai/scan-payloads.md) (or `/scan-payloads.md`), or install the [Agent Skill](https://api-docs.scanr.ai/agents.md#agent-skill), which carries the same rules. ## Limits - **Requests:** each tool has its own per-minute, per-hour and per-day limits, equal to your plan's REST limits (30/min on Starter; see [Plans & Rate Limits](https://api-docs.scanr.ai/rate-limits.md)) and separate from the REST endpoints' counters. Connecting and listing tools doesn't count. - **AI credits:** `natural_scan` and `refine_payload` spend your plan's daily AI credit budget, shared with the REST natural-language endpoints and reset at 00:00 UTC. - **Size:** requests are limited to 1 MiB. Scan results aren't truncated, so a broad scan can return thousands of tickers; narrow the payload rather than asking the model to page through them. ## Errors - **Authentication** fails before any tool runs, with a JSON body whose `error_description` says what went wrong: - `401` for a missing token, a token sent as `X-API-Key`, or a token that is malformed, revoked or expired; - `403` for a token without the `read` scope, or an account whose API plan is inactive; - `503` when the plan couldn't be checked. Retry it. - **Everything else** is a tool result with `isError: true` and a message the assistant can read and act on: a rate limit (with the window that ran out), the AI credit budget, personal data access being off, an invalid payload, or an argument that failed validation. Scans run from MCP are recorded in your scan history like any other. If you switch off [personal data access](https://api-docs.scanr.ai/authentication.md#personal-data-access) for API tokens, `scan` returns an error saying so; the other tools keep working. ## Calling it without a client MCP is JSON-RPC over HTTP, so you can call a tool with `curl`. The server is stateless: no session to open, one POST per call. Send both `Accept` types, or the server answers `406`: ```bash curl https://mcp.scanr.ai \ -H "Authorization: Bearer $SCANR_API_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "search_securities", "arguments": { "q": "nvidia", "l": 3 } } }' ``` The reply arrives as a server-sent event: ```text event: message data: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\n \"count\": 3, ..."}]}} ``` Swap the method for `tools/list` (and drop `params`) to list the tools and their input schemas. ## Need help? Found a bug, or need a tool the server doesn't have? Email [contact@bagsbydata.com](mailto:contact@bagsbydata.com). --- # Docs for Agents These docs are written to be read by AI agents as well as people. If you're building an agent that uses scanr.ai, or you are one, this page lists the machine-readable versions and how to use them. **Connect your agent:** The quickest start: paste this into your agent. [/connect.md](https://api-docs.scanr.ai/connect.md) tells it how to set up the [MCP server](https://api-docs.scanr.ai/mcp-server.md) or REST API for your client and run a first scan. ```text Read https://api-docs.scanr.ai/connect.md and follow the instructions to connect me to scanr.ai. ``` ## Where an agent should start | Resource | URL | Use it for | | --- | --- | --- | | Index | [/llms.txt](https://api-docs.scanr.ai/llms.txt) | Every page, with a one-line description and a link to its markdown. Read this first. | | Any page as markdown | Add `.md` to its URL: [/quickstart.md](https://api-docs.scanr.ai/quickstart.md), [/scan-payloads.md](https://api-docs.scanr.ai/scan-payloads.md) | Clean markdown with the tabs, cards and callouts flattened, and every link absolute. The docs home is [/index.md](https://api-docs.scanr.ai/index.md). | | Everything at once | [/llms-full.txt](https://api-docs.scanr.ai/llms-full.txt) | All pages in one file, for loading the whole manual into context. | | OpenAPI spec | [/openapi.json](https://api-docs.scanr.ai/openapi.json) | Every endpoint a token can call, with request and response schemas. | | Onboarding | [/connect.md](https://api-docs.scanr.ai/connect.md) | Step-by-step setup an agent follows to connect a user: token, MCP or REST, a first scan. | | Agent Skill | [/skill.md](https://api-docs.scanr.ai/skill.md) | A compact, installable guide to scanning with scanr.ai. | | Scan catalog | `GET https://scanr.ai/api/v2/scan/instructions` | Every filter, parameter, plot name and allowed value, live. No token needed. | Each page also has a **Copy page** button (top right) that copies its markdown, with a menu to open the page in Claude or ChatGPT. ## Agent Skill The skill at [/skill.md](https://api-docs.scanr.ai/skill.md) is a single `SKILL.md` file in the [Agent Skills](https://docs.claude.com/en/docs/agents-and-tools/agent-skills/overview) format. It teaches an agent the scan model, the payload rules that trip models up, the natural-language workflow and the endpoints worth calling, in a few pages, and points to these docs for the rest. Install it for Claude Code: ```bash mkdir -p ~/.claude/skills/scanr curl -s https://api-docs.scanr.ai/skill.md -o ~/.claude/skills/scanr/SKILL.md ``` Put it in `.claude/skills/scanr/` inside a repository instead to share it with a team. Agents without skill support can take the same file as part of their system prompt. ## MCP, REST or both? | If your agent… | Use | | --- | --- | | runs in Claude Code, Claude Desktop, Cursor, VS Code or Codex | The [MCP server](https://api-docs.scanr.ai/mcp-server.md). Nothing to build: the scan tools appear in the client. | | is your own code, on any framework | The REST API, with the [OpenAPI spec](https://api-docs.scanr.ai/openapi.json) or a few hand-written tools. [Build a Scanning Agent](https://api-docs.scanr.ai/guides/scanning-agent.md) shows the tool set. | | needs relative strength, patterns, themes or indicators | REST. The MCP server covers scanning, securities, news and market status. | Both use the same token, plan and data. An MCP-connected assistant with the Agent Skill installed covers most interactive use. ## OpenAPI spec [/openapi.json](https://api-docs.scanr.ai/openapi.json) is an OpenAPI 3.1 document for the public API: only the endpoints an API token can call, with their full request and response schemas. Its server is `https://scanr.ai` and every path starts with `/api/v2`. Use it to: - **generate a client** with a tool such as [openapi-generator](https://openapi-generator.tech/) or `openapi-typescript`; - **import the API** into Postman, Insomnia or Bruno; - **turn endpoints into agent tools**: each operation's request schema is a ready-made tool input schema. Pick the handful your agent needs rather than all of them; see [Build a Scanning Agent](https://api-docs.scanr.ai/guides/scanning-agent.md). The spec is generated from the running API, so it changes when the API does. ## Rules for agents - **Scan, don't crawl.** One scan answers "which stocks…?" for the whole market. Never loop over tickers calling per-ticker endpoints to filter them yourself. - **Get a payload, don't write one.** Generate it with `natural_scan` / `POST /scan/natural`, or use one the user exported from the scanr.ai scanner (**Filters → Export filters**), then edit it with refine. Hand-written payloads are fine for small edits, but check plot names and comparators against [Building Scan Payloads](https://api-docs.scanr.ai/scan-payloads.md). - **Show the reasoning.** Before running a generated scan, tell the user what it will screen for (`reasoning.summary`). It catches misreadings early. - **Keep payloads.** A payload is a reusable asset. Re-running it costs no AI credits. - **Read errors.** A `400` names the field that's wrong; fix that field rather than regenerating the whole payload. A `429` means wait, not retry. - **Mind the clock.** A scan during market hours reads provisional bars. Say so when reporting intraday results. See [Coverage & Freshness](https://api-docs.scanr.ai/coverage.md). - **Use a dedicated token** with the `read` scope only, and never echo it back into a conversation. --- # Run an End-of-Day Screen The most common use of the API: once the market closes, run your screen, rank the matches, and save a list you'll act on tomorrow. This guide builds that as one script and schedules it. **You'll need:** a token (any plan for the scan; Power User or higher for the relative-strength column), and Python 3.9+ with `requests`, or Node 18+. ## 1. Choose the screen The script runs whatever payload is in `screen.json`. Keeping the screen in a file, not in the script, lets you change it without touching code. Get it either way: - **Export it from the scanner.** Open the scan in the [scanr.ai scanner](https://scanr.ai/scan), click **Filters → Export filters**, then **Download JSON**, and save the file as `screen.json`. This is the easiest way to run a screen you already watch in the app. - **Describe it.** Send the screen to [`/scan/natural`](https://api-docs.scanr.ai/natural-language-scans.md), read the `reasoning` it returns, and save `generated_payload` as `screen.json`: ```bash curl -s https://scanr.ai/api/v2/scan/natural \ -H "Authorization: Bearer $SCANR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"query": "price above $10, average daily dollar volume over the last 50 days above $20M, price above the 50-day moving average, the 50-day above the 200-day, and relative strength of 75 or more"}' \ | jq '.generated_payload' > screen.json ``` Either way you generate or export it once. The nightly run then costs one request and no AI credits. This guide uses a classic trend-following watchlist: stocks above $10 trading at least $20M a day on average, above their 50-day moving average, with the 50-day above the 200-day and relative strength in the top quarter of the market. Written out, `screen.json` is the following. The three `SMA` rows are three separate conditions, and all of them must hold. ```json { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "Stock": { "PrevDayClose": [ { "State": { "Value": { "Lower": { "Operator": "gte", "Value": 10 } } } } ] }, "Technical": { "SMA": [ { "Timeframe": "1d", "Inputs": ["bar_close"], "Params": { "Period": { "Value": 50 } }, "Returns": { "Want": { "SMA": { "Price": { "Comparison": { "LessThan": true } } } } } }, { "Timeframe": "1d", "Inputs": ["bar_close"], "Params": { "Period": { "Value": 50 } }, "Returns": { "Want": { "SMA": { "Study": { "GreaterThan": { "SMA": [ { "Timeframe": "1d", "Inputs": ["bar_close"], "Params": { "Period": { "Value": 200 } }, "Returns": { "Want": { "SMA": { "PlotChild": true } } } } ] } } } } } }, { "Timeframe": "1d", "Inputs": ["bar_dollar_volume"], "Params": { "Period": { "Value": 50 } }, "Returns": { "Want": { "SMA": { "Value": { "GreaterThan": 20000000 } } } } } ] }, "Indicator": { "RelativeStrengthMkt": [ { "Returns": { "Want": { "Value": { "GreaterThanOrEqual": 75 } } } } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } ``` ## 2. Write the script The script checks the market is closed (so daily bars are settled), runs the scan, adds each match's relative-strength score, and writes a dated CSV. Python: ```python """End-of-day scan: run screen.json after the close and save the matches.""" import csv import datetime as dt import json import os import sys import time import requests API = "https://scanr.ai/api/v2" HEADERS = {"Authorization": f"Bearer {os.environ['SCANR_API_TOKEN']}"} def call(method, path, **kwargs): """One API call, retrying 429s and 5xx with backoff.""" for attempt in range(4): response = requests.request(method, API + path, headers=HEADERS, timeout=120, **kwargs) if response.status_code not in (429, 500, 502, 503, 504) or attempt == 3: response.raise_for_status() return response.json() time.sleep(60 if response.status_code == 429 else 2**attempt) def market_is_closed(): status = call("GET", "/misc/market-status")["status"] return status.get("market") == "closed" def latest_rs(tickers): """Latest market RS (1-99) per ticker. Needs a Power User or Business token.""" since = int((time.time() - 5 * 86400) * 1000) # the last few sessions, in ms body = {"tickers": tickers, "from": since, "limit": 500} latest = {} while True: page = call("POST", "/stocks/get-stock-indicators", json=body) for row in page.get("results") or []: seen = latest.get(row["ticker"]) if seen is None or row["date"] > seen["date"]: latest[row["ticker"]] = row if page.get("is_last_page", True): break body["cursor_date"] = page["next_cursor_date"] body["cursor_ticker"] = page["next_cursor_ticker"] return {ticker: row.get("rs_score") for ticker, row in latest.items()} def main(): if not market_is_closed() and "--force" not in sys.argv: sys.exit("Market is not closed yet; daily bars aren't settled. Use --force to run anyway.") with open("screen.json") as f: payload = json.load(f) # an exported or generated payload result = call("POST", "/scan", json={"instructions": payload}) tickers = result["tickers"] or [] print(f"{result['count']} matches") try: rs = latest_rs(tickers) if tickers else {} except requests.HTTPError as error: if error.response.status_code != 403: raise rs = {} # Starter plan: no RS column today = dt.date.today().isoformat() with open(f"screen-{today}.csv", "w", newline="") as f: writer = csv.writer(f) writer.writerow(["date", "rank", "ticker", "rs_score"]) for rank, ticker in enumerate(tickers, start=1): writer.writerow([today, rank, ticker, rs.get(ticker, "")]) if __name__ == "__main__": main() ``` JavaScript: ```javascript // End-of-day scan: run screen.json after the close and save the matches. // Node 18+. Usage: node eod.mjs [--force] import { readFile, writeFile } from "node:fs/promises"; const API = "https://scanr.ai/api/v2"; const headers = { Authorization: `Bearer ${process.env.SCANR_API_TOKEN}`, "Content-Type": "application/json", }; async function call(method, path, body) { for (let attempt = 0; ; attempt++) { const response = await fetch(API + path, { method, headers, body: body ? JSON.stringify(body) : undefined, }); const retryable = [429, 500, 502, 503, 504].includes(response.status); if (response.ok) return response.json(); if (!retryable || attempt === 3) { throw Object.assign(new Error(`${response.status} ${await response.text()}`), { status: response.status, }); } await new Promise((r) => setTimeout(r, response.status === 429 ? 60_000 : 2 ** attempt * 1000)); } } async function latestRs(tickers) { const body = { tickers, from: Date.now() - 5 * 86_400_000, limit: 500 }; const latest = new Map(); for (;;) { const page = await call("POST", "/stocks/get-stock-indicators", body); for (const row of page.results ?? []) { const seen = latest.get(row.ticker); if (!seen || row.date > seen.date) latest.set(row.ticker, row); } if (page.is_last_page ?? true) break; body.cursor_date = page.next_cursor_date; body.cursor_ticker = page.next_cursor_ticker; } return new Map([...latest].map(([ticker, row]) => [ticker, row.rs_score])); } const { status } = await call("GET", "/misc/market-status"); if (status.market !== "closed" && !process.argv.includes("--force")) { console.error("Market is not closed yet; daily bars aren't settled. Use --force."); process.exit(1); } const payload = JSON.parse(await readFile("screen.json", "utf8")); // an exported or generated payload const result = await call("POST", "/scan", { instructions: payload }); const tickers = result.tickers ?? []; console.log(`${result.count} matches`); let rs = new Map(); try { if (tickers.length) rs = await latestRs(tickers); } catch (error) { if (error.status !== 403) throw error; // Starter plan: no RS column } const today = new Date().toISOString().slice(0, 10); const lines = ["date,rank,ticker,rs_score"]; tickers.forEach((ticker, i) => lines.push(`${today},${i + 1},${ticker},${rs.get(ticker) ?? ""}`)); await writeFile(`screen-${today}.csv`, lines.join("\n") + "\n"); ``` The script makes three kinds of request (one scan, one market-status check, and a relative-strength call per page of 500 rows), so it fits comfortably in any plan's limits. ## 3. Schedule it Run it on weekdays at 16:30 New York time, which leaves a margin after the 16:00 close. On a market holiday the market is closed all day, so the script runs and returns the previous session's list. cron: ```bash # crontab -e (CRON_TZ is supported by cronie and most modern crons) CRON_TZ=America/New_York 30 16 * * 1-5 cd /path/to/screen && SCANR_API_TOKEN=scanr_... python3 eod.py >> eod.log 2>&1 ``` GitHub Actions: ```yaml # .github/workflows/eod-screen.yml name: End-of-day screen on: schedule: # GitHub cron is UTC: 20:30 UTC is 16:30 EDT (15:30 EST; the market-status check covers the gap). - cron: "30 20 * * 1-5" workflow_dispatch: {} jobs: screen: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: { python-version: "3.12" } - run: pip install requests - run: python eod.py env: SCANR_API_TOKEN: ${{ secrets.SCANR_API_TOKEN }} - uses: actions/upload-artifact@v4 with: name: screen path: screen-*.csv ``` GitHub's scheduler runs in UTC and can start late under load. During winter (EST) 20:30 UTC is 15:30 in New York, before the close, and the script exits without scanning. Use `"30 21 * * 1-5"` from November to March, or run it hourly after 20:00 UTC and let the market-status check decide. ## 4. Build on it - **Diff against yesterday.** Load the previous CSV and report only new tickers: the stocks that *entered* the screen today are usually the actionable ones. - **Let scanr.ai do the scheduling.** [Save the scan](https://api-docs.scanr.ai/saved-scans.md) instead, and it runs in the background; read what entered it from `POST /api/v2/scan/paginated-scan-events`. - **Judge the screen.** Once it's saved, its [performance](https://api-docs.scanr.ai/guides/backtest-a-scan.md) tells you whether the stocks it picks actually go on to outperform. ## Next - [Find Relative-Strength Leaders](https://api-docs.scanr.ai/guides/relative-strength-leaders.md): Better screens built around relative strength. - [Measure a Scan's Edge](https://api-docs.scanr.ai/guides/backtest-a-scan.md): Is your screen worth running? - [Coverage & Freshness](https://api-docs.scanr.ai/coverage.md): Why timing matters. --- # Find Relative-Strength Leaders Relative strength (RS) ranks every stock's trailing performance against the rest of the market, as a percentile from 1 to 99. A stock with an RS of 95 has outperformed 95% of the market. Leaders tend to keep leading, which makes RS the backbone of many momentum and trend-following methods. This guide gets you leaders three ways: a ranked list, a scan that combines RS with trend filters, and a history that shows whose RS is rising. **You'll need:** a Power User or Business token for the RS endpoints. The scans in step 2 run on any plan. ## 1. The ranked list `POST /api/v2/stocks/relative-strength` returns the strongest stocks in a scope, best first: cURL: ```bash curl https://scanr.ai/api/v2/stocks/relative-strength \ -H "Authorization: Bearer $SCANR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"scope": "market", "limit": 25}' ``` Python: ```python import os import requests API = "https://scanr.ai/api/v2" HEADERS = {"Authorization": f"Bearer {os.environ['SCANR_API_TOKEN']}"} leaders = requests.post( f"{API}/stocks/relative-strength", headers=HEADERS, json={"scope": "market", "limit": 25}, timeout=60, ).json()["results"] for row in leaders: print(f"{row['ticker']:6} RS {row['rs_score']:>2} {row['sector']} / {row['industry']}") ``` Rank inside a group by setting `scope` to `sector` or `industry` and naming it in `value` (names from `GET /api/v2/stocks/sectors` and `/stocks/industries`). The ranking then uses `rs_sector_score` or `rs_industry_score`: ```json { "scope": "sector", "value": "Healthcare", "limit": 10 } ``` Set `"ascending": true` for the weakest stocks instead: laggards to avoid, or short candidates. Thinly traded names can top an unfiltered list. Set `min_market_cap` (USD, fund AUM counts as market cap) to rank only names above a size floor: ```json { "scope": "market", "limit": 25, "min_market_cap": 2000000000 } ``` For any other condition (volume, trend, distance from the high), scan instead. ## 2. Scan for leaders The scanner combines RS with any other condition. This is a leadership screen in the spirit of a trend template: top-decile RS, price above the 50-day average, the 50-day above the 200-day, within 25% of the 52-week high, and real liquidity. **Get the payload.** Send the screen as a sentence to [`/scan/natural`](https://api-docs.scanr.ai/natural-language-scans.md), or ask your agent, which calls the `natural_scan` tool: ```bash curl -s https://scanr.ai/api/v2/scan/natural \ -H "Authorization: Bearer $SCANR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"query": "relative strength of 90 or more against the market, price above the 50-day moving average, the 50-day moving average above the 200-day, within 25% of the 52-week high, and average daily dollar volume over the last 50 days above $10M"}' \ | jq '.generated_payload' > leaders.json ``` Read the response's `reasoning` before you run it; a generated payload can differ from the one below in detail. If you already run a screen like this in the [scanr.ai scanner](https://scanr.ai/scan), export it instead (**Filters → Export filters**) and save it as `leaders.json`. Written out, the payload is: ```json { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"] }, "All": [ { "Indicator": { "RelativeStrengthMkt": [ { "Returns": { "Want": { "Value": { "GreaterThanOrEqual": 90 } } } } ] }, "Stock": { "Week52High": [ { "State": { "Price": { "Comparison": { "EqualTo": true }, "WithinPercent": 25 } } } ] }, "Technical": { "SMA": [ { "Timeframe": "1d", "Inputs": ["bar_close"], "Params": { "Period": { "Value": 50 } }, "Returns": { "Want": { "SMA": { "Price": { "Comparison": { "LessThan": true } } } } } }, { "Timeframe": "1d", "Inputs": ["bar_close"], "Params": { "Period": { "Value": 50 } }, "Returns": { "Want": { "SMA": { "Study": { "GreaterThan": { "SMA": [ { "Timeframe": "1d", "Inputs": ["bar_close"], "Params": { "Period": { "Value": 200 } }, "Returns": { "Want": { "SMA": { "PlotChild": true } } } } ] } } } } } }, { "Timeframe": "1d", "Inputs": ["bar_dollar_volume"], "Params": { "Period": { "Value": 50 } }, "Returns": { "Want": { "SMA": { "Value": { "GreaterThan": 10000000 } } } } } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } ``` **Run it**, and keep the matches for step 3: ```python import json with open("leaders.json") as f: payload = json.load(f) matches = requests.post( f"{API}/scan", headers=HEADERS, json={"instructions": payload}, timeout=60, ).json()["tickers"] or [] print(f"{len(matches)} leaders:", ", ".join(matches[:20])) ``` Keep `leaders.json`. Re-running it costs one request and no AI credits. ### Variations **Leaders of their industry, not just the market** Require both: strong against the market, and the strongest within the industry. Refine `leaders.json` with *"market relative strength of 80 or more and industry relative strength of 90 or more"* ([`/scan/refine`](https://api-docs.scanr.ai/natural-language-scans.md#refine-it)), or replace the `Indicator` block by hand: ```json "Indicator": { "RelativeStrengthMkt": [ { "Returns": { "Want": { "Value": { "GreaterThanOrEqual": 80 } } } } ], "RelativeStrengthIndustry": [ { "Returns": { "Want": { "Value": { "GreaterThanOrEqual": 90 } } } } ] } ``` **Leaders in strong groups** A leader whose whole group is also strong is more robust than a lone outlier. `GroupStrength` reads the regime of the stock's sector or thematic ETF; positive means the group is extended above its mean. Refine with *"market relative strength of 80 or more, and group strength of 10 or more"*, or set the `Indicator` block by hand: ```json "Indicator": { "RelativeStrengthMkt": [ { "Returns": { "Want": { "Value": { "GreaterThanOrEqual": 80 } } } } ], "GroupStrength": [ { "Returns": { "Want": { "Value": { "GreaterThanOrEqual": 10 } } } } ] } ``` This is scanr.ai's *Leaders in Strong Groups* preset. `GroupStrength` settles once a day, so during the session it reflects the previous close. **Only when the market itself is healthy** `MarketStrength` is the same regime reading for the broad market, identical for every stock. Adding it makes the whole screen return nothing when the market is below its adaptive mean, which is often when breakouts fail. Refine with *"only when market strength is above 0"*, or add it by hand: ```json "MarketStrength": [ { "Returns": { "Want": { "Value": { "GreaterThan": 0 } } } } ] ``` Add it inside the `Indicator` block. ## 3. Find rising RS A stock whose RS climbed from 60 to 85 over a month is often more interesting than one that has sat at 95 for a year. `POST /api/v2/stocks/get-stock-indicators` returns RS history for a list of tickers; pass `from` and `to` as Unix milliseconds: ```python import time def rs_history(tickers, days=45): """{ticker: [(date, rs_score), ...]} oldest first, over the last `days` calendar days.""" body = {"tickers": tickers, "from": int((time.time() - days * 86400) * 1000), "limit": 500} history = {} while True: page = requests.post(f"{API}/stocks/get-stock-indicators", headers=HEADERS, json=body, timeout=60).json() for row in page.get("results") or []: history.setdefault(row["ticker"], []).append((row["date"], row["rs_score"])) if page.get("is_last_page", True): break body["cursor_date"] = page["next_cursor_date"] body["cursor_ticker"] = page["next_cursor_ticker"] return {t: sorted(rows) for t, rows in history.items()} # `matches` is the leadership scan from step 2. Rank it by RS gained over ~20 sessions. history = rs_history(matches) risers = sorted( ((t, rows[-1][1] - rows[-21][1]) for t, rows in history.items() if len(rows) > 20), key=lambda item: item[1], reverse=True, ) for ticker, gained in risers[:15]: print(f"{ticker:6} +{gained} RS points in 20 sessions") ``` ## Reading RS well - **RS is relative.** In a falling market, an RS of 90 can belong to a stock that is down, just less than nearly everything else. Pair it with a trend filter (price above its averages) as in step 2. - **RS updates through the day.** Intraday values move with prices and settle after the close; rows carry `realtime: true` until then. Screen after the close for a stable list. - **Sector and industry RS answer different questions.** Market RS finds the strongest stocks; industry RS finds the strongest stock *in* a group, useful when you've already chosen the group. ## Next - [Track Theme Rotation](https://api-docs.scanr.ai/guides/theme-rotation.md): Find the strongest groups, then their leaders. - [Run an End-of-Day Screen](https://api-docs.scanr.ai/guides/end-of-day-screen.md): Run the leadership scan every evening. - [Measure a Scan's Edge](https://api-docs.scanr.ai/guides/backtest-a-scan.md): Check whether the screen's picks actually outperform. --- # Track Theme Rotation Money rotates between groups of stocks: miners one month, networking hardware the next. scanr.ai's **themes** are those groups, found from how stocks actually trade together (their price correlation) rather than from industry codes, so a theme like *silver miners* holds the stocks that really move with silver. This guide finds the themes gaining strength, then scans inside the best one for its leaders. **You'll need:** a Business token for the theme endpoints. The scan in step 3 runs on any plan. ## 1. List the themes `GET /api/v2/stocks/themes` returns every theme with its members: ```python import os import requests API = "https://scanr.ai/api/v2" HEADERS = {"Authorization": f"Bearer {os.environ['SCANR_API_TOKEN']}"} catalog = requests.get(f"{API}/stocks/themes", headers=HEADERS, timeout=60).json() themes = [t for t in catalog["themes"] if t["theme_band"] != "residual"] for theme in themes[:10]: print(theme.get("theme_key"), "|", theme["label"], "|", theme["num_tickers"], "stocks") ``` Each theme has a stable `theme_key` (use it in scans), a `label` and `description`, its member `tickers` with each member's `tier` (1 for core members, higher for looser ones), and `avg_intra_correlation`, how tightly the members move together. `theme_band` says what kind of group it is: `canonical` themes have a recognized name, `unnamed` ones are real correlated groups still waiting for one, and `residual` groups are leftovers bucketed by size or float rather than a shared story. Skip `residual` for rotation work. Memberships are recalculated weekly. ## 2. Rank themes by strength `GET /api/v2/stocks/themes/performance/latest` scores every theme for the current session: | Field | Meaning | | --- | --- | | `performance` | The theme's return for the session, across its members. | | `avg_rs_score` | The average relative strength (1–99) of the theme's members. | | `rs_slope_20d`, `rs_slope_r2` | The trend of that average over the last 20 sessions, and how steady the trend is (R²). | | `avg_relative_volume` | How heavily the members are trading compared with normal. | | `tickers` | Each member's `daily_return`, `relative_volume` and `dollar_volume`. | Rotation shows up as **rising** strength more than high strength. Rank by the RS slope, keeping only steady trends: ```python latest = requests.get(f"{API}/stocks/themes/performance/latest", headers=HEADERS, timeout=60).json() rising = sorted( ( t for t in latest["themes"] or [] if t["theme_band"] != "residual" and t.get("rs_slope_20d") is not None and (t.get("rs_slope_r2") or 0) >= 0.5 ), key=lambda t: t["rs_slope_20d"], reverse=True, ) for t in rising[:10]: print(f"{t['label']:40} RS {t['avg_rs_score']:5.1f} slope {t['rs_slope_20d']:+.2f}") ``` For your own measures, pull the time series from `GET /api/v2/stocks/themes/performance/history?days=90`: one entry per session (`perf_date`), each with every theme's figures. `GET /api/v2/stocks/themes/performance/by-date?date=2026-09-15` returns a single past session. ## 3. Scan inside the theme `Universe.Theme` restricts any scan to a theme's members. This finds the members of the top rising theme that are themselves leaders, above their 50-day average with RS of 70 or more. Get the leadership part of the payload the usual way: send *"relative strength of 70 or more, and price above the 50-day moving average"* to [`/scan/natural`](https://api-docs.scanr.ai/natural-language-scans.md), or export a scan you already use from the [scanr.ai scanner](https://scanr.ai/scan) (**Filters → Export filters**). Save it as `theme-leaders.json`. The theme is the part that changes from week to week, so the code sets it. Written out, with the theme filled in, the payload is: ```json { "Universe": { "Exchange": ["nasdaq", "nyse", "amex", "arca", "bats", "bx"], "Type": ["cs", "os", "adrc"], "Theme": ["silver-miners"], "ThemeMaxTier": 2 }, "All": [ { "Indicator": { "RelativeStrengthMkt": [ { "Returns": { "Want": { "Value": { "GreaterThanOrEqual": 70 } } } } ] }, "Technical": { "SMA": [ { "Timeframe": "1d", "Inputs": ["bar_close"], "Params": { "Period": { "Value": 50 } }, "Returns": { "Want": { "SMA": { "Price": { "Comparison": { "LessThan": true } } } } } } ] } } ], "Sort": { "field": "market_cap", "direction": "desc" } } ``` In code, fill the theme from step 2 and run it: ```python import json with open("theme-leaders.json") as f: payload = json.load(f) payload["Universe"]["Theme"] = [rising[0]["theme_key"]] result = requests.post(f"{API}/scan", headers=HEADERS, json={"instructions": payload}, timeout=60).json() print(rising[0]["label"], "leaders:", result["tickers"]) ``` `ThemeMaxTier` sets how far from the theme's core to look: `1` core members only, `2` core and satellite members (the default), `3` every member. `Theme` accepts several keys, which are alternatives: a stock in any of them qualifies. `GET /api/v2/scan/universe-themes` lists the scannable theme keys with member counts, and works on any plan. A key that isn't in it matches nothing, without an error. ## Putting it together A weekly rotation report is these three steps in a loop: rank themes by RS slope, take the top three, scan each for leaders with the same `theme-leaders.json`, and report the leaders alongside their theme's figures. It costs one request for the performance data and one per theme scanned, and no AI credits after the payload is first generated. ## Next - [Find Relative-Strength Leaders](https://api-docs.scanr.ai/guides/relative-strength-leaders.md): Leadership screens to run inside a theme. - [Datasets](https://api-docs.scanr.ai/datasets.md#market-themes): The theme endpoints in full. - [Building Scan Payloads](https://api-docs.scanr.ai/scan-payloads.md#universe-restrictions): Universe restrictions. --- # Research a Concept Some questions aren't about price at all. Does this company have anything to do with Greenland? Which stocks are exposed to GLP-1 drugs? **Concept research** answers them from what a company says in its SEC filings, what the news says about it and its company profile. Every "yes" comes with the passage it rests on and a link to the source. There are two questions, and an endpoint for each: | Question | Endpoint | Returns | | --- | --- | --- | | Does this ticker relate to the concept? | `GET /api/v2/stocks/concept-research/relate` | One answer: `related`, `not_related` or `unknown`, with its citation. | | Which tickers relate to the concept? | `GET /api/v2/stocks/concept-research/find` | The related tickers ranked by probability, each with its citation. | A concept is free text: a theme, place, product, technology or event (*"rare earths"*, *"Strait of Hormuz"*, *"GLP-1"*). There is no list to pick from. Pass the phrase as a person would say it rather than expanding it into keywords. Generic market vocabulary (*"valuation"*, *"growth"*) is refused. **You'll need:** a Power User or Business token. The examples use the made-up tickers `XMPL` and `XMPM`. > **Rolling out:** Concept research opens account by account. Until it's on for yours, both endpoints answer `404` and the MCP tool says it isn't available yet. Once it's on, a `503` with `code: "not_available_yet"` means the concept index itself hasn't opened. ## Concept research or search? `GET /api/v2/stocks/search-securities?q=lithium&query_description=true` finds companies whose description *contains* a keyword. It is fast and cheap, but it can't tell a lithium miner from a company that mentions lithium batteries once, and it never says why it matched. Concept research *judges* the relation. A classifier reads the company's filings, news and profile and decides whether it relates to the concept, and a positive answer always carries the passage that convinced it. Use search to look a company up; use concept research when the question is "is X exposed to / connected to Y?". ## 1. Ask about one ticker Python: ```python import os import requests API = "https://scanr.ai/api/v2" HEADERS = {"Authorization": f"Bearer {os.environ['SCANR_API_TOKEN']}"} answer = requests.get( f"{API}/stocks/concept-research/relate", headers=HEADERS, params={"ticker": "XMPL", "concept": "rare earths"}, timeout=60, ).json() print(answer["answer"], "-", answer["meaning"]) ``` cURL: ```bash curl -G https://scanr.ai/api/v2/stocks/concept-research/relate \ -H "Authorization: Bearer $SCANR_API_TOKEN" \ --data-urlencode "ticker=XMPL" \ --data-urlencode "concept=rare earths" ``` JavaScript: ```javascript const params = new URLSearchParams({ ticker: "XMPL", concept: "rare earths" }); const res = await fetch( `https://scanr.ai/api/v2/stocks/concept-research/relate?${params}`, { headers: { Authorization: `Bearer ${process.env.SCANR_API_TOKEN}` } }, ); const answer = await res.json(); console.log(answer.answer, "-", answer.meaning); ``` ```json { "ok": true, "mode": "relate", "ticker": "XMPL", "concept": "Rare earths", "conceptSlug": "rare-earths", "conceptStatus": "active", "answer": "related", "meaning": "XMPL relates to \"Rare earths\". Say so and cite the evidence: link its url as a markdown link naming the source and date, and quote the passage briefly.", "coverage": "index", "provenance": "From the concept index: a sweep asked this pair against the ticker's current digest (its filings, news and company profile).", "probability": 0.91, "threshold": 0.62, "confidence": 0.88, "citation": { "source": "SEC filing", "quote": "Our Halvorsen project hosts a rare earth deposit, and in 2024 we completed a pilot plant producing a mixed rare earth carbonate.", "url": "https://example.com/filings/xmpl-10k-2024", "publishedOn": "2025-03-14", "docId": "0001234567-25-000012:14" }, "relatedSince": "2025-03-14", "askedAsOf": "2026-09-30", "modelVersion": "concept-clf/2026-09", "resolvedBy": "hot" } ``` `concept` comes back as the label it resolved to, so *"rare earth metals"* and *"REEs"* both answer as `Rare earths`. `resolvedBy` says how: `hot` for a known concept or alias, `warm` for a close match to one, `cold` for a phrase nothing matched. A cold phrase is answered live and comes back with `conceptStatus: "provisional"`. ## 2. Read the answer There are three ways to answer, and only one of them is a yes: | `answer` | Meaning | What to say | | --- | --- | --- | | `related` | The classifier found evidence, and `citation` holds it. | It relates, citing the source. | | `not_related` | The pair was asked and the classifier found no evidence. | No evidence in its filings, news or profile *as of `askedAsOf`*. That is not proof of no connection. | | `unknown` | Nothing had asked this pair. | Unknown. **Not a no.** | `coverage` says where the answer came from: | `coverage` | Source | | --- | --- | | `index` | A sweep asked this pair against the ticker's current filings, news and profile. | | `live` | Nobody had asked yet, so it was classified just now. The answer is stored, so the next ask is a read. | | `partial` | Past dates only: a stored relation exists, but no sweep had asked this pair by that date. | | `none` | Nothing had asked the pair by that date. `answer` is `unknown`. | `meaning` and `provenance` put the same thing in plain language, worded for an AI model to relay: pass them to your model verbatim, or build your own text from `answer` and `coverage`. Don't collapse `unknown` into `not_related`; they mean different things, and the distinction is the point. `probability` is the classifier's probability that the ticker relates, and `threshold` is the bar it was compared against. `probability` is `null` when it fell below the storage floor (so `not_related`, exact value not kept) or the pair was never asked. `relatedSince` is when the cited evidence became public, which is when the relation starts. ## 3. Find the tickers that relate ```python found = requests.get( f"{API}/stocks/concept-research/find", headers=HEADERS, params={"concept": "rare earths", "limit": 15}, timeout=60, ).json() print(found["meaning"]) for hit in found["results"]: c = hit["citation"] print(f"{hit['ticker']:6} p={hit['probability']:.2f} {c['source']}, {c['publishedOn']}: {c['url']}") ``` ```json { "ok": true, "mode": "find", "concept": "Rare earths", "conceptSlug": "rare-earths", "conceptStatus": "active", "coverage": "partial", "meaning": "Partial, not exhaustive: what the index holds plus one live pass over 200 keyword-matched candidates, as of 2026-09-30. Other tickers may relate too; say this is not a complete list. Cite each ticker's evidence as a markdown link naming the source and date. A background sweep of the whole universe was requested, so asking again later may find more.", "count": 2, "candidatesAsked": 200, "limitReached": false, "sweepRequested": true, "askedAsOf": "2026-09-30", "resolvedBy": "hot", "results": [ { "ticker": "XMPL", "probability": 0.91, "citation": { "source": "SEC filing", "quote": "Our Halvorsen project hosts a rare earth deposit, and in 2024 we completed a pilot plant producing a mixed rare earth carbonate.", "url": "https://example.com/filings/xmpl-10k-2024", "publishedOn": "2025-03-14", "docId": "0001234567-25-000012:14" }, "live": false }, { "ticker": "XMPM", "probability": 0.77, "citation": { "source": "news article", "quote": "The motor maker signed a five-year supply agreement for neodymium magnets.", "url": "https://example.com/news/n-88231", "publishedOn": "2026-05-02", "docId": "n-88231" }, "live": true } ] } ``` Results are ranked by `probability`, highest first. `limit` is 1–25 (default 15). Read `coverage` before you treat the list as complete: - **`index`:** the concept is fully indexed, so this is the whole market's answer. If `limitReached` is `true` the list was cut at `limit` and more tickers relate. An empty `index` list really does mean no ticker relates. - **`partial`:** the concept isn't fully indexed yet. The list is what the index holds plus one live pass over `candidatesAsked` keyword-matched candidates; `live: true` marks a ticker that pass found. Other tickers may relate too. An **empty `partial` list** means "none among the 200 candidates asked", not "no ticker relates": ```json { "ok": true, "mode": "find", "concept": "Lunar mining", "conceptSlug": "lunar-mining", "conceptStatus": "provisional", "coverage": "partial", "meaning": "None among the 200 candidates asked, as of 2026-09-30. This is NOT \"no ticker relates to Lunar mining\": only those candidates were checked. Say exactly that.", "count": 0, "candidatesAsked": 200, "limitReached": false, "sweepRequested": false, "askedAsOf": "2026-09-30", "resolvedBy": "cold", "results": [] } ``` When `sweepRequested` is `true`, the answer was thin and a sweep of the whole market was queued for the concept. Asking again later can return more. ## Asking about the past Both endpoints take `as_of` (`YYYY-MM-DD`) to answer as of an earlier date. A past date is answered **from the index only** and never classified live: a live pass would read today's filings into the past. So a past date can come back `unknown` (relate) or as an empty `partial` list (find) where today's answer would not: ```json { "ok": true, "mode": "relate", "ticker": "XMPL", "concept": "Lunar mining", "conceptSlug": "lunar-mining", "conceptStatus": "provisional", "answer": "unknown", "meaning": "Unknown: there is no answer for XMPL and \"Lunar mining\" as of 2026-01-02. This is NOT a no. Say it is unknown; asking without a date gets today's answer, which may be classified live.", "coverage": "none", "provenance": "Nothing had asked this pair as of 2026-01-02, and a past date is never classified live.", "probability": null, "threshold": null, "confidence": null, "citation": null, "relatedSince": null, "askedAsOf": "2026-01-02", "modelVersion": null, "resolvedBy": "cold" } ``` `askedAsOf` is the date the question was answered for. `relatedSince` (relate) and each citation's `publishedOn` say when the evidence became public: a relation exists from that date on. ## Follow the citations Every `related` answer and every `find` result carries a `citation`: | Field | Meaning | | --- | --- | | `source` | `SEC filing`, `news article` or `company profile`. | | `quote` | The passage the classifier read, trimmed at a word boundary (relate keeps up to 600 characters, find up to 240). | | `url` | A link to the source document. `null` when none is known. | | `publishedOn` | When the passage became public. | | `docId` | The passage's id in its corpus, stable across calls. | Show the citation with the answer, and link `url` rather than paraphrasing it: the quote is the evidence, and a reader should be able to check it. The quote is stored with the answer, so it outlives the source (old news is pruned). A `related` answer without a citation is never returned; if the classifier ever said yes without evidence, the answer comes back `unknown`. ## Refusals A refusal is an answer, not a failure. It has the usual [error body](https://api-docs.scanr.ai/errors.md) plus a stable `code` to branch on and `retryable`, whether the same request can succeed later: ```json { "error": { "statusCode": 422, "message": "\"valuation\" is generic market vocabulary, which concept research refuses rather than answers.", "code": "generic_concept", "retryable": false } } ``` | Status | `code` | Meaning | What to do | | --- | --- | --- | --- | | `503` | `not_available_yet` | The concept index hasn't opened, so nothing is answered yet. It says nothing about the ticker or concept. | Don't retry in a loop. Fall back to `search-securities` with `query_description=true`, and call it a keyword match. | | `503` | `classifier_unavailable` | The answer needs a live classification and the classifier is down. Not a no. | Retry later (`retryable: true`). | | `422` | `generic_concept` | The concept is generic market vocabulary (*"valuation"*, *"growth"*). | Ask about something specific. | | `422` | `empty_concept` | Nothing is left of the concept once normalized. | Ask which theme, place, product or event is meant. | | `404` | `no_source_documents` | **relate only:** the ticker has no filings, news or profile to judge from. Not a no. | Report that it can't be answered for this ticker. | | `504` | `timeout` | Answering took too long. | Safe to retry once (`retryable: true`). | Two 503s mean different things, so branch on `code`, not only on the status: ```python def relate(ticker, concept): res = requests.get( f"{API}/stocks/concept-research/relate", headers=HEADERS, params={"ticker": ticker, "concept": concept}, timeout=60, ) if res.ok: return res.json() error = res.json()["error"] if error.get("code") == "not_available_yet": return None # the feature isn't open yet; don't hammer it if error.get("retryable"): raise RuntimeError(f"try again later: {error['message']}") raise ValueError(error["message"]) # rephrase the question ``` A `404` **without** a `code` means concept research isn't enabled for your account yet. The usual `400`, `401`, `403` (a Starter token) and `429` apply as on every endpoint. ## Over MCP The [MCP server](https://api-docs.scanr.ai/mcp-server.md) has the same research as one tool, `concept_research`: with `ticker` it answers one pair (relate), without it it finds the tickers that relate (find). It returns the same JSON as the endpoints, and a refusal comes back as a result with `ok: false`, its `code`, a `message` to relay and `retryable`, rather than as a tool error. It needs a Power User or Business token. A prompt like this is enough: ```text Which of my watchlist relates to rare earths? My watchlist: XMPL, XMPM, AAPL, NVDA. Cite the source for every yes, and say which ones are unknown rather than no. ``` A well-behaved assistant calls `concept_research` once per ticker with `ticker` set, because `find` returns only the top results and can be partial, while `relate` gives every watchlist name a definitive `related`, `not_related` or `unknown`. It then reports the yes answers with their links and keeps the unknowns separate from the noes. ## Limits Each request counts once against the endpoint's normal [rate limits](https://api-docs.scanr.ai/rate-limits.md), whether it was answered from the index or classified live. A live answer is stored, so asking the same pair again is a fast read. Concept research spends no AI credits. ## Next - [MCP Server](https://api-docs.scanr.ai/mcp-server.md): Connect an assistant and give it `concept_research`. - [Build a Scanning Agent](https://api-docs.scanr.ai/guides/scanning-agent.md): Combine research with scans in one agent. - [Errors](https://api-docs.scanr.ai/errors.md): Status codes and retrying. --- # Compute Technical Indicators The technicals endpoints compute any of 80+ indicators (moving averages, oscillators, volatility and volume studies, regressions, Hilbert-transform cycles) for a list of tickers, on scanr.ai's own price history. You send tickers and parameters; you never send price data. Use them to show *why* a stock matched a scan, to feed a model, or to chart studies without running your own TA library. **You'll need:** a Power User or Business token. ## One indicator Each indicator has its own endpoint, `POST /api/v2/technicals/technical/{category}/{name}`. RSI is `momentum/rsi`: cURL: ```bash curl https://scanr.ai/api/v2/technicals/technical/momentum/rsi \ -H "Authorization: Bearer $SCANR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"tickers": ["AAPL", "MSFT", "NVDA"], "timeframe": "1d", "params": {"period": 14}}' ``` Python: ```python import os import requests API = "https://scanr.ai/api/v2" HEADERS = {"Authorization": f"Bearer {os.environ['SCANR_API_TOKEN']}"} rsi = requests.post( f"{API}/technicals/technical/momentum/rsi", headers=HEADERS, json={"tickers": ["AAPL", "MSFT", "NVDA"], "timeframe": "1d", "params": {"period": 14}}, timeout=60, ).json() for row in rsi["results"]: print(row["ticker"], round(row["values"]["rsi"], 1)) ``` JavaScript: ```javascript const rsi = await fetch("https://scanr.ai/api/v2/technicals/technical/momentum/rsi", { method: "POST", headers: { Authorization: `Bearer ${process.env.SCANR_API_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ tickers: ["AAPL", "MSFT", "NVDA"], timeframe: "1d", params: { period: 14 } }), }).then((r) => r.json()); for (const row of rsi.results) console.log(row.ticker, row.values.rsi.toFixed(1)); ``` ```json { "indicator": "rsi", "outputs": ["rsi"], "results": [ { "ticker": "AAPL", "values": { "rsi": 58.4 } }, { "ticker": "MSFT", "values": { "rsi": 61.9 } }, { "ticker": "NVDA", "values": { "rsi": 71.2 } } ], "count": 3 } ``` ### The request | Field | Meaning | | --- | --- | | `tickers` | One or more tickers. | | `timeframe` | `1m` … `4hr`, `1d`, `2d`, `3d`, `4d`, `1wk`, `1mo`, the same values as scans. | | `params` | The indicator's integer parameters, **lower-case**: `period`, `fastperiod`, `slowperiod`, `signalperiod`, `nbdevup`, … Omitted parameters use their defaults. | | `enum_params` | Moving-average types for studies that take them: `{"matype": "EMA"}`. One of `SMA`, `EMA`, `WMA`, `DEMA`, `TEMA`, `TRIMA`, `KAMA`, `MAMA`, `T3`. | | `within` | `1` (the default) returns the latest value. A larger number returns that many trailing values as a series, oldest first. | | `limit_bars` | Feed the study more history than its default lookback, for slow studies that need warming up. | | `include_inactive` | Include delisted or inactive tickers. | Values always end at the latest bar, which during market hours is the in-progress one. There is no date range. ### Multi-output indicators Some indicators produce several lines; `outputs` names them. MACD returns `macd`, `macdsignal` and `macdhist`; Bollinger Bands return `upperband`, `middleband` and `lowerband`. With `within`, each output becomes a series: ```bash curl https://scanr.ai/api/v2/technicals/technical/momentum/macd \ -H "Authorization: Bearer $SCANR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"tickers": ["NVDA"], "timeframe": "1d", "within": 5}' ``` ```json { "indicator": "macd", "outputs": ["macd", "macdsignal", "macdhist"], "results": [ { "ticker": "NVDA", "series": { "macd": [3.1, 3.4, 3.9, 4.2, 4.6], "macdsignal": [2.8, 2.9, 3.1, 3.3, 3.6], "macdhist": [0.3, 0.5, 0.8, 0.9, 1.0] } } ], "count": 1 } ``` A value is `null` where the study hasn't warmed up yet (for example, the first 13 bars of a 14-period RSI when the ticker has little history). ## Many indicators at once `POST /api/v2/technicals/technical/bulk` computes a set of indicators over the same tickers and timeframe in one request, from one fetch of price history. Name each result with a `key`: cURL: ```bash curl https://scanr.ai/api/v2/technicals/technical/bulk \ -H "Authorization: Bearer $SCANR_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "tickers": ["NVDA", "AMD", "AVGO"], "timeframe": "1d", "indicators": [ { "indicator": "sma", "key": "sma50", "params": { "period": 50 } }, { "indicator": "sma", "key": "sma200", "params": { "period": 200 } }, { "indicator": "rsi", "params": { "period": 14 }, "within": 10 }, { "indicator": "atr", "params": { "period": 14 } }, { "indicator": "bbands", "params": { "period": 20 }, "enum_params": { "matype": "EMA" } } ] }' ``` Python: ```python bulk = requests.post( f"{API}/technicals/technical/bulk", headers=HEADERS, json={ "tickers": ["NVDA", "AMD", "AVGO"], "timeframe": "1d", "indicators": [ {"indicator": "sma", "key": "sma50", "params": {"period": 50}}, {"indicator": "sma", "key": "sma200", "params": {"period": 200}}, {"indicator": "rsi", "params": {"period": 14}, "within": 10}, {"indicator": "atr", "params": {"period": 14}}, ], }, timeout=60, ).json() for row in bulk["results"]: ind = row["indicators"] trend = "up" if ind["sma50"]["values"]["line"] > ind["sma200"]["values"]["line"] else "down" print(row["ticker"], "trend", trend, "ATR", round(ind["atr"]["values"]["line"], 2)) ``` Each ticker's `indicators` map holds one entry per `key` (a key defaults to the indicator's name, so two `sma` entries need their own keys). Single-line indicators name their output `line`. If a requested indicator can't be computed for your plan, it's listed under `excluded` with the reason, and the rest are still returned. ## Indicators and scans together The same studies are scan filters, so the two endpoints divide the work: 1. **Scan** to find the stocks that meet a condition across the whole market (for example, RSI(14) below 30). 2. **Compute** indicators for the matches to rank or explain them (the exact RSI, distance from the 200-day average, ATR for position sizing). One scan plus one bulk call covers any number of matches, which is far cheaper than computing indicators for every stock and filtering them yourself. > **The names differ between the two:** Scan payloads use the catalog's spelling: `"Technical": {"RSI": [{"Params": {"Period": {"Value": 14}}}]}`, with capitalized names and `{"Value": n}` wrappers. The technicals endpoints use lower-case slugs and plain numbers: `/momentum/rsi` with `{"params": {"period": 14}}`. Output names differ too: `RSI` in a scan's `Want`, `rsi` here; `Signal` there, `macdsignal` here. ## The indicator list | Category | Examples | | --- | --- | | `overlap` | `sma`, `ema`, `wma`, `dema`, `tema`, `kama`, `t3`, `bbands`, `sar`, `midpoint` | | `momentum` | `rsi`, `macd`, `stoch`, `stochrsi`, `adx`, `cci`, `mfi`, `roc`, `willr`, `aroon`, `ultosc` | | `volatility` | `atr`, `natr`, `trange`, `adr`, `squeeze` | | `volume` | `obv`, `ad`, `adosc`, `relvol-stddev`, `relvol-directional` | | `statistic` | `linearreg`, `linearreg-slope`, `stddev`, `beta`, `correl`, `tsf` | | `cycle` | `ht-dcperiod`, `ht-sine`, `ht-trendmode` | | `price` | `avgprice`, `medprice`, `typprice`, `wclprice` | | `math_o` | `max`, `min`, `sum`, `minmax` | Every endpoint, with its parameters, defaults and outputs, is under **Technical Indicators** in the [API Reference](https://api-docs.scanr.ai/api). ## Next - [Building Scan Payloads](https://api-docs.scanr.ai/scan-payloads.md#technical-studies): The same studies as scan filters. - [Run an End-of-Day Screen](https://api-docs.scanr.ai/guides/end-of-day-screen.md): Scan, then enrich the matches. - [API Reference](https://api-docs.scanr.ai/api): Every indicator endpoint. --- # Measure a Scan's Edge A scan that returns plausible-looking stocks every day isn't necessarily a good scan. The question is what those stocks did *next*. scanr.ai answers it for every saved scan: each time a stock enters the result, it's recorded as a simulated trade, and the trades are scored over holding periods from a day to a year. This guide reads those scores, drills into the trades behind them, and explains how to avoid fooling yourself with them. **You'll need:** a Power User or Business token, and a [saved scan](https://api-docs.scanr.ai/saved-scans.md) that has been running for a while. ## How the simulation works - A **trade** opens when a stock enters the scan's result, at the price when it was recorded. - It closes after a fixed **time horizon**: `1d`, `3d`, `5d`, `10d`, `1M`, `3M`, `6M`, `1y`, or at year end (`ytd`). - A stock that stays in the result doesn't open a new trade every run; there's a cooldown between re-entries. - These are simulated triggers, not broker fills: no slippage, commissions or position sizing. Trades are recorded as the scan runs, so the record builds up over time. A new scan reports `insufficient_history: true` until there's enough of it to judge. ## 1. Read the scorecard `POST /api/v2/scan/myscans/most-recent-scan-performances` returns the latest statistics for your scans, grouped by horizon: ```python import os import requests API = "https://scanr.ai/api/v2" HEADERS = {"Authorization": f"Bearer {os.environ['SCANR_API_TOKEN']}"} SCAN_ID = 918273 # the saved scan's leviathan_id perf = requests.post( f"{API}/scan/myscans/most-recent-scan-performances", headers=HEADERS, json={"scan_ids": [SCAN_ID], "universe": "listed"}, timeout=60, ).json() print(f"{'horizon':8} {'trades':>7} {'win':>7} {'avg':>7} {'expect':>7} {'sharpe':>7}") for horizon in ["1d", "5d", "10d", "1M", "3M"]: for row in perf["results"].get(horizon) or []: if row["insufficient_history"]: print(f"{horizon:8} not enough history yet") continue print( f"{horizon:8} {row['total_trades'] or 0:>7} " f"{row['win_rate'] or 0:>7.1%} " f"{row['avg_percent_return'] or 0:>7.2%} " f"{row['expectancy'] or 0:>7.2%} " f"{row['sharpe_ratio'] or 0:>7.2f}" ) ``` `scan_ids: null` returns every saved scan you own, which makes it easy to rank them against each other. `universe` limits the trades to exchange-listed stocks (`listed`), OTC (`otc`), or both (`all`, the default). Percentages come back as **fractions**: a `win_rate` of `0.58` is 58%, an `avg_percent_return` of `0.0784` is 7.84%. Sharpe, Sortino and Calmar are plain ratios. The fields worth reading first: | Field | Meaning | | --- | --- | | `total_trades` | How many trades the numbers rest on. Read this before anything else. | | `win_rate` | The share of trades that ended in profit. | | `avg_percent_return` | The average return per trade over the horizon. | | `expectancy` | What a trade is worth on average: `win rate × average win − loss rate × average loss`. | | `sharpe_ratio`, `sortino_ratio` | Return per unit of risk (all volatility, or downside only). | | `avg_max_drawdown` | How far trades typically went against you before the horizon ended. | | `median_mfe`, `median_mae` | The typical best and worst point of a trade: how much room it gave you. | | `top_tickers` | The tickers that traded most often. | ## 2. Look at the trades Aggregates hide a lot. `POST /api/v2/scan/myscans/scans-for-ticker` tells you which of your scans have traded a ticker, and `POST /api/v2/scan/myscans/ticker-trade-outcomes` lists every simulated trade for one ticker in one scan: ```python trades = requests.post( f"{API}/scan/myscans/ticker-trade-outcomes", headers=HEADERS, json={"scan_id": SCAN_ID, "ticker": "NVDA", "time_horizon": "1M"}, timeout=60, ).json() for t in trades["results"] or []: status = "closed" if t["DidExit"] else "open" print(t["ActualEntryDate"][:10], f"{t['PercentageReturn']:+.2%}", status) ``` Each trade has its entry and exit dates, `PercentageReturn` (a fraction), `DidExit` (whether the horizon has elapsed), `BarsHeld`, and the best and worst points along the way (`MaxProfitSinceEntryPercentage`, `MaxLossSinceEntryPercentage`, `MaxDrawdown`). Entry and exit prices are not included in API responses; use the dates to look them up on a chart. ## 3. Judge it honestly - **Count the trades.** Twenty trades can show a 70% win rate by chance. Treat anything under a hundred trades as a hint, not a result. - **Pick the horizon before you look.** Nine horizons give nine chances for one of them to look good. Decide how long you intend to hold, then read that row. - **Compare with doing nothing.** A trend-following scan in a rising market will look good at every horizon. Save a deliberately dumb control scan (for example, every stock above $10 with RS above 50) and compare the two. - **Watch the drawdowns.** A strong `avg_percent_return` with a deep `avg_max_drawdown` is a strategy you probably won't hold through. - **Change one thing at a time.** Save variants as separate scans (a different RSI threshold, an extra liquidity filter) and let them run side by side. The one with more trades and a similar edge is usually the better choice. - **Expect decay.** Edges fade as markets change. Re-check scans you rely on every few months. ## Next - [Saved Scans & Performance](https://api-docs.scanr.ai/saved-scans.md#performance): The performance endpoints in full. - [Run an End-of-Day Screen](https://api-docs.scanr.ai/guides/end-of-day-screen.md): Put a proven scan to work. - [Find Relative-Strength Leaders](https://api-docs.scanr.ai/guides/relative-strength-leaders.md): Scans worth measuring. --- # Build a Scanning Agent An AI agent with scanr.ai can answer questions like *"which beaten-down large caps are starting to turn up, and which of them are in strong sectors?"* by building a scan, running it and reading the results. This guide covers the two ways to get there, and the design choices that make the agent reliable. ## Option 1: no code, with MCP If the agent is Claude Code, Claude Desktop, Cursor, VS Code or Codex, connect the [MCP server](https://api-docs.scanr.ai/mcp-server.md) and install the [Agent Skill](https://api-docs.scanr.ai/agents.md#agent-skill). The client gets the scan tools, and the skill tells the model how to use them. Then ask: > Find stocks above their 200-day moving average with RSI under 35 and market cap over $5B. Show me what the scan checks before you run it. That covers most interactive use. Build your own agent when you need it inside your product, on a schedule, or with tools the MCP server doesn't have (relative strength, themes, your own data). ## Option 2: your own agent The agent below uses the [Claude API](https://docs.claude.com/en/api/overview) with four tools over the scanr.ai REST API. The model decides when to call them; the SDK's tool runner handles the loop. ### Design the tools around the workflow The tools mirror the reliable scanning workflow (generate → refine → run → enrich), and two design choices matter more than anything else: - **The model never writes payload JSON.** `natural_scan` and `refine_scan` produce payloads on the server, and the agent stores them and hands the model a short **payload id**. The model passes ids between tools instead of re-typing large JSON, which is where agents otherwise introduce errors. - **Results are trimmed before the model sees them.** A broad scan can match a thousand tickers. `run_scan` returns the count and the first 50 in sort order, which is what the model needs to reason with. ### The code ```python """A scanning agent: Claude + scanr.ai. pip install anthropic requests""" import json import os import time import anthropic import requests from anthropic import beta_tool SCANR = "https://scanr.ai/api/v2" SCANR_HEADERS = {"Authorization": f"Bearer {os.environ['SCANR_API_TOKEN']}"} PAYLOADS: dict[str, dict] = {} # payload id -> scan payload, kept out of the model's context def scanr(path: str, body: dict) -> dict: response = requests.post(SCANR + path, headers=SCANR_HEADERS, json=body, timeout=120) if response.status_code >= 400: # Hand the error to the model: a 400 names the field that is wrong. return {"error": response.status_code, "message": response.text[:500]} return response.json() def remember(payload: dict) -> str: payload_id = f"p{len(PAYLOADS) + 1}" PAYLOADS[payload_id] = payload return payload_id @beta_tool def natural_scan(description: str) -> str: """Turn a plain-English stock screen into a scan payload. Does not run it. Returns a payload_id and the reasoning (a summary and one line per filter). Show the summary to the user before running the scan. Args: description: The screen in plain English, with concrete thresholds where known, e.g. "market cap over $5B, above the 200-day moving average, RSI(14) under 35". """ result = scanr("/scan/natural", {"query": description}) if "error" in result or not result.get("conversion_success"): return json.dumps({"error": result.get("message") or result.get("validation_errors")}) return json.dumps({ "payload_id": remember(result["generated_payload"]), "reasoning": result.get("reasoning"), }) @beta_tool def refine_scan(payload_id: str, change: str) -> str: """Change an existing scan payload in plain English, keeping its other filters. Returns a new payload_id and the updated reasoning. Args: payload_id: The id returned by natural_scan or an earlier refine_scan. change: What to change, e.g. "only large caps" or "use the weekly chart". """ if payload_id not in PAYLOADS: return json.dumps({"error": f"unknown payload_id {payload_id}"}) result = scanr("/scan/refine", { "existing_payload": PAYLOADS[payload_id], "refinement_query": change, }) if "error" in result or not result.get("refinement_success"): return json.dumps({"error": result.get("message") or result.get("validation_errors")}) return json.dumps({ "payload_id": remember(result["refined_payload"]), "reasoning": result.get("reasoning"), }) @beta_tool def run_scan(payload_id: str) -> str: """Run a scan payload over the whole U.S. market. Returns the match count and up to 50 matching tickers, largest first. Args: payload_id: The id of the payload to run. """ if payload_id not in PAYLOADS: return json.dumps({"error": f"unknown payload_id {payload_id}"}) result = scanr("/scan", {"instructions": PAYLOADS[payload_id]}) if "error" in result: return json.dumps(result) tickers = result.get("tickers") or [] return json.dumps({"count": result["count"], "tickers": tickers[:50]}) @beta_tool def relative_strength(tickers: list[str]) -> str: """Latest relative-strength percentile (1-99, 99 strongest) for up to 50 tickers, against the market, the sector and the industry. Use it to rank scan results. Args: tickers: Ticker symbols, e.g. ["NVDA", "AMD"]. """ since = int((time.time() - 5 * 86400) * 1000) result = scanr("/stocks/get-stock-indicators", {"tickers": tickers[:50], "from": since, "limit": 500}) if "error" in result: return json.dumps(result) latest: dict[str, dict] = {} for row in result.get("results") or []: if row["ticker"] not in latest or row["date"] > latest[row["ticker"]]["date"]: latest[row["ticker"]] = row return json.dumps({ t: {k: row.get(k) for k in ("rs_score", "rs_sector_score", "rs_industry_score", "sector")} for t, row in latest.items() }) SYSTEM = """You are a stock-screening assistant with access to scanr.ai. To answer a screening question: 1. Call natural_scan with the user's criteria, stated concretely. 2. Tell the user in one or two sentences what the scan checks (from the reasoning). If it misread the request, fix it with refine_scan rather than starting over. 3. Call run_scan. If it matches nothing, loosen the strictest filter with refine_scan and say what you changed. If it matches hundreds, tighten it. 4. When ranking or comparing matches, call relative_strength on the top results. 5. Answer with the count, the most relevant tickers and why they qualify. Scans read live data: during U.S. market hours, daily indicators include today's unfinished bar, so say results are provisional. You screen; you don't give investment advice.""" client = anthropic.Anthropic() def ask(question: str) -> str: runner = client.beta.messages.tool_runner( model="claude-opus-5-5", max_tokens=16000, system=SYSTEM, tools=[natural_scan, refine_scan, run_scan, relative_strength], output_config={"effort": "medium"}, # If a request is declined by a safety classifier, retry it on the # recommended fallback model instead of stopping. betas=["server-side-fallback-2026-07-01"], fallbacks="default", messages=[{"role": "user", "content": question}], ) final = runner.until_done() if final.stop_reason == "refusal": return "The request was declined." return "".join(block.text for block in final.content if block.type == "text") if __name__ == "__main__": print(ask( "Find large caps (over $10B) that are above their 200-day moving average " "but pulled back to an RSI under 40. Rank the top ones by relative strength." )) ``` Run it with `SCANR_API_TOKEN` and `ANTHROPIC_API_KEY` set. A typical run makes one `natural_scan` call, sometimes a `refine_scan`, one `run_scan` and one `relative_strength` call: a few scanr.ai requests and a few thousand AI credits. The `relative_strength` tool needs a Power User or Business token; on Starter, leave it out. ### Adapting it - **Other frameworks.** The four functions are plain HTTP calls. Wrap them as tools in any agent framework, or generate them from the [OpenAPI spec](https://api-docs.scanr.ai/openapi.json). - **More tools.** Useful additions are market status (`GET /misc/market-status`), security search (`GET /stocks/search-securities`) for resolving company names, theme performance (Business) for *"which groups are leading?"*, and saving a scan (`POST /scan/save-filters`) so the user can follow it. - **Payloads from the scanner.** Users often have the screen already. Add an `import_scan` tool that takes JSON the user exported from the scanr.ai scanner (**Filters → Export filters**), stores it with `remember()`, and returns a payload id. Refine and run it like a generated one, with no AI credits. - **Hand-written payloads.** If you want the model to write payloads directly (for full control, or to skip AI credits), give it [Building Scan Payloads](https://api-docs.scanr.ai/scan-payloads.md) as context and a `run_payload` tool that accepts JSON, and return the API's `400` messages to it verbatim. It works, but expect more retries than with `natural_scan`. - **Scheduled agents.** Run `ask()` from a scheduler after the close for a daily written briefing; see [Run an End-of-Day Screen](https://api-docs.scanr.ai/guides/end-of-day-screen.md). ## What makes these agents reliable - **One scan, not a crawl.** Agents left alone sometimes loop over tickers calling per-stock endpoints. The system prompt and tool descriptions steer them to scan first; keep it that way. - **Show the screen before the result.** Having the agent say what the scan checks catches misreadings ("small caps" read as under $2B when the user meant under $300M) before they turn into confident wrong answers. - **Treat errors as instructions.** Return scanr.ai's error text to the model. A `400` names the bad field; a `429` means stop and wait. - **Give it its own token.** A dedicated, `read`-only token makes the agent's usage visible under API Usage and easy to revoke. ## Next - [MCP Server](https://api-docs.scanr.ai/mcp-server.md): The no-code route. - [Docs for Agents](https://api-docs.scanr.ai/agents.md): llms.txt, markdown pages, the OpenAPI spec and the Agent Skill. - [Natural-Language Scans](https://api-docs.scanr.ai/natural-language-scans.md): The endpoints behind `natural_scan` and `refine_scan`.