# Screener API

> Filter the tracked universe on the SentiSense Score, the SentiSense Rating letter, attention, analyst consensus, technicals and price in a single query, and read the field catalog that backs it.

Base URL: `https://app.sentisense.ai/api/v1/screener`
Tier: Free (API key required)

All API access requires an API key via the `X-SentiSense-API-Key` header. Keys look like `ss_live_...`.

> SDKs: [Python](https://github.com/SentiSenseApp/sentisense#screener) · [Node](https://github.com/SentiSenseApp/sentisense-node) · example: [screener.ts](https://github.com/SentiSenseApp/sentisense-node/blob/main/examples/screener.ts)

The Screener runs a structured filter over the tracked universe and returns the matching rows in one response. It is the only endpoint where our own signals, the SentiSense Score and attention, are queryable in the same `WHERE` clause as analyst consensus, technicals and price.

That cross is the point. A screen on analyst ratings alone is something a dozen free tools already do. A screen on *analyst ratings where our Score disagrees* is not.

Comparing providers? See [the best stock screener APIs in 2026](/blog/best-stock-screener-apis-2026/).

## Authentication

Send your key in the `X-SentiSense-API-Key` header, same as every other endpoint.

```bash
curl -X POST https://app.sentisense.ai/api/v1/screener/execute \
  -H "X-SentiSense-API-Key: $SENTISENSE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "plan": { "filters": [ { "fieldName": "SENTI_SCORE_7D", "op": "GTE", "value": 13 } ] } }'
```

## The plan object

Every screen is a `plan`. The same shape works for both universes.

| Field | Type | Default | Notes |
|---|---|---|---|
| `universe` | `STOCK` / `ETF` | `STOCK` | Set by the endpoint you call; the body value is a no-op. |
| `filters` | array | required | ANDed together. There is no OR; run two screens and merge. |
| `sort` | object | none | `{ "fieldName": "...", "dir": "ASC" \| "DESC" }` |

Each filter is `{ "fieldName": "<FIELD>", "op": "<OP>", "value": <number> }`.

`limit` sits on the request body next to `plan`, not inside it, because a plan is a stored
object and paging is a transport concern. It defaults to 100 and caps at 500.

Operators: `GTE`, `LTE`, `GT`, `LT`, `EQ`, `NEQ`, `IN`, `NOT_IN`. `IN` / `NOT_IN` take a `values` array instead of `value` and are only meaningful on the string-typed fields: `SENTISENSE_RATING` in the stock universe, and `ISSUER`, `ASSET_CLASS`, `TRACKED_INDEX` in the ETF universe.

`value` is a JSON number, or a compact amount string with a `K`, `M`, `B` or `T` suffix in either case, which is expanded (`"500M"` is 500000000, `"1.2T"` is 1200000000000): the shorthand the dollar and count fields' catalog descriptions mention, expanded the same way on any numeric field. A numeric string such as `"10"` still reads as a number; a string that is neither is a `400 malformed_request` whose message names the key the clause's operator reads.

### Nulls never match

A row missing the field you filtered on is excluded, in both directions. `RETURN_1Y >= 0` and `RETURN_1Y < 0` do not partition the universe: a stock listed four months ago is in neither result. Sorting puts nulls last regardless of direction.

This matters more than it sounds. Coverage is not uniform: analyst fields are populated for roughly 97% of the universe, the 200-day technicals for the names with at least 200 trading days of history. If a screen returns fewer rows than you expect, check coverage before you check your thresholds.

## Reading the SentiSense Score

Most of the interesting screens filter on the Score, so it is worth being precise about what the number is.

The SentiSense Score is **not** sentiment polarity. Sentiment is a [-1, 1] polarity measure. The Score is an unbounded, volume-aware measure of directional conviction that currently runs roughly -30 to +45 across the tracked universe. The bands:

| Range | Reading |
|---|---|
| -5 to +5 | Neutral |
| +5 to +13 | Slightly bullish |
| +13 to +23 | Bullish |
| +23 and above | Strong |

Symmetric on the bearish side. Filter on the band edges (`5`, `13`, `23`), not on values like `0.5`, which are polarity-scale and will silently behave as "any positive score".

`SENTI_SCORE_7D` and `SENTI_SCORE_1M` are window averages. `SCORE_CHANGE_7D` is the 7-day score minus the 1-month baseline, so positive means strengthening against the longer window.

## POST /execute

Run a screen against the stock universe.

**Request**

```json
{
  "plan": {
    "filters": [
      { "fieldName": "SENTI_SCORE_7D", "op": "GTE", "value": 5 },
      { "fieldName": "ANALYST_BUY_RATIO_PCT", "op": "LTE", "value": 30 },
      { "fieldName": "ANALYST_COUNT", "op": "GTE", "value": 5 }
    ],
    "sort": { "fieldName": "SENTI_SCORE_7D", "dir": "DESC" }
  },
  "limit": 25
}
```

That is the "crowd is bullish, the street is not" screen. The `ANALYST_COUNT >= 5` leg is not decoration: coverage bottoms out at a single analyst, and a 0% buy ratio from one analyst is noise, not disagreement.

**Response**

```json
{
  "matched": 41,
  "limit": 25,
  "results": [
    {
      "ticker": "AAPL",
      "sentiSenseScore7D": 14.2,
      "sentiSenseScore1M": 9.8,
      "scoreChange7D": 4.4,
      "socialDominance": 0.031,
      "marketCap": 3120000000000,
      "currentPrice": 211.34,
      "changePercent": -0.62,
      "analystBuyRatioPct": 68.1,
      "analystTargetUpsidePct": 12.7,
      "analystCount": 47,
      "pctOff200dMa": 6.3,
      "maCrossState": 1,
      "return1Y": 18.4,
      "volatility30D": 22.9
    }
  ]
}
```

`matched` is the number of rows the plan actually matched, before `limit` was applied. It is there so truncation is visible: a capped list with no count is how a caller quietly concludes the universe is smaller than it is.

Every row carries the full field set, not just the fields you filtered on, so you can sort or post-process client side without a second call. Fields with no data for that ticker are `null`.

Rows also carry a few fields that are not filterable but are there for rendering: `week52High` and `week52Low` as raw prices, `lastUpdated` as epoch seconds, and three small series per ticker: `sentisenseScoreBars7D`, `sentisenseScoreBars30D`, and `priceSparkline30D`. A screen result is chartable as returned, with no second call per row.

**Optional ticker scope.** Pass a top-level `tickers` array alongside `plan` to run the screen against a watchlist instead of the whole universe:

```json
{ "tickers": ["NVDA", "AMD", "AVGO"], "plan": { "filters": [ ... ] } }
```

**Validation.** The plan is checked before it runs: an unrecognized `fieldName` in `filters` or `sort` returns HTTP 400 with `{ error, field, message }`, where `message` names the bad field and lists the valid ones for this universe. Field names are case-sensitive; take them from `GET /fields` rather than guessing.

## POST /etfs/execute

Identical request shape, run against the ETF universe. Use the ETF field names from the catalog below. The same 400 validation applies, against the ETF field catalog.

ETFs carry two distinct Score fields and they answer different questions:

- `CONSTITUENTS_WEIGHTED_SENTISENSE` is the holdings-weighted Score across what the fund actually owns. This is the one you usually want.
- `DIRECT_SENTISENSE` is the Score from chatter about the ticker itself. On a widely-traded index fund that is mostly macro noise.

`WEIGHT_COVERED_PCT` tells you how much of the fund's weight had constituent data behind the weighted number. A weighted Score over thin coverage is not wrong so much as under-evidenced.

## GET /fields

Returns the full catalog: every filterable field, its group, its unit, the operators it accepts, and a human description. Build your filter UI from this rather than hardcoding the list, and you inherit new fields as we ship them.

```json
{
  "stock": [
    {
      "name": "SENTI_SCORE_7D",
      "label": "SentiSense 7D",
      "group": "Sentiment",
      "type": "NUMBER",
      "unit": "SCORE",
      "ops": ["GTE", "GT", "LTE", "LT"],
      "sortable": true,
      "description": "7-day SentiSense score. Above +5 is bullish, +13 strongly so; below -5 is bearish."
    }
  ],
  "etf": [ ... ]
}
```

The string-typed ETF fields (`ISSUER`, `ASSET_CLASS`, `TRACKED_INDEX`) come back with a `values` array populated from the live universe, so the pickers stay current without a redeploy. `SENTISENSE_RATING` also ships a `values` array, but a fixed one: its five letters are a closed vocabulary rather than something the data discovers.

Two fields are `type: "ENUM"` rather than `NUMBER`: `SENTIMENT_DIRECTION` and `MA_CROSS_STATE`. An ENUM descriptor carries an `options` array of `{ "value", "label" }` pairs and accepts only `EQ`, so you can render the choices verbatim instead of guessing what the integers mean.

### Stock fields

| Group | Fields |
|---|---|
| Sentiment | `SENTI_SCORE_7D`, `SENTI_SCORE_1M`, `SENTISENSE_RATING`, `SCORE_CHANGE_7D`, `SENTIMENT_DIRECTION`, `SENTI_SCORE_TREND_7D`, `SENTI_SCORE_TREND_30D`, `SENTI_SCORE_RISING_STREAK_30D` |
| Popularity | `SOCIAL_DOMINANCE`, `MENTION_SHARE`, `MENTION_VELOCITY`, `DOMINANCE_CHANGE` |
| Price & size | `MARKET_CAP`, `PRICE`, `CHANGE_PERCENT`, `CHANGE`, `VOLUME`, `PCT_OFF_52W_HIGH`, `PCT_OFF_52W_LOW`, `PRICE_TREND_30D` |
| Analyst | `ANALYST_BUY_RATIO_PCT`, `ANALYST_TARGET_UPSIDE_PCT`, `ANALYST_COUNT`, `ANALYST_RATING_MOMENTUM_30D`, `ANALYST_RATING_MEAN` |
| Technical | `PCT_OFF_200D_MA`, `PCT_OFF_50D_MA`, `MA_CROSS_STATE`, `RETURN_1M`, `RETURN_3M`, `RETURN_6M`, `RETURN_1Y`, `VOLATILITY_30D` |

Four of these have semantics worth stating outright, because guessing them wrong produces a screen that looks fine and means nothing:

- **`SENTISENSE_RATING` is a letter, and the letter is a band of a score.** It is the one string-typed field in the stock universe: values `A`, `B`, `C`, `D`, `F`, operators `IN` and `NOT_IN` only, with the letters in a `values` array. It is sortable, and sorting resolves to the underlying 0 to 100 score rather than the letter's alphabetical order, so the order inside a letter is defined and `DESC` puts the highest score first. The `/stocks` table sorts its Rating column the same way. Rows carry `ratingLetter`, `ratingScore` and `ratingPercentile` back: the score is the headline, the percentile the rank it was built from before risk adjustments. Unrated stocks match no letter, so `NOT_IN ["F"]` is not the complement of `IN ["F"]`. Full derivation on the [SentiSense Rating API](/docs/api/sentisense-rating/) page.
- **`ANALYST_RATING_MEAN` is inverted.** It is the vendor's 1-to-5 scale where **1.0 is strong buy** and 5.0 is strong sell. Bullish is `LTE 2.5`, not `GTE`. Prefer `ANALYST_BUY_RATIO_PCT`, which runs the intuitive direction.
- **`MA_CROSS_STATE` is ordinal**, not a percentage: `1` golden cross (50-day above 200-day), `-1` death cross, `0` neither. Use `EQ`.
- **`SENTIMENT_DIRECTION` is the sign of the 7-day Score with a neutral band**: `1` above +5, `-1` below -5, `0` in between. Despite the name it is not sentiment polarity, and `0` is a real bucket, not an exact-zero match: most of the universe sits in the neutral band on a typical day.

`ANALYST_COUNT` is the sum of the rating buckets, which is not the vendor's own analyst count: those two populations disagree for most tickers because the target-price panel and the ratings panel are not the same set of analysts.

### ETF fields

| Group | Fields |
|---|---|
| Sentiment | `CONSTITUENTS_WEIGHTED_SENTISENSE`, `DIRECT_SENTISENSE` |
| Analyst | `WEIGHTED_ANALYST_UPSIDE` |
| Price & size | `MARKET_CAP` (AUM), `EXPENSE_RATIO`, `CURRENT_PRICE`, `CHANGE_PERCENT`, `PRICE_CHANGE`, `VOLUME`, `PCT_OFF_52W_HIGH`, `PCT_OFF_52W_LOW` |
| Coverage | `WEIGHT_COVERED_PCT`, `HOLDINGS_COUNT` |
| Profile | `ISSUER`, `ASSET_CLASS`, `TRACKED_INDEX` |

## GET /screens

The curated screens we ship in the product, each with a plan you can execute directly or use as a starting point.

```json
{
  "screens": [
    {
      "id": "crowd-vs-street",
      "name": "Crowd vs Street",
      "summary": "Bullish 7-day SentiSense Score where 30% or fewer analysts rate it a buy",
      "plan": { "filters": [ ... ], "sort": { ... } }
    }
  ]
}
```

Two conventions in the names, worth knowing because they are load-bearing: `+` means both conditions hold, `vs` means the two sides disagree.

Screen `id` values are never renamed or reused, but a screen can be retired: a persisted id may eventually stop appearing in this list, so handle a missing id gracefully. The `name` and `summary` are display copy and may be revised.

Every filter and sort in a curated plan carries `fieldName`, in both universes, so a typed client can read one key and be done. Some stock plans also carry a legacy `field` key holding the same name. Ignore it if you like; `fieldName` is the one to read, and it is the one to write. `field` stays accepted on input indefinitely, so older saved plans keep executing verbatim.

### Run a curated screen with the SDKs

The same round-trip in both SDKs: list the screens, pick one, execute its plan as-is. Install with `pip install sentisense` or `npm install sentisense`.

```python
from sentisense import SentiSenseClient

client = SentiSenseClient(api_key="ss_live_...")

screens = client.list_screens()
screen = next(s for s in screens if s.id == "oversold-with-positive-sentiment")
result = client.run_screen(plan=screen.plan, limit=10)

print(f"{screen.name}: {result.matched} matches")
for row in result.results:
    print(row.ticker, row.sentiSenseScore7D, row.pctOff52wHigh)
```

```typescript
import SentiSense from "sentisense";

const client = new SentiSense({ apiKey: "ss_live_..." });

const { screens } = await client.screener.screens();
const screen = screens.find(s => s.id === "oversold-with-positive-sentiment");
const result = await client.screener.run({ plan: screen.plan, limit: 10 });

console.log(`${screen.name}: ${result.matched} matches`);
for (const row of result.results) console.log(row.ticker, row.sentiSenseScore7D, row.pctOff52wHigh);
```

## Freshness

Screens read a snapshot that refreshes every 20 minutes. The Score windows behind it are daily. This is not a real-time quote feed and should not be polled per second; for live prices use the quotes endpoints on the Stocks API.

## Limits

Rate limited per minute like the rest of the API: 300/min on PRO, 30/min on Free. A screen is a single request no matter how many rows it returns, so prefer one broad screen plus client-side slicing over many narrow ones.
