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.
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.
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
{
"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
{
"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:
{ "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_SENTISENSEis the holdings-weighted Score across what the fund actually owns. This is the one you usually want.DIRECT_SENTISENSEis 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.
{
"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_MEANis inverted. It is the vendor's 1-to-5 scale where 1.0 is strong buy and 5.0 is strong sell. Bullish isLTE 2.5, notGTE. PreferANALYST_BUY_RATIO_PCT, which runs the intuitive direction.MA_CROSS_STATEis ordinal, not a percentage:1golden cross (50-day above 200-day),-1death cross,0neither. UseEQ.SENTIMENT_DIRECTIONis the sign of the 7-day Score with a neutral band:1above +5,-1below -5,0in between. Despite the name it is not sentiment polarity, and0is 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.
{
"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.
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)
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.
Try It
Test endpoints directly from your browser. Paste your API key once: it's saved locally and shared across all widgets. Get a free key
POST/api/v1/screener/execute
Run a screen against the stock universe
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
"https://app.sentisense.ai/api/v1/screener/execute"POST/api/v1/screener/etfs/execute
Run a screen against the ETF universe
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
"https://app.sentisense.ai/api/v1/screener/etfs/execute"GET/api/v1/screener/fields
Every filterable field, with units and operators
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
"https://app.sentisense.ai/api/v1/screener/fields"GET/api/v1/screener/screens
The curated screens we ship in the product
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
"https://app.sentisense.ai/api/v1/screener/screens"