---
name: uw-dashboard
description: Build a single-file positioning dashboard for any ticker from Unusual Whales data. Asks the person which parts of the picture they care about, then builds only those: dealer gamma, option flow, dark pool, implied volatility, biggest contracts, sector comparison. Use when someone asks for a dashboard, a positioning view, a deep dive, or "show me what's going on with <TICKER>".
---

# Unusual Whales Positioning Dashboard

Build one self-contained HTML page about a single company, from live Unusual Whales data, with no network access in the finished file.

**You are not filling an order. You are asking someone what they want and then building it.**
The person reading this may never have built anything before.
Check the prerequisites yourself rather than assuming them, ask what belongs on the page before you spend a single call, and explain what you are doing as you go.
The page they end up with should be the one they chose.

---

## 0. Before you pull anything

Three checks, in order.
Stop at the first failure and give them the fix, which in every case takes under a minute.

**Check 1: is the Unusual Whales connector available?**
Confirm you can see tools named `get_company_info`, `get_gex_levels` and `get_greek_exposure_by_strike`.
If not, the connector is not added.
Send them to `https://unusualwhales.com/public-api/mcp`, which walks through setup with a short video for Claude and one for Codex.
Warn them the connector URL carries their API key, so the whole string is a password and belongs in the app's settings and nowhere else.
Never ask them to paste the key or the connector URL into the conversation.
If they do it anyway, tell them to rotate it.

**Check 2: can you write files where they will find them?**
You need a folder.
If you do not have one, tell them to use the add-folder button and pick or create an empty folder.
Never write the dashboard somewhere they cannot open it.

**Check 3: is there a ticker, and does it have options?**
If they did not name one, ask.
Confirm it with `get_company_info` before spending anything else.
If the symbol does not resolve, say so and ask for another rather than guessing what they meant.
Then read its `has_options` flag before any other call.
If it is false, tell them the ticker has no listed options and ask for another.
Keep `price`, `sector` and `next_earnings_date` from that response: later panels need them.
`search_tickers` returns empty for some recently listed names that `get_company_info` resolves fine, so treat `get_company_info` as the authority.

---

## 1. Ask what they want on it

**Ask this once, before pulling anything, and wait for an answer.**

> Which of these do you want on the dashboard? Pick as many as you like, or say "everything".
>
> 1. **Dealer positioning**: where market makers are long and short gamma, and the strike levels that tend to hold price
> 2. **Option flow**: the day's buying and selling pressure minute by minute, and which expiries the premium is aimed at
> 3. **Dark pool**: where shares actually changed hands off exchange, and the biggest blocks
> 4. **Volatility**: the implied volatility curve, and whether an event is priced in
> 5. **Biggest contracts**: the individual option contracts carrying the most money today
> 6. **How it compares**: where this name sits against the rest of its sector

How to ask it:

- **Ask once.** One question with the whole menu. Never interrogate them panel by panel.
- **If the app's multiple-choice screen holds fewer than six options**, split the menu into two groups of three: dealer positioning, option flow and dark pool first, then volatility, biggest contracts and how it compares. Show each option's call count.
- **Plain English, never tool names.** They are choosing a subject, not an endpoint. Nobody outside this file knows what `get_greek_exposure_by_strike` is.
- **"Everything" is a valid answer**, and so is naming a ticker with no preference at all. Take all six, say that is what you are doing, and move on. Do not ask twice.
- **Quote the cost before you spend it.** "That's ten calls." A full six-choice build is 14 or 15 calls: 15 when their ticker is not among the sector's names shown.
- **Confirm the picks back in one line** before building: "Building dealer positioning, option flow and volatility for NVDA. Ten calls." This is the moment they see it is their page and not a template.
- **If they pick one thing, build one thing, properly.** Never pad the page out to five panels because five looks better. A one-panel dashboard that answers their question is the correct output.

---

## 2. Pull only what they picked

Always, regardless of choices:

| Purpose                                                   | Tools                                                                                                                 | Calls  |
| --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------ |
| Name, live price, sector, options check, next earnings    | `get_company_info`, already made in check 3                                                                           | 1      |
| Previous close and the day's share volume, for the header | `get_stock_screener` (`ticker`: their symbol). Skip it when "How it compares" already returned a row for their ticker | 0 or 1 |

Then, for each choice:

| Choice             | Tools                                                                                                                                                                                                                                                                  | Calls |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
| Dealer positioning | `get_gex_levels` (`source: "oi"`), `get_max_pain`, `get_greek_exposure_by_strike`                                                                                                                                                                                      | 3     |
| Option flow        | `get_ticker_candles_by_range` (`range: "1m"`, `interval: "1d"`, `end_interval: "0d"`, `session: "regular"`), `get_greek_flow_by_ticker`, `get_flow_per_expiry`                                                                                                         | 3     |
| Dark pool          | `get_dark_pool_volume_price_group`, `get_dark_pool_trades` (`ticker_symbol`, `order: "prem"`, `intraday_only: true`, `limit: 12`)                                                                                                                                      | 2     |
| Volatility         | `get_implied_volatility_term_structure`, `get_market_events` (`min_date`: today, `max_date`: 7 days from today)                                                                                                                                                        | 2     |
| Biggest contracts  | `get_options_screener` (`ticker_symbol`, `order: "premium"`, `order_direction: "desc"`, `limit: 8`)                                                                                                                                                                    | 1     |
| How it compares    | `get_stock_screener` twice, both with `sectors`: the sector from check 3, `issue_types: ["Common Stock", "ADR"]`, `order: "net_premium"`, `limit: 8`. The first call as is, for the most bullish names. The second adds `order_direction: "asc"`, for the most bearish | 2     |

Do not call anything not listed here, and never invent REST paths.

**Three of these return large payloads.**
`get_greek_exposure_by_strike` returns a few hundred strikes, `get_greek_flow_by_ticker` returns every minute of the session, and `get_stock_screener` returns wide rows.
Write each response straight to a file and query the file.
Never carry those payloads through the conversation.

**Numbers can arrive as text or as numbers, even within one row.**
Convert every numeric field before any arithmetic or comparison.

**The header's day change comes from the stock screener, not from company info.**
`get_company_info` returns a live `price` and no day change.
Take `prev_close` from the screener row for their ticker: from the sector calls if their ticker is among those rows, otherwise from the header call.
The day change is company info's `price` against that `prev_close`.
Check the row's `date` first.
If it is not today's date in New York, today's session has not opened: show that session's change instead, `close` against `prev_close`, labelled with the row's date.

**Candles arrive newest first, under short keys.**
Each row carries `o`, `h`, `l`, `c`, `start` and `vol`, not the longer names the tool description gives.
Sort them oldest first and keep only the latest session's rows.

**Flow rows exist only for minutes that had option trades.**
`get_greek_flow_by_ticker` returns its rows under a `result` key, in no set order.
A minute with no trades has no row, not a zero.
Sort by `timestamp`, keep regular hours only, 9:30 AM to 4:00 PM Eastern, and add each row's `dir_delta_flow` into a running total.
Count the rows that came back.
Never assume 390.

**Max pain returns one row per expiry, in no set order.**
Sort by `expiry` and take the first row.
A far-dated expiry can sit 30% from spot, so use the nearest expiry only.
The value is calculated from this morning's open interest and does not move during the session.

**Anchor dark pool prints to the latest session with `intraday_only: true`.**
Never pass `newer_than` with today's date: on a weekend, a holiday or before the first print it returns nothing.
Keep average-price, contingent and extended-hours prints.
They are real trades, and contingent prints are often tied to an option trade.
Keep each row's condition from `sale_cond_codes`, `trade_code` and `ext_hour_sold_codes`.
Pick the table's rows by premium:

1. Take the 8 largest prints where `canceled` is false.
2. Add every print where `canceled` is true and the premium is larger than the smallest of those 8.
3. Set each row's `over_1pct`: its size against 1% of `day_volume` from the header's screener row.
   Set it false on every cancelled print, whatever its size.
   A cancelled trade did not happen, so it cannot be 1% of a day's volume.

**Implied volatility arrives as fractions.**
Multiply `volatility` and `implied_move_perc` by 100 for percent.
`implied_move` is already in dollars.
Drop an expiry that has already expired: a 0-day row pulled after the 4:00 PM Eastern close prices an expired contract and can read several hundred percent.

**Market event times arrive in UTC.**
Convert them to Eastern before showing them.

**The screener sorts by net premium but does not return it.**
Net premium is `net_call_premium` minus `net_put_premium`.
Compute it for every row, and keep the screener's order: never re-sort by your own number.
Show the most bullish call's rows in the order returned, then the most bearish call's rows reversed, so the chart runs from most bullish to most bearish.
If a ticker appears in both calls, keep it once.

**Their ticker is usually already there.**
When it is among the rows returned, set `subject` true on that row and leave it where the screener put it.
Never add a second row for it, and never mark it as outside.
Only when it is in neither call do you take its row from the header call and mark it as outside the names shown.

---

## 3. Reduce to one data file

Write `<ticker>_data.json` next to the page, containing only the sections they chose.
This is the contract the template reads, and keeping it separate is what makes the next ticker cheap.

```json
{
  "ticker": "NVDA",
  "asof": "2026-09-08T17:54:02Z",
  "session": "2026-09-08",
  "sector": "Technology",
  "panels": ["positioning", "flow", "darkpool", "vol", "contracts", "peers"],
  "spot": 226.01,
  "prev_close": 233.24,
  "change_pct": -3.1,
  "change_session": "2026-09-08",
  "day_volume": 95133045,

  "levels": {
    "call_wall": 230.0,
    "put_wall": 225.0,
    "gamma_flip": 222.98,
    "gamma_magnet": 230.0,
    "basis": "open interest"
  },
  "max_pain": {
    "expiry": "2026-09-09",
    "level": 225.0,
    "basis": "this morning's open interest"
  },
  "window": [214, 241],
  "gex": [{ "strike": 214.0, "net": 195 }],
  "gex_dropped": 245,
  "chain_range": [0.5, 460],

  "vol": [{ "price": 225.0, "dark": 3207376, "lit": 1106791 }],
  "blocks": [
    {
      "at": "2026-09-08T21:39:19Z",
      "size": 3322553,
      "price": 225.73,
      "premium": 749999888.69,
      "condition": "average_price_trade, extended_hours_trade",
      "canceled": false,
      "over_1pct": true
    }
  ],

  "flow": [{ "expiry": "2026-09-09", "call": 56973991, "put": 50694504 }],
  "intraday": {
    "px": [{ "t": "13:30", "c": 229.7599 }],
    "flow": [{ "t": "13:30", "cum": -729710 }],
    "flow_rows": 388,
    "neg_rows": 236,
    "final_cum": -5490126,
    "crossed_zero": true,
    "session_open": 229.7599,
    "session_last": 225.79
  },

  "iv": [
    {
      "expiry": "2026-09-09",
      "dte": 1,
      "iv": 36.23,
      "move_usd": 3.1,
      "move_pct": 1.37,
      "flagged": false
    }
  ],
  "events": [{ "at": "2026-09-09T18:00:00Z", "event": "FOMC rate decision" }],
  "next_earnings": "2026-11-18",

  "contracts": [
    {
      "sym": "NVDA261218C00245000",
      "strike": 245,
      "type": "call",
      "expiry": "2026-12-18",
      "premium": 34786826,
      "volume": 40639,
      "oi": 9924
    }
  ],

  "peers": [
    {
      "ticker": "AAPL",
      "net_premium": 80495324,
      "subject": false,
      "outside": false,
      "group": "bullish"
    }
  ]
}
```

`panels` records what they chose.
Everything else is present only if its choice was picked.

Four of these fields exist only so the template never has to state a fact in prose.
`gex_dropped` is how many strikes the window rule removed, and `chain_range` is the full chain's low and high, for the note that says what is not drawn.
`crossed_zero` says whether cumulative flow changed sign, which decides the legend.
`group` puts each peer in the bullish or the bearish call, which gives the gap its position and each group its count.

`window` is the price range every price-axis panel shares.
Compute it as `spot * 0.93` to `spot * 1.07`, rounded outward to whole dollars, and drop `gex` and `vol` rows outside it.
This one rule prevents most of the ways this chart goes wrong.
A thin or newly listed option chain can put a wall 30% from spot; that level is real, but drawing it flattens the price line into a horizontal streak and destroys the panel.
Report such levels in a note instead.

`intraday.px` and `intraday.flow` timestamps are UTC `HH:MM`.
Keep them that way in the file and convert to Eastern at render time.

---

## 4. Build the page

Two files: a template carrying `const DATA = /*__DATA__*/;` and `__TICKER__` placeholders, and the data file injected into it.
Build the template once; every later ticker reuses it untouched.

**The template reads `DATA.panels` and renders only what is there.**
This is what lets someone add a panel later without a rebuild.

**The template holds no fact about this ticker.**
No symbol, no sector name, no dollar figure, no count, in any heading, caption, `aria-label`, legend or sentence.
Every one of those is read from `DATA`, the prose included.
Build each caption from fields.
Never type a sentence because it is true of the numbers in front of you today.

This is the rule that makes the next ticker one sentence of work, and it is the easiest one to break, because a sentence like "245 strikes outside this window are omitted" reads as a description while you are writing it and is a lie the next morning.

If a caption needs a number the contract does not carry, add the field to the contract.
Do not settle for the number.

Before you finish, search the template for the ticker symbol and for the sector name.
The only hit should be the `__TICKER__` placeholder.

Always: a header with ticker, last price and day change.
When the day change is from an earlier session, label it with that session's date.

### Dealer positioning

A strip of five level tiles across the top (call wall, put wall, gamma flip, gamma magnet, max pain), then **net gamma by strike**: diverging horizontal bars, price on the y axis, zero line marked.
Positive means dealers are long gamma and sell into strength; negative means they chase.
Label the max pain tile as based on this morning's open interest.
Put this caption under the panel: "The level tiles are recalculated at the current price through the day. The bars use this morning's positions and do not move."

### Option flow

**Where the money went, minute by minute** (full width, and first on the page if present): price and the running total of directional delta flow in two bands on one shared time axis.
Plot every point at its own time on a true time axis, never at its position in the array, so the flow line runs flat through minutes with no trades.
If dealer positioning was also chosen, overlay the gamma levels on the price band as dashed references; if it was not, leave them off rather than pulling them just for the overlay.
Positive flow means buyers lifting offers, negative means sellers hitting bids.

Then **option premium by expiry**: grouped vertical bars, calls and puts.

### Dark pool

**Volume by price**: stacked horizontal bars, off exchange and lit.
If dealer positioning is also on the page, use the **same price scale** as the gamma panel so the two can be read against each other.

Then **biggest blocks**: a compact table, newest first, showing time, size in shares, price, premium and the print's condition.
Mark every row whose `over_1pct` is true, and label the mark "more than 1% of today's volume at the time of this snapshot".
Read the field. Never work the mark out again at render time: the field already withholds it from a cancelled print.

**A cancelled print must be impossible to miss.**
Start its row with a "CANCELLED" badge in bold red text, in its own cell before the time.
Set it at least 1.5 times the size of the text in the table's data cells, not its header cells.
Strike through its size, price and premium.
A badge set in the body's own size disappears into the row, which is the one thing this rule exists to prevent.

### Volatility

**Implied volatility term structure**: a line by days to expiry, out to 45 days.
Flag any expiry within 7 days whose implied volatility is more than 15% above the median of the expiries from 8 to 45 days.
Beside a flagged expiry, name the nearest event on or before it, from the market events call or the next earnings date from check 3.
If neither holds an event in that window, say no scheduled event was found rather than supplying a cause.
Show each expected move in dollars and as a percent, both labelled, for example "±$4.57 (2.1%) by Friday".

### Biggest contracts

A table, not a chart, because the useful thing here is the detail: contract (strike, side, expiry), premium, volume, open interest.
Put a light bar behind the premium column so the ranking is visible at a glance.
**Flag rows where volume exceeds open interest**, and say in the note that volume above OI suggests but does not confirm new positioning, because it is not confirmed until the next morning's OI update.

### How it compares

Net premium across the sector as horizontal bars, **their ticker highlighted and every other name in the de-emphasis grey**.
This is an emphasis chart, not a categorical one: the point is where their name sits, not which bar is which company.

- List names top to bottom, most bullish first.
- Draw a vertical zero line. A positive net premium bar extends right of it, a negative one extends left.
- Put a gap between the most bullish group and the most bearish group, labelled "names in between not shown".
- Label their bar directly.
- If their ticker is in neither group, draw its bar inside that gap, labelled "outside the names shown".

### Layout

Full-width for the intraday panel.
Two columns for the rest.
If the count leaves one panel alone on the last row, let it span the full width rather than sitting in a half-width box beside a gap.

---

## 5. Rules that matter more than the styling

Every one of these exists because a real ticker broke it.

**Constrain the price axis, and footnote what falls outside.**
Covered above.
Never stretch a scale to reach a distant level.

**Align series by timestamp, not by array index.**
The price bars and the flow rows come from separate calls and can end at different times.
Build an index from timestamp to position and place flow by its own timestamp.
If one series ends earlier than the other, state the gap and give no cause: "Price data ends 75 minutes before flow data."

**The legend names only what is drawn.**
Decide it from `intraday.crossed_zero`, never from what today's chart happens to look like.
When flow never crosses zero, drop the swatch for the side that never appears and say it in words instead: "never crossed into net buying."
A legend entry with no matching mark makes people think the file is broken.

**Do not draw walls on the gamma panel.**
`get_gex_levels` is recalculated at the current price through the session, while `get_greek_exposure_by_strike` is this morning's exposure on a different scale, so the largest bar often is not the published wall.
Both are correct.
Put the levels in the strip, plot exposure in the panel, and let the caption explain the difference rather than reconciling them silently.

**Never invent a number.**
If a field is missing, print what is missing.
No interpolation, no estimate, no "approximately".

**Exclude stray levels.**
A price level with a handful of shares is noise.
The window rule usually removes these; if one survives, drop it and say so.

---

## 6. Style

Dark surface.
Hairline grid one shade off the surface.
Thin marks, generous padding, no gradients, no chart libraries, no borders drawn around marks.

**Never use two y scales on one plot.**
Two scales invent a correlation that is not in the data.
Stack the bands instead.

Gains and losses in blue and red (`#3987e5` / `#e66767`); dark pool and lit in violet and orange (`#9085e9` / `#d95926`), a pair that stays distinguishable under colour vision deficiency.
Every chart gets an `aria-label` and hover values.
Tables get real `<th>` headers.
The page must hold up at 390 pixels wide; let wide charts and tables scroll inside their own card rather than shrinking labels to nothing.

---

## 7. Security

The finished page makes **no network calls**.
The data is baked in at build time.

**Never write the API token into the HTML, into JavaScript, or into any file in the folder.**
It lives in the connector.
If they paste their key or their connector URL into the conversation, tell them to rotate it at `unusualwhales.com/dashboard/api`.

---

## 8. Check the page before you hand it over

Write a validator in the folder and run it.
Name it `validate.<ext>` in whatever language you are already building in.

**It has to render the page and read the text a person would see.**
A validator that only parses the file catches a syntax error and nothing else.
The charts draw from `DATA` at load time, so a wrong property name parses clean and plots nothing.
That is the failure this section exists to catch: one wrong field name in a y-scale silently emptied the flagship chart while a parse-only validator reported success.

Fail the build on any of these.

1. `NaN`, `undefined`, `null` or `Infinity` in the rendered text of the page.
2. Any series the legend names that drew no mark.
3. Any console error.
4. Any leftover `__TICKER__` or `__DATA__` in the finished page.
5. The ticker symbol or the sector name appearing in the template anywhere but the `__TICKER__` placeholder.
6. Any network-capable reference in the page: a `fetch`, an `XMLHttpRequest`, a `src` or an `href` pointing off the file.
7. A chart with no `aria-label`.
8. A `gex` or `vol` row outside the shared price window.
9. A cancelled print carrying the 1% volume mark.
10. Horizontal scrolling at 390 pixels wide anywhere but inside a card that is meant to scroll.

**Re-run it on every page in the folder after every template edit, not just on the ticker you are working on.**
The template is shared.
Editing it for a later ticker and rebuilding the earlier pages from it is how a page that was already checked and approved goes out broken.

---

## 9. Finish the job

Write a `README.md` in the folder recording **which panels they chose**, which tools produced each one, how to rebuild for another ticker, and the date of the snapshot.
Someone opening that folder in three weeks should not have to guess, and neither should you when they come back asking for a change.

Then tell them, in plain language:

- **What they left out, and that it is one sentence away.** "You skipped dark pool and the sector comparison. Say the word and I'll add either."
- The page is a snapshot, not a live feed. To refresh, ask again.
- The next ticker is one sentence, because the template already exists.
- Positioning is not prediction.

---

## Troubleshooting

**"I can't reach the API."**
Some environments block outbound network by policy.
A `403` mentioning Cloudflare or "Error 1010" is an upstream block, not an auth failure, and not a problem with their subscription.

**Empty options data.**
Check 3 has already turned away a ticker with no listed options, so the likely cause is a thin chain.
Say so, and show what you could get rather than failing silently.

**A panel with one bar.**
Usually the window rule doing its job on a thin chain.
That is information about the ticker.
Note it and move on.

**Rate limits.**
A full build is 14 or 15 calls, fewer for a partial one.
A basket of twenty names at full build is 300 calls at most.
Tell them the arithmetic before they run it, not after.
