Tutorial

Building an A-Share Sentiment Dashboard with ChatGPT Sites

ChatGPT Sites turns a prompt into a web page, and the data comes from one fetch in the browser. The free endpoints send access-control-allow-origin: *, so no backend and no key — call them straight from the page. Runnable code below.

Why no backend? Because cross-origin calls are already allowed

Before a browser calls another domain it asks whether that domain allows it. Both free endpoints return access-control-allow-origin: * (verified 2026-10-28), so a plain fetch on the page gets the data — no proxy server required.

Verify it yourself; the response headers must contain that line:

Command line (verify the CORS header)
curl -s -D - -o /dev/null \
  -H "Origin: https://example.chatgpt.site" \
  https://api.ashareapi.com/v1/changedist | grep -i access-control

# access-control-allow-origin: *

Decide what the dashboard shows

The first three rows are enough for a sentiment dashboard. See the endpoint list for all 96 endpoints, 7 of which are free and need no signup.

Advancers / decliners
/v1/changedist
No
count
Limit-ups / limit-downs
/v1/changedist
No
count
Two-market turnover
/v1/changedist
No
CNY 100M (inside data text)
Last price of a stock
/v1/quote?code=sh600519
No
CNY
Hot search ranking
/v1/hot
No
—

Step 1: hand the page description to ChatGPT

Create a Site and paste the prompt below. It defines the layout; the data wiring comes next.

Prompt (paste as-is)
Build a single-page A-share sentiment dashboard:
1. Four cards on top: advancers, decliners, limit-ups, two-market turnover
2. Fetch data from https://api.ashareapi.com/v1/changedist
   directly in the browser, no backend
3. Red for up, green for down, dark background
4. Auto-refresh every 30 seconds

The generated site lives at <site-name>.<your-account>.chatgpt.site. The model has no knowledge of the real field names when it writes the page, so its data layer usually does not match — replace it with the code in the next step.

Step 2: wire the data in (complete code, replace wholesale)

The following is written against the live response. Swap out the generated <script>, or replace the whole file:

HTML + vanilla JS (no dependencies, runnable)
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>A-share sentiment</title>
<style>
  body{margin:0;padding:24px;background:#0b0b0f;color:#e9e9f2;
       font:14px/1.5 -apple-system,"Segoe UI",sans-serif}
  .row{display:flex;gap:12px;flex-wrap:wrap}
  .card{min-width:120px;padding:14px 18px;border-radius:10px;
        background:#16161d;border:1px solid #2a2a36}
  .k{font-size:12px;color:#8a8aa0}
  .v{margin-top:4px;font-size:26px;font-weight:600}
  .up{color:#ff4d4f}.down{color:#00c853}
  #ts{margin-top:16px;font-size:12px;color:#8a8aa0}
</style>
<div class="row" id="cards"></div>
<div id="ts"></div>

<script>
var API = "https://api.ashareapi.com/v1";

function card(k, v, cls) {
  return '<div class="card"><div class="k">' + k + '</div>' +
         '<div class="v ' + (cls || "") + '">' + v + '</div></div>';
}

async function loadMood() {
  var r = await fetch(API + "/changedist");
  var b = await r.json();
  if (!b.ok) throw new Error("changedist failed");

  var s = (b.structured && b.structured[0]) || {};   // keys are Chinese
  var m = String(b.data).match(/两市成交额:([\d.]+)亿/);  // turnover lives in data text

  document.getElementById("cards").innerHTML =
    card("Advancers", s["上涨"], "up") +
    card("Decliners", s["下跌"], "down") +
    card("Limit-ups", s["涨停"], "up") +
    card("Limit-downs", s["跌停"], "down") +
    card("Up share", s["上涨占比"]) +
    card("Turnover", m ? m[1] + " (100M CNY)" : "—");

  document.getElementById("ts").textContent =
    "Updated " + new Date().toLocaleTimeString();
}

loadMood();
setInterval(loadMood, 30000);   // 30s; never below 12s
</script>
</html>

⚠️ Three real pitfalls

  • Field names are Chinese — the keys in structured[0] are literally "上涨", "下跌", "涨停", not up / down. Generated code guesses English almost every time; always check against the live response.
  • 上涨占比 is a string like "20%", not the number 20 — it carries a percent sign. Run parseFloat() before any arithmetic, or your chart library gets NaN.
  • Turnover is not in structured — structured only holds the first table (advancers/decliners). Turnover sits in the Markdown data text, so extract it with a regex: b.data.match(/两市成交额:([\d.]+)亿/).

Step 3: add individual stocks with one more endpoint

JavaScript (same script block)
async function loadStock(code) {
  var r = await fetch(API + "/quote?code=" + code);
  var b = await r.json();
  if (!b.ok || !b.data || !b.data.length) return null;

  var bar = b.data[0];          // newest first, data[0] is the latest bar
  return {
    date: bar.date,
    last: Number(bar.last),
    high: Number(bar.high),
    low: Number(bar.low),
    volume: Number(bar.volume),     // lots
    amount: Number(bar.amount),     // CNY
    turnover: Number(bar.turnover)  // percent
  };
}

loadStock("sh600519").then(console.log);
date
Trading date
—
2026-10-29
last
Latest price
CNY
1344.93
high / low
Session high / low
CNY
1354.98 / 1322.60
volume
Volume
lots
8251
amount
Turnover value
CNY
1105305033
turnover
Turnover rate
%
0.07

volume is in lots (1 lot = 100 shares) and amount is in CNY — the two most common unit mistakes. Worth a code comment.

Three things to watch once it runs

  • Anonymous limit is 5 requests per minute — keep the refresh interval at 12 seconds or more, or you will get 429s. The 30 seconds above is safe.
  • Never hardcode a paid key in the frontend — page source is public and the key will be copied. For higher rates, fetch on a server and keep the key there.
  • A dashboard describes state, it does not predict direction — 4,156 advancers means "broad gains today", not "buy tomorrow". Read sentiment alongside historical averages and volume.

What this setup is not good for

  • Sub-second refresh — the free quota is 5/min; aggressive polling just gets rate limited. Use a key and aggregate server-side if you need more.
  • A multi-user product — every visitor shares your single anonymous quota, so they crowd each other out.
  • Trading signals — this article covers data retrieval and display only, not investment advice.

FAQ

Will a ChatGPT-generated page be blocked by CORS?

No. The free endpoints return access-control-allow-origin: * (verified 2026-10-29), so a browser fetch works. The one-line check is in step 1.

Do I need to sign up or apply for a key?

No. changedist / quote / hot are among the 7 free endpoints — no signup, no key, anonymous calls allowed (5/min).

Can I share the finished page?

The site URL is shareable, in the form <site-name>.<your-account>.chatgpt.site. Anonymous access depends on your site settings in ChatGPT Sites — open it in a private window to check: a sign-in prompt means the site is not public yet.

Why does my advance/decline count differ from other platforms?

Check the basis first: /v1/changedist is the current session; /v1/market-overview?type=updown is T-1 (its response states the data date). Platforms also differ on scope (ST stocks, Beijing exchange, suspended names).

Can I add a candlestick chart?

Yes. /v1/kline?code=sh600519&count=60 returns daily bars (free, forward-adjusted); feed them to any chart library. See the kline entry in the endpoint list for fields and parameters.

Last updated: 2026-10-28

Code and fields taken from live responses (re-verified 2026-10-29): /v1/changedist uses Chinese keys in structured[0] (上涨 / 下跌 / 涨停 / 跌停 / 上涨占比 / 停牌 / 平盘), 上涨占比 is a percent-sign string, and the two-market turnover appears only in the data text; /v1/quote returns data in reverse-date order with volume in lots and amount in CNY; both endpoints return access-control-allow-origin: *. Figures in this article are samples and change with the market; fields and rate limits follow the endpoint reference.