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:
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.
| Reading | Endpoint | Key required | Unit |
|---|---|---|---|
| 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 | — |
/v1/changedist/v1/changedist/v1/changedistdata text)/v1/quote?code=sh600519/v1/hotStep 1: hand the page description to ChatGPT
Create a Site and paste the prompt below. It defines the layout; the data wiring comes next.
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 secondsThe 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:
<!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"上涨","下跌","涨停", notup/down. Generated code guesses English almost every time; always check against the live response. 上涨占比is a string like"20%", not the number20— it carries a percent sign. RunparseFloat()before any arithmetic, or your chart library gets NaN.- Turnover is not in
structured—structuredonly holds the first table (advancers/decliners). Turnover sits in the Markdowndatatext, so extract it with a regex:b.data.match(/两市成交额:([\d.]+)亿/).
Step 3: add individual stocks with one more endpoint
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);| Field | Meaning | Unit | Sample value (sh600519 · intraday 2026-10-29) |
|---|---|---|---|
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 |
datelasthigh / lowvolumeamountturnovervolume 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
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.
No. changedist / quote / hot are among the 7 free endpoints — no signup, no key, anonymous calls allowed (5/min).
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.
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).
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.