# ETFs API

> ETF discovery, holdings composition, and holdings-weighted aggregates: analyst consensus, insider activity, and sentiment derived from fund constituents.

Base URL: `https://app.sentisense.ai/api/v1/etfs`
Tier: Free (full data on every tier; PRO lifts rate limits and monthly quota)

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#etfs-beta) · [Node](https://github.com/SentiSenseApp/sentisense-node#etfs-beta)

## Overview

The ETFs API exposes fund-level data for the exchange-traded funds SentiSense tracks. Two flavors of endpoints:

1. **Composition data** (`/`, `/{ticker}/holdings`), the issuer-level facts: which ETFs exist, what each one holds, when the composition was last refreshed.
2. **Holdings-weighted aggregates** (`/{ticker}/aggregates/...`), synthesized fund-level views derived from each constituent's per-stock data, weighted by allocation. Three rollups today: analyst consensus, insider activity, and SentiSense sentiment.

**Why aggregates matter:** funds aren't rated by sell-side analysts, don't have insiders of their own, and may not get many direct news mentions. But the companies *inside* a fund do. The aggregate endpoints surface "what the underlying constituents are signaling, weighted by how much of the fund they represent": a view that's genuinely useful for ETF research and isn't published anywhere else.

**Coverage transparency:** every aggregate response carries a `coverage` block (`holdingsCount`, `holdingsCovered`, `weightCovered`, `partial`, `totalKnownHoldings`) so consumers can interpret the headline number with the right confidence. When fewer than ~30% of fund AUM has the underlying data (e.g. foreign-listed funds like VEA/VWO where most holdings have no per-stock coverage), the endpoint returns 404 rather than publish a misleading aggregate.

**Coverage note:** ETF coverage spans a growing set of widely-traded funds (SPY, QQQ, IWM, VOO, VTI, the major SPDR sector ETFs, etc.). Expect coverage and aggregate freshness to improve over time.

**Options on ETFs:** end-of-day option chain analytics are published for tracked ETFs too (IV rank, put/call percentile, 25-delta skew, open-interest walls, max pain, unusual contracts), but they live on the [Options Intelligence API](/docs/api/options/) rather than here: `GET /api/v1/stocks/{ticker}/options/summary` works with an ETF ticker such as `SPY` or `GLD`. The market-wide `/api/v1/options/overview` radar also carries an ETF board in `data.etfRows`, ranked separately from the stock board in `data.rows`.

**Access:** all endpoints require an API key, and every API caller receives the full response (including `topContributors` on the aggregate endpoints). PRO and FREE differ only in per-minute rate limits and monthly quota. The list endpoint (`GET /`) does not consume monthly quota; every other endpoint does.

---

## GET /

Returns every ETF SentiSense tracks. Sorted by ticker for stable client-side caching.

**Authentication:** API key required (no quota cost) | **Parameters:** None

**Example Request:**

```bash
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
  "https://app.sentisense.ai/api/v1/etfs"
```

**Response:** array of ETF info objects.

| Field | Type | Description |
|-------|------|-------------|
| `ticker` | string | Fund ticker (e.g. `QQQ`) |
| `name` | string | Official fund name |
| `kbEntityId` | string | Ontology entity ID (e.g. `kb/etf/22`) |
| `urlSlug` | string | URL-friendly slug for the fund detail page |
| `issuer` | string | Fund issuer (e.g. `Invesco`, `Vanguard`, `iShares`) |
| `trackedIndex` | string \| null | Reference index or strategy description |
| `assetClass` | string \| null | One of `Equity`, `Bond`, `Commodity`, etc. |
| `imageUrl` | string \| null | Square presentation image for the fund, keyed to its issuer. Same shape as `logoUrl`/`iconUrl` on a stock, so one avatar slot renders both |

---

## GET /{ticker}/holdings

Returns the full composition of an ETF: which stocks it holds, each holding's weight, and freshness metadata.

**Authentication:** API key required

**Parameters:**

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `ticker` | path | Yes | - | ETF ticker (e.g. `QQQ`) |

**Example Request:**

```bash
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
  "https://app.sentisense.ai/api/v1/etfs/QQQ/holdings"
```

**Response object:**

| Field | Type | Description |
|-------|------|-------------|
| `ticker` | string | Fund ticker |
| `issuer` | string | Fund issuer |
| `issuerEndpoint` | string | Data source attribution, always `SentiSense` |
| `asOfDate` | string | Composition snapshot date from the issuer (ISO date `YYYY-MM-DD`) |
| `fetchedAt` | long \| null | When SentiSense refreshed the composition (epoch seconds) |
| `nextRefreshDue` | string | When the composition is scheduled to be refreshed next (ISO date `YYYY-MM-DD`) |
| `totalHoldings` | int | Number of holdings in the visible composition |
| `holdings` | array | Per-holding rows (see below) |
| `partial` | boolean \| null | `true` when the composition is a top-N view rather than the full fund |
| `totalKnownHoldings` | int \| null | Issuer's reported total holdings when `partial=true` |

**Holding object:**

| Field | Type | Description |
|-------|------|-------------|
| `ticker` | string | Constituent symbol exactly as the fund's issuer filed it. For an international fund this is the local exchange code, not a US ticker. Unchanged, so existing callers keep the value they have always had, but read `linkedTicker` when resolving a holding to a stock. |
| `name` | string | Constituent company name |
| `weightPct` | decimal | Holding weight in the fund, expressed as a percentage (0–100) |
| `firstSeen` | string \| null | First date this holding appeared in the composition (ISO date `YYYY-MM-DD`) |
| `exchange` | string \| null | Listing venue the issuer reported for this position, verbatim (e.g. `NYSE`, `SIX Swiss Exchange`, `Tokyo Stock Exchange`). `null` when the fund's data source carries no venue column. |
| `localTicker` | string \| null | The symbol as listed on `exchange`. Same value as `ticker`, named so it is clear the symbol is venue-local and may not identify a US company. |
| `linkedTicker` | string \| null | The US stock symbol this holding resolves to, or `null` when it cannot be resolved to one. |

**Resolving a holding to a stock: use `linkedTicker`, not `ticker`.** International funds file their constituents under the local exchange symbol, and some of those collide with unrelated US tickers: iShares MSCI EAFE files `ROP` for Roche on the SIX Swiss Exchange, which is Roper Technologies in the US. `linkedTicker` is `null` whenever that resolution cannot be made safely, so treat a `null` as "do not link this row", not as missing data. The three venue fields are a recent addition; `ticker`, `name`, `weightPct` and `firstSeen` are unchanged.

Returns 404 if the ETF isn't tracked or no holdings file is available (commodity-only funds like GLD have no equity holdings).

---

## GET /{ticker}/quote

Returns a single-call aggregate snapshot for the ETF detail page: the latest price (delayed 15 minutes) + today's OHLC + 52-week range + trailing-12-month dividend yield + ETF fundamentals (AUM, expense ratio, NAV, inception date). Peer of [GET /stocks/{ticker}/quote](/docs/api/stocks/#get-quote) for fund tickers.

**Authentication:** API key required | **Rate limit:** standard quota

**Parameters:**

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `ticker` | path | Yes | - | ETF ticker (e.g. `VTI`) |

**Example Request:**

```bash
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
  "https://app.sentisense.ai/api/v1/etfs/VTI/quote"
```

**Response object:**

| Field | Type | Description |
|-------|------|-------------|
| `ticker` | string | ETF ticker |
| `currentPrice` | number | Regular-session price, delayed 15 minutes. During RTH (09:30 to 16:00 ET): the most recent regular-session value. Otherwise: most recent regular-session close. |
| `change` | number | `currentPrice` change vs `previousClose` |
| `changePercent` | number | `currentPrice` change vs `previousClose`, in percentage points (`-1.27` means -1.27%) |
| `volume` | number | Volume for the current session |
| `open` | number | Opening price for the current session |
| `dayHigh` | number | Intraday high |
| `dayLow` | number | Intraday low |
| `previousClose` | number | Previous session close |
| `week52High` | number | 52-week high |
| `week52Low` | number | 52-week low |
| `dividendYield` | number | Trailing-12-month distribution yield (decimal, e.g. `0.0104` for 1.04%) |
| `aum` | number | Assets under management, USD. ETF analogue of `marketCap` on the stock quote. |
| `expenseRatio` | number | Annual fund expense ratio (decimal, e.g. `0.0003` for 0.03%) |
| `nav` | number | Net asset value per share, USD |
| `inceptionDate` | string | Fund inception date (ISO `YYYY-MM-DD`) |
| `timestamp` | number | When this response was served (epoch milliseconds). Not the age of the price. |
| `priceAsOf` | number | When the market data behind `currentPrice` is from (epoch milliseconds). This is the field to read for freshness. Omitted outside regular hours, and whenever the upstream data is not dated, so treat its absence as "unknown" rather than "fresh". |
| `extendedHours` | object | Extended-hours view (pre-market or after-hours). Absent during RTH, overnight, and weekends. Shape: `{ session, price, change, changePercent }`. |

Every field except `ticker` and `timestamp` is omitted from the response when its value is unknown; fields are never sent as `null`. Render "--" or hide the row when a field is absent.

**Example response:**

```json
{
  "ticker": "VTI",
  "currentPrice": 363.74,
  "change": -4.66,
  "changePercent": -1.27,
  "volume": 3500000,
  "open": 364.53,
  "dayHigh": 364.93,
  "dayLow": 362.28,
  "previousClose": 367.40,
  "week52High": 368.25,
  "week52Low": 283.00,
  "dividendYield": 0.0104,
  "aum": 2200000000000,
  "expenseRatio": 0.0003,
  "nav": 362.68,
  "inceptionDate": "2001-05-24",
  "timestamp": 1747535400000
}
```

**Stock tickers:** This endpoint is ETF-only. Calling it with a stock ticker (e.g. `AAPL`) returns `400 ticker_is_not_etf` with a pointer to [GET /stocks/{ticker}/quote](/docs/api/stocks/#get-quote) which returns market cap, P/E, and EPS instead of AUM and expense ratio.

**Rate-limit note:** Cached for 15 seconds server-side. Each call still counts toward your monthly quota.

---

## GET /{ticker}/aggregates/analyst

Returns a holdings-weighted analyst consensus for an ETF, derived from each covered constituent's per-stock analyst coverage. Math: weight × per-stock upside, renormalized to the covered subset.

**Authentication:** API key required. Returns the full response (including `topContributors`) to every API caller; PRO and FREE differ only in per-tier rate limits and monthly quota.

**Parameters:**

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `ticker` | path | Yes | - | ETF ticker |

**Example Request:**

```bash
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
  "https://app.sentisense.ai/api/v1/etfs/QQQ/aggregates/analyst"
```

```python
from sentisense import SentiSenseClient

client = SentiSenseClient(api_key="ss_live_YOUR_KEY")
result = client.get_etf_analyst_aggregate("QQQ")
agg = result.data
print(f"QQQ weighted 12mo upside: {agg['weightedConsensus']['upsidePercent']}%")
print(f"Coverage: {agg['coverage']['holdingsCovered']} of {agg['coverage']['holdingsCount']} holdings"
      f" ({agg['coverage']['weightCovered']}% AUM)")
```

**Response Schema:**

| Field | Type | Description |
|-------|------|-------------|
| `isPreview` | boolean | Always `false` on the aggregate endpoints (every API caller gets the full response); the wrapper is kept for consistency with the rest of the API |
| `previewReason` | string | Always `null` on the aggregate endpoints |
| `data` | object | Aggregate (see below) |

**Aggregate object:**

| Field | Type | Description |
|-------|------|-------------|
| `ticker` | string | ETF ticker |
| `asOfDate` | string | Composition snapshot date the rollup was computed against (ISO date `YYYY-MM-DD`) |
| `computedAt` | long | Epoch seconds when this rollup was computed |
| `coverage` | object | Coverage block (see below) |
| `weightedConsensus` | object | Weighted consensus (see below) |
| `topContributors` | array | Top 10 contributors by absolute contribution to the weighted upside |

**Coverage object** (same shape on all three aggregate endpoints):

| Field | Type | Description |
|-------|------|-------------|
| `holdingsCount` | int | Holdings present in `holdings.json` |
| `holdingsCovered` | int | Holdings that had per-stock data and were included in the aggregate |
| `weightCovered` | decimal | Sum of weights (0–100) for the covered holdings |
| `partial` | boolean \| null | `true` when the underlying composition is a top-N view |
| `totalKnownHoldings` | int \| null | Issuer's true holdings count when `partial=true` |

**Weighted consensus object:**

| Field | Type | Description |
|-------|------|-------------|
| `upsidePercent` | decimal | Weighted 12-month upside as a percentage (e.g. `12.4` = +12.4%) |
| `consensusLabel` | string | Most-weighted bucket: `BUY` / `HOLD` / `SELL` |
| `distribution` | object | Fractions of covered AUM in each bucket: `{ "BUY": 0.62, "HOLD": 0.31, "SELL": 0.07 }` |
| `totalAnalysts` | int | Sum of analyst counts across covered holdings |

**Contributor object:**

| Field | Type | Description |
|-------|------|-------------|
| `ticker` | string | Constituent ticker |
| `weightPct` | decimal | Holding weight in the fund (0–100) |
| `upsidePercent` | decimal | Per-stock analyst upside |
| `consensusLabel` | string | Per-stock consensus bucket |
| `contributionPp` | decimal | Signed contribution to the fund's weighted upside, in percentage points |

Returns 404 with `{error, reason, message}` when the ticker isn't an ETF or when coverage is too low to publish a defensible aggregate (typically foreign-listed funds where most holdings lack per-stock data).

---

## GET /{ticker}/aggregates/insider

Returns a holdings-weighted insider activity aggregate for an ETF: net dollar flow, buy/sell split, and per-holding contributors over a configurable trailing window.

**Authentication:** API key required. Returns the full response (including `topContributors`) to every API caller; PRO and FREE differ only in per-tier rate limits and monthly quota.

**Parameters:**

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `ticker` | path | Yes | - | ETF ticker |
| `lookbackDays` | int | No | 30 | Trailing days of insider trades to include (typical values: 30, 90) |

**Example Request:**

```bash
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
  "https://app.sentisense.ai/api/v1/etfs/ARKK/aggregates/insider?lookbackDays=90"
```

```python
result = client.get_etf_insider_aggregate("ARKK", lookback_days=90)
flow = result.data["weightedNetFlow"]
print(f"Net insider flow: ${flow['netDollars']:,} ({flow['buyTradeCount']} buys, {flow['sellTradeCount']} sells)")
```

**Aggregate object:**

| Field | Type | Description |
|-------|------|-------------|
| `ticker` | string | ETF ticker |
| `asOfDate` | string | Composition snapshot date (ISO date `YYYY-MM-DD`) |
| `computedAt` | long | Epoch seconds when this rollup was computed |
| `lookbackDays` | int | Window included in the trade aggregation |
| `coverage` | object | Coverage block (same shape as analyst aggregate) |
| `weightedNetFlow` | object | Aggregated flow (see below) |
| `topContributors` | array | Top 10 contributors by absolute weighted-net-dollar contribution |

**Weighted net flow object:**

| Field | Type | Description |
|-------|------|-------------|
| `netDollars` | long | Weighted net dollar flow (buys − sells). Negative = net selling. |
| `buyDollars` | long | Weighted gross buy dollars |
| `sellDollars` | long | Weighted gross sell dollars |
| `buyTradeCount` | long | Total buy trades across covered holdings (unweighted, for context) |
| `sellTradeCount` | long | Total sell trades across covered holdings (unweighted, for context) |
| `distinctInsiderCount` | int | Distinct insiders across covered holdings |

**Contributor object:**

| Field | Type | Description |
|-------|------|-------------|
| `ticker` | string | Constituent ticker |
| `weightPct` | decimal | Holding weight in the fund (0–100) |
| `netDollars` | long | Per-stock net insider flow over the window (signed) |
| `weightedNetDollars` | long | Signed contribution to the fund's weighted net flow |
| `tradeCount` | int | Per-stock trade count over the window |

Returns 404 when no holdings have insider activity over the window or coverage is insufficient.

---

## GET /{ticker}/aggregates/sentiment

The constituent-weighted score is precomputed daily for a growing set of widely-traded funds (SPY, QQQ, VOO, VTI, IWM, major SPDR sector funds, and more) and is expanding as we ingest more ETF data. Returns 404 for funds outside the current coverage window.

Returns two complementary SentiSense Score readings for an ETF side-by-side:

- **Constituent-Weighted:** daily-precomputed weighted average across the fund's constituents (the new view).
- **Direct:** score derived from mentions of the fund's own ticker (the existing per-ETF `sentisense_score`).

These can diverge meaningfully: bull market chatter about a fund itself may not match the sentiment around its individual holdings, and the gap can be a signal.

**Authentication:** API key required. Response uses the standard `{isPreview, previewReason, data}` wrapper; the headline is the same for FREE and PRO (no per-holding contributors are included in v1).

**Parameters:**

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `ticker` | path | Yes | - | ETF ticker |

**Example Request:**

```bash
curl -H "X-SentiSense-API-Key: ss_live_YOUR_KEY" \
  "https://app.sentisense.ai/api/v1/etfs/QQQ/aggregates/sentiment"
```

```python
result = client.get_etf_sentiment_aggregate("QQQ")
agg = result.data
print(f"Constituent-weighted: {agg['constituentsWeighted']['sentiSenseScore']} ({agg['constituentsWeighted']['scoreLabel']})")
if agg.get("direct"):
    print(f"Direct (mentions of QQQ itself): {agg['direct']['sentiSenseScore']} ({agg['direct']['scoreLabel']})")
```

**Aggregate object:**

| Field | Type | Description |
|-------|------|-------------|
| `ticker` | string | ETF ticker |
| `asOfDate` | string \| null | Composition snapshot date the weighted view was computed against (ISO date `YYYY-MM-DD`) |
| `computedAt` | long | Epoch seconds when this aggregate was assembled |
| `coverage` | object | Coverage block (same shape as analyst aggregate) |
| `constituentsWeighted` | object | Weighted reading (see below) |
| `direct` | object \| null | Direct reading; `null` for low-mention funds with no daily score |

**Reading object** (used for both `constituentsWeighted` and `direct`):

| Field | Type | Description |
|-------|------|-------------|
| `sentiSenseScore` | decimal | SentiSense Score reading (signed; positive = bullish conviction, negative = bearish, magnitude = strength of the read) |
| `scoreLabel` | string | Coarse-grained label: `BULLISH` / `NEUTRAL` / `BEARISH` |
| `asOfTimestamp` | long | Epoch seconds when the underlying metric was produced |

Returns 404 when the ticker isn't an ETF or the constituent-weighted metric hasn't been produced yet (brand-new ETFs may show 404 until the next daily pipeline run).

---

## Errors

| Status | Code | Description |
|--------|------|-------------|
| 400 | invalid_parameter | Invalid `ticker` or out-of-range `lookbackDays` |
| 401 | api_key_required | No API key on a programmatic call to an aggregate endpoint |
| 429 | quota_exceeded | Monthly request quota for your tier is used up |
| 404 | not_found | ETF not tracked, no composition data, or insufficient coverage for an aggregate |
