QuietSwing
  • Market Movers
  • Themes
  • Stocks
  • Ideas

Theme Analysis

QUIETSWING / THEMATIC VIEW

Theme analysis

Find themes with broad price movement, unusual volume attention, and persistent trends.

Theme snapshot
Choose an as-of date. Returns cover one week, two weeks, one month and three months ending on that date.
viewof asOfDate = Inputs.date({label: "As-of date", min: dateBounds.min, max: dateBounds.max, value: asOfDefault, required: true})
latestSnapshot = fetch("/api/themes").then(response => response.json())
dateBounds = latestSnapshot.bounds

function loadStoredAsOf(bounds) {
  try {
    const stored = localStorage.getItem("quietdesk-theme-as-of")
    return stored >= bounds.min && stored <= bounds.max ? stored : bounds.max
  } catch {
    return bounds.max
  }
}

asOfDefault = loadStoredAsOf(dateBounds)
function dateKey(value) {
  if (value == null) return null
  if (typeof value === "string") return value.slice(0, 10)
  if (typeof value === "number") return new Date(value).toISOString().slice(0, 10)
  return value.toISOString().slice(0, 10)
}

themeAsOfState = (() => {
  const value = dateKey(asOfDate)
  try {
    localStorage.setItem("quietdesk-theme-as-of", value)
  } catch {
    // Browser storage is optional; the page still works without it.
  }
  return value
})()

function formatPercent(value) {
  if (value == null) return "Unavailable"
  return `${value >= 0 ? "+" : ""}${value.toFixed(2)}%`
}

function formatAttentionDelta(value) {
  if (value == null) return "Unavailable"
  return `${value >= 0 ? "+" : ""}${value.toFixed(2)}`
}

function formatIqr(value) {
  return value == null ? "Unavailable" : `${value.toFixed(2)} pp`
}

function formatMarketCap(value) {
  if (!value) return "Unknown"
  return value === "mid" ? "Mid" : value.charAt(0).toUpperCase() + value.slice(1)
}

function formatPricePosition(value) {
  if (value == null) return html`<span class="muted">Unavailable</span>`
  const bucket = value >= 67 ? "high" : value <= 33 ? "low" : "mid"
  const label = bucket === "high" ? "High" : bucket === "low" ? "Low" : "Mid"
  return html`<span class="position-badge ${bucket}">${value.toFixed(0)}%</span><span class="position-label">${label}</span>`
}

HORIZONS = [{key: "oneWeek"}, {key: "twoWeek"}, {key: "oneMonth"}, {key: "threeMonth"}]

function renderStateChip(state) {
  const stateClass = state.name.toLowerCase()
  const unconfirmed = state.confirmed === false && state.name !== "Unavailable"
  return html`
    <span class="state-chip ${stateClass} ${unconfirmed ? "unconfirmed" : ""}">${state.name}</span>
    ${unconfirmed ? html`<span class="state-note">Unconfirmed</span>` : ""}
  `
}

function renderRankings(result) {
  const eligible = result.themes.filter(theme => theme.eligible)
  const leaders = [...eligible]
    .sort((a, b) => b.medianReturns.oneMonth - a.medianReturns.oneMonth)
    .slice(0, 5)
  const pressure = [...eligible]
    .sort((a, b) => a.medianReturns.oneMonth - b.medianReturns.oneMonth)
    .slice(0, 5)
  const attention = [...eligible]
    .filter(theme => theme.attentionTrend != null)
    .sort((a, b) => b.attentionTrend - a.attentionTrend)
    .slice(0, 5)

  function card(rows, kind, kicker, title, metric) {
    return html`
      <section class="theme-ranking-card ${kind}">
        <div class="theme-ranking-heading">
          <div class="panel-kicker">${kicker}</div>
          <h2>${title}</h2>
        </div>
        <ol class="theme-ranking-list">
          ${rows.map(row => html`
            <li>
              <div>
                <span class="theme-ranking-name">${row.themeName}</span>
                <span class="theme-ranking-meta">${metric === "attention" ? `Level ${formatAttentionDelta(row.attentionLevel)} · ` : ""}${row.relativeState.name} · ${row.trend.name} · ${row.oneWeekBreadth.toFixed(0)}% positive this week · ${row.stockCount} stocks</span>
              </div>
              <span class="theme-ranking-value">${metric === "attention" ? formatAttentionDelta(row.attentionTrend) : formatPercent(row.medianReturns.oneMonth)}</span>
            </li>
          `)}
        </ol>
      </section>
    `
  }

  return html`
    <div class="theme-ranking-grid">
      ${card(leaders, "leader", "1M LEADERS", "Theme leaders", "return")}
      ${card(pressure, "pressure", "1M PRESSURE", "Under pressure", "return")}
      ${card(attention, "attention", "ATTENTION Δ", "Attention accelerating", "attention")}
    </div>
  `
}

function horizonTone(value) {
  if (value == null) return "unavailable"
  if (Math.abs(value) < 1) return "neutral"
  return value > 0 ? "positive" : "negative"
}

function sortableHeader(label, column, type = "number") {
  return html`<th data-sort-column=${column} data-sort-type=${type}><button type="button" class="table-sort-button">${label}<span class="sort-indicator" aria-hidden="true">↕</span></button></th>`
}

function makeSortableTable(table, initialColumn, initialDirection = -1, rankColumn = null) {
  const body = table.tBodies[0]
  const headers = [...table.querySelectorAll("th[data-sort-column]")]
  let activeColumn = initialColumn
  let direction = initialDirection

  function updateHeaders() {
    for (const header of headers) {
      const column = Number(header.dataset.sortColumn)
      const active = column === activeColumn
      header.setAttribute("aria-sort", active ? (direction > 0 ? "ascending" : "descending") : "none")
      header.querySelector(".sort-indicator").textContent = active ? (direction > 0 ? "↑" : "↓") : "↕"
    }
  }

  function sortRows(header) {
    const column = Number(header.dataset.sortColumn)
    direction = column === activeColumn
      ? -direction
      : header.dataset.sortType === "text" ? 1 : -1
    activeColumn = column
    const rows = [...body.rows]
    rows.sort((a, b) => {
      const left = a.cells[column].dataset.sortValue ?? ""
      const right = b.cells[column].dataset.sortValue ?? ""
      const leftMissing = left === ""
      const rightMissing = right === ""
      if (leftMissing || rightMissing) return leftMissing === rightMissing ? 0 : leftMissing ? 1 : -1
      const comparison = header.dataset.sortType === "text"
        ? left.localeCompare(right)
        : Number(left) - Number(right)
      return comparison * direction
    })
    rows.forEach((row, index) => {
      body.append(row)
      if (rankColumn != null) row.cells[rankColumn].textContent = index + 1
    })
    updateHeaders()
  }

  for (const header of headers) {
    header.querySelector("button").addEventListener("click", () => sortRows(header))
  }
  updateHeaders()
}

function renderThemeTable(themes, benchmarks) {
  const analysisRows = [
    ...themes.map(theme => ({
      rowType: "theme",
      rowId: theme.themeId,
      displayName: theme.themeName,
      medianReturns: theme.medianReturns,
      trend: theme.trend,
      relativeState: theme.relativeState,
      oneWeekBreadth: theme.oneWeekBreadth,
      attentionLevel: theme.attentionLevel,
      attentionTrend: theme.attentionTrend,
      iqrWidth: theme.iqrWidth,
      stockCount: theme.stockCount
    })),
    ...benchmarks.map(benchmark => ({
      rowType: "benchmark",
      rowId: benchmark.benchmarkId,
      displayName: benchmark.benchmarkName,
      proxyTicker: benchmark.proxyTicker,
      medianReturns: benchmark.medianReturns,
      trend: benchmark.trend,
      relativeState: null,
      oneWeekBreadth: null,
      attentionLevel: null,
      attentionTrend: null,
      iqrWidth: null,
      stockCount: null
    }))
  ].sort((a, b) =>
    (b.medianReturns.oneWeek ?? -Infinity) - (a.medianReturns.oneWeek ?? -Infinity)
    || (b.medianReturns.oneMonth ?? -Infinity) - (a.medianReturns.oneMonth ?? -Infinity)
    || a.displayName.localeCompare(b.displayName)
  )

  const unavailable = () => html`<span class="muted">—</span>`
  const rows = analysisRows.map((row, index) => html`
    <tr class=${row.rowType === "benchmark" ? "benchmark-row" : ""}>
      <td class="rank-cell">${index + 1}</td>
      <td class="theme-name-cell" data-sort-value=${row.displayName}>
        ${row.displayName}
        ${row.rowType === "benchmark" ? html`<span class="benchmark-badge">${row.proxyTicker} · Benchmark</span>` : ""}
        ${row.rowType === "theme" && row.stockCount < 10 ? html`<span class="sample-badge">Small sample</span>` : ""}
      </td>
      ${HORIZONS.map(horizon => html`<td class="horizon-return ${horizonTone(row.medianReturns[horizon.key])}" data-sort-value=${row.medianReturns[horizon.key] ?? ""}>${formatPercent(row.medianReturns[horizon.key])}</td>`)}
      <td class="state-cell">${renderStateChip(row.trend)}</td>
      <td class="state-cell">${row.relativeState ? renderStateChip(row.relativeState) : unavailable()}</td>
      <td data-sort-value=${row.oneWeekBreadth ?? ""}>${row.oneWeekBreadth == null ? unavailable() : `${row.oneWeekBreadth.toFixed(0)}%`}</td>
      <td class="attention-delta ${horizonTone(row.attentionLevel)}" data-sort-value=${row.attentionLevel ?? ""}>${row.attentionLevel == null ? unavailable() : formatAttentionDelta(row.attentionLevel)}</td>
      <td class="attention-delta ${horizonTone(row.attentionTrend)}" data-sort-value=${row.attentionTrend ?? ""}>${row.attentionTrend == null ? unavailable() : formatAttentionDelta(row.attentionTrend)}</td>
      <td data-sort-value=${row.iqrWidth ?? ""}>${row.iqrWidth == null ? unavailable() : formatIqr(row.iqrWidth)}</td>
      <td data-sort-value=${row.stockCount ?? ""}>${row.stockCount == null ? unavailable() : row.stockCount}</td>
      <td>${row.rowType === "theme" ? html`<button type="button" class="theme-view-button" data-theme-id=${row.rowId}>View<span class="visually-hidden"> constituents for ${row.displayName}</span></button>` : unavailable()}</td>
    </tr>
  `)

  const container = html`
    <div class="theme-table-container">
      <section class="mover-panel theme-table-panel">
        <div class="panel-heading">
          <div>
            <div class="panel-kicker">THEMES + BENCHMARKS</div>
            <h2>Return ranking</h2>
          </div>
          <div class="panel-subtitle">Click a column heading to sort</div>
        </div>
        <div class="table-scroll">
          <table class="mover-table theme-summary-table">
            <thead>
              <tr><th>Rank</th>${sortableHeader("Theme / benchmark", 1, "text")}${sortableHeader("1W", 2)}${sortableHeader("2W", 3)}${sortableHeader("1M", 4)}${sortableHeader("3M", 5)}<th>Trend</th><th>Relative state</th>${sortableHeader("1W breadth", 8)}${sortableHeader("Attention level", 9)}${sortableHeader("Attention Δ", 10)}${sortableHeader("1M IQR", 11)}${sortableHeader("Stocks", 12)}<th>Members</th></tr>
            </thead>
            <tbody>${rows}</tbody>
          </table>
        </div>
      </section>
      <dialog class="theme-dialog" aria-labelledby="theme-dialog-title"></dialog>
    </div>
  `

  const themeById = new Map(themes.map(theme => [theme.themeId, theme]))
  const dialog = container.querySelector(".theme-dialog")
  makeSortableTable(container.querySelector(".theme-summary-table"), 2, -1, 0)
  for (const button of container.querySelectorAll(".theme-view-button")) {
    button.addEventListener("click", () => {
      const theme = themeById.get(button.dataset.themeId)
      dialog.replaceChildren(renderThemeMembers(theme))
      for (const closeButton of dialog.querySelectorAll(".theme-dialog-close")) {
        closeButton.addEventListener("click", () => dialog.close())
      }
      dialog.showModal()
    })
  }
  return container
}

function renderThemeMembers(theme) {
  const rows = theme.members.map(member => html`
    <tr>
      <td class="ticker" data-sort-value=${member.ticker}>${member.ticker}</td>
      ${HORIZONS.map(horizon => html`<td class="horizon-return ${horizonTone(member.returns[horizon.key])}" data-sort-value=${member.returns[horizon.key] ?? ""}>${formatPercent(member.returns[horizon.key])}</td>`)}
      <td class="position" data-sort-value=${member.pricePosition ?? ""}>${formatPricePosition(member.pricePosition)}</td>
      <td class="attention-delta ${horizonTone(member.attentionLevel)}" data-sort-value=${member.attentionLevel ?? ""}>${formatAttentionDelta(member.attentionLevel)}</td>
      <td class="attention-delta ${horizonTone(member.attentionTrend)}" data-sort-value=${member.attentionTrend ?? ""}>${formatAttentionDelta(member.attentionTrend)}</td>
      <td class="market-cap">${formatMarketCap(member.marketCap)}</td>
      <td class="themes">${member.otherThemes.length ? member.otherThemes.map(name => html`<span class="theme-tag">${name}</span>`) : html`<span class="muted">No other themes</span>`}</td>
    </tr>
  `)

  const container = html`
    <div class="theme-dialog-shell">
      <header class="theme-dialog-header">
        <div>
          <div class="panel-kicker">THEME CONSTITUENTS</div>
          <h2 id="theme-dialog-title">${theme.themeName}</h2>
          <p>${theme.stockCount} eligible stocks, ranked by one-month return.</p>
        </div>
        <button type="button" class="theme-dialog-close icon-close" aria-label="Close constituent list">×</button>
      </header>
      <div class="theme-dialog-metrics">
        <div><span>Trend</span><strong>${renderStateChip(theme.trend)}</strong></div>
        <div><span>Relative state</span><strong>${renderStateChip(theme.relativeState)}</strong></div>
        <div><span>1M return</span><strong>${formatPercent(theme.medianReturns.oneMonth)}</strong></div>
        <div><span>1W breadth</span><strong>${theme.oneWeekBreadth.toFixed(0)}%</strong></div>
        <div><span>Attention level</span><strong>${formatAttentionDelta(theme.attentionLevel)}</strong></div>
        <div><span>Attention Δ</span><strong>${formatAttentionDelta(theme.attentionTrend)}</strong></div>
        <div><span>1M IQR</span><strong>${formatIqr(theme.iqrWidth)}</strong></div>
      </div>
      <div class="theme-member-scroll">
        <table class="mover-table theme-member-table">
          <thead><tr>${sortableHeader("Ticker", 0, "text")}${sortableHeader("1W", 1)}${sortableHeader("2W", 2)}${sortableHeader("1M", 3)}${sortableHeader("3M", 4)}${sortableHeader("3M position", 5)}${sortableHeader("Attention level", 6)}${sortableHeader("Attention Δ", 7)}<th>Cap</th><th>Other themes</th></tr></thead>
          <tbody>${rows}</tbody>
        </table>
      </div>
      <footer class="theme-dialog-footer">
        <button type="button" class="theme-dialog-close dialog-close-button">Close</button>
      </footer>
    </div>
  `
  makeSortableTable(container.querySelector(".theme-member-table"), 3)
  return container
}
themeSnapshot = dateKey(asOfDate) === latestSnapshot.actualAsOf
  ? latestSnapshot
  : fetch(`/api/themes?as_of=${dateKey(asOfDate)}`).then(response => response.json())
(() => {
  const result = themeSnapshot
  if (result.error) return html`<div class="empty-state">${result.error}</div>`
  return html`
    <div class="period-line">As of <strong>${result.actualAsOf}</strong> · Horizon starts: 1W ${result.horizonDates.oneWeek}, 2W ${result.horizonDates.twoWeek}, 1M ${result.horizonDates.oneMonth}, 3M ${result.horizonDates.threeMonth}</div>
    <div class="period-line">Highlights and trend lines include only themes with enough stocks; smaller themes still appear in the landscape and full table.</div>
    ${renderRankings(result)}
  `
})()
POSITIONING

Theme landscape

One-month performance vs SPY · recent volume attention · color shows whether attention is rising or falling

Each dot is a theme. Further right means a stronger one-month return than SPY; higher means more unusual recent trading volume; greener means attention is rising, redder that it is fading.

(() => {
  const result = themeSnapshot
  if (result.error) return html`<div class="empty-state">${result.error}</div>`
  const spyReturn = result.benchmarks.find(benchmark => benchmark.proxyTicker === "SPY")?.medianReturns.oneMonth
  if (spyReturn == null) return html`<div class="empty-state">SPY benchmark return is unavailable.</div>`
  const data = result.themes
    .filter(theme => theme.attentionLevel != null && theme.attentionTrend != null)
    .map(theme => ({
      ...theme,
      relativeOneMonth: theme.medianReturns.oneMonth - spyReturn
    }))
  const maxAttentionDelta = Math.max(0.1, ...data.map(theme => Math.abs(theme.attentionTrend)))
  return Plot.plot({
    width: 1100,
    height: 430,
    marginLeft: 64,
    marginRight: 36,
    marginBottom: 48,
    style: {background: "transparent", fontSize: "11px"},
    x: {grid: true, label: "1M median return vs SPY (percentage points)"},
    y: {grid: true, label: "Attention level (recent volume vs. normal)"},
    color: {type: "linear", scheme: "RdYlGn", domain: [-maxAttentionDelta, maxAttentionDelta], legend: true, label: "Attention Δ"},
    marks: [
      Plot.ruleX([0], {stroke: "#b9c2cf"}),
      Plot.ruleY([0], {stroke: "#b9c2cf"}),
      Plot.dot(data, {
        x: "relativeOneMonth",
        y: "attentionLevel",
        r: 6,
        fill: "attentionTrend",
        stroke: "white",
        strokeWidth: 1.5,
        opacity: 0.82,
        tip: true,
        title: theme => `${theme.themeName}\n1M vs SPY: ${formatPercent(theme.relativeOneMonth)}\n1M return: ${formatPercent(theme.medianReturns.oneMonth)}\nAttention level: ${formatAttentionDelta(theme.attentionLevel)}\nAttention Δ: ${formatAttentionDelta(theme.attentionTrend)}\nTrend: ${theme.trend.name}\nRelative state: ${theme.relativeState.name}\n1W breadth: ${theme.oneWeekBreadth.toFixed(0)}%\nStocks: ${theme.stockCount}`
      })
    ]
  })
})()
TREND

Theme trends

Latest 1W leaders and pressure over their trailing 3M paths
Start of the latest week

Lines follow the three strongest and three weakest themes of the latest week, each measured from the start of the three-month window.

(() => {
  const result = themeSnapshot
  if (result.error) return html`<div class="empty-state">${result.error}</div>`
  const data = result.trends.map(row => ({...row, date: new Date(`${row.date}T00:00:00Z`)}))
  const dateCount = new Set(data.map(row => row.date.getTime())).size
  return Plot.plot({
    width: 1100,
    height: 430,
    marginLeft: 58,
    marginRight: 28,
    marginBottom: 48,
    style: {background: "transparent", fontSize: "11px"},
    x: {
      type: "utc",
      label: null,
      ticks: Math.min(6, dateCount),
      tickFormat: date => date.getUTCHours() === 0
        ? date.toLocaleDateString("en-CA", {month: "short", day: "numeric", timeZone: "UTC"})
        : ""
    },
    y: {grid: true, label: "Median constituent return from 3M start (%)"},
    color: {legend: true},
    marks: [
      Plot.ruleY([0], {stroke: "#b9c2cf"}),
      Plot.ruleX([new Date(`${result.horizonDates.oneWeek}T00:00:00Z`)], {
        stroke: "#7f8a99",
        strokeDasharray: "4,4"
      }),
      Plot.line(data, {
        x: "date",
        y: "return",
        stroke: "theme",
        strokeWidth: 2.2,
        title: row => `${row.theme}\n${row.date.toISOString().slice(0, 10)}: ${formatPercent(row.return)} from 3M start\nLatest 1W: ${formatPercent(row.oneWeekReturn)}`
      }),
      Plot.dot(data.filter(row => row.date.getTime() === Math.max(...data.map(item => item.date.getTime()))), {
        x: "date",
        y: "return",
        fill: "theme",
        r: 3.5
      })
    ]
  })
})()
(() => {
  const result = themeSnapshot
  if (result.error) return ""
  return renderThemeTable(result.themes, result.benchmarks)
})()
Each stock counts in every theme it belongs to. Theme returns reflect the typical member stock; benchmarks use ETF prices. Trend describes the direction of recent returns. Relative state compares a theme’s recent performance with its one-month standing against other themes, and is marked unconfirmed when participation is narrow or the theme is small. Breadth is the share of member stocks that rose. Attention shows how unusual recent trading volume is compared with each stock’s norm, and Attention Δ whether it is rising or falling. IQR shows how widely member returns are spread. 3M position shows where the latest close sits in its three-month range.
 

For research and education only; not financial advice.