# Screener API

> Filter the tracked universe on the SentiSense Score, 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.

## 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 fields in the ETF universe (`ISSUER`, `ASSET_CLASS`, `TRACKED_INDEX`).

### 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": [ ... ] } }
```

## POST /etfs/execute

Identical request shape, run against the ETF universe. Use the ETF field names from the catalog below.

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.

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`, `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` |

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

- **`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 deliberately 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 stable and safe to persist. 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.
