Install from npm:
npm install psx-data-apipackage | releases
Typed client for the Pakistan Stock Exchange. Live quotes, five years of OHLCV history, indices, sectors, and the full security directory.
Plain typed arrays. Real numbers. null for absent values - never a formatted
string, never a misleading 0.
import { createPsxClient } from 'psx-data-api';
const psx = createPsxClient();
const { data: quotes } = await psx.marketWatch();
const hbl = quotes.find((q) => q.symbol === 'HBL');
hbl.current; // 302.80 - number
hbl.change; // 1.23 - negative when declining
hbl.volume; // 1153509 - integer, not "1,153,509"
hbl.changePct; // 0.41 - number | nullnpm install psx-data-apiRequires Node 20.19+ (or any runtime with a global fetch). Ships ESM with
bundled type declarations.
Server-side only. The Pakistan Stock Exchange does not send
access-control-allow-originon any endpoint, so a browser cannot call it directly -fetchfrom a React component will fail regardless of this library. Run it in Node, a serverless function, or an edge worker, and call it from the browser through your own route.
| Method | Returns | Size |
|---|---|---|
marketWatch() |
Every listed security with live quotes | 495 |
marketSummary() |
Exchange totals: status, volume, trades, breadth | 1 |
history(symbol) |
Daily OHLCV, oldest first | ~1,239 bars |
intraday(symbol) |
Price/volume ticks, last two sessions | ~682 |
symbols() |
Full directory: equities, debt, ETFs | 1,028 |
indices() |
Current index levels | 17 |
sectorSummary() |
Per-sector aggregates | 38 |
company(symbol) |
Fundamentals and share counts | 1 |
const { data } = await psx.marketWatch();
const movers = data
.filter((q) => q.changePct != null)
.sort((a, b) => (b.changePct ?? 0) - (a.changePct ?? 0))
.slice(0, 10);
for (const q of movers) {
console.log(`${q.symbol.padEnd(8)} ${q.changePct!.toFixed(2)}% ${q.current}`);
}const summary = await psx.marketSummary();
console.log(summary.status); // "OPEN" | "CLOSED" | "PRE_OPEN"
console.log(summary.total); // 569
console.log(summary.trades); // 295473
// Internally consistent - the exchange's own arithmetic holds.
summary.advanced + summary.declined + summary.unchanged === summary.total;const bars = await psx.history('HBL');
const closes = bars.map((b) => b.close);
const last = bars.at(-1);
console.log(`${last?.time} close ${last?.close}`);history() returns bars oldest-first, so charting libraries need no re-sorting.
Note that PSX publishes no high/low on this endpoint - those fields are
null rather than guessed.
bars.at(-1);
// {
// time: '2026-09-30T11:00:00.000Z',
// open: 302.8,
// high: null, <- not published
// low: null, <- not published
// close: 306.1,
// volume: 1153509,
// }const sectors = await psx.sectorSummary();
const ranked = sectors
.filter((s) => s.marketCapBn != null)
.sort((a, b) => (b.marketCapBn ?? 0) - (a.marketCapBn ?? 0));
for (const s of ranked.slice(0, 5)) {
console.log(
`${s.sector.code} ${s.sector.name.padEnd(32)} ` +
`${s.advanced}/${s.declined} PKR ${s.marketCapBn}Bn`,
);
}const hbl = await psx.company('HBL');
hbl.totalShares; // 1466852508
hbl.freeFloatShares; // 586741003
hbl.description; // free-text profile
hbl.warnings; // [] when the page parsed cleanlyThe exchange documents a minimum 15-second polling interval per symbol and states it reserves the right to block an IP address. Both are enforced here.
Rate limiting applies per symbol, so ten different symbols can be fetched concurrently while no single symbol exceeds one request per 15 seconds. Calls for the same symbol queue rather than firing together.
// Fetched in parallel; HBL still respects the floor.
const [hbl, ogdc] = await Promise.all([psx.history('HBL'), psx.history('OGDC')]);Caching lifetime follows the trading session: 15 seconds while the market is open (09:00-15:00 Asia/Karachi, Mon-Fri), 30 minutes when closed. The security directory and index constituents change rarely and are held for an hour.
await psx.marketWatch(); // fetched
await psx.marketWatch(); // cached, ~0ms
await psx.marketWatch({ bypassCache: true }); // fetched again
psx.clearCache(); // drop everything
psx.stats(); // { cache: { hits, misses, coalesced, size }, limiter: { waits, totalWaitMs } }Concurrent callers for the same uncached key share one request, so a React page mounting ten components sends one request, not ten.
An async iterable, so no event-emitter dependency. It bypasses the cache deliberately - a stream serving cached values is not a stream.
const controller = new AbortController();
for await (const tick of psx.stream(['HBL', 'OGDC'], {
intervalMs: 15_000,
signal: controller.signal,
})) {
console.log(tick.symbol, tick.price, tick.change);
}
controller.abort(); // stops the loopThe interval is measured from the start of each cycle, so a slow fetch pushes the next one out rather than stacking requests behind it.
PSX publishes data through two endpoints with very different properties.
dps.psx.com.pk carries most of it, but requires three things on every
request: an X-Req-Id header (a value inlined in page HTML), an
X-Requested-With: XMLHttpRequest header, and a browser User-Agent. That key
rotates - this client discovers it lazily and refreshes it automatically on
a 403.
www.psx.com.pk/market-summary/ requires none of that. A bare fetch works.
// Works even where dps.psx.com.pk is unreachable.
const psx = createPsxClient({ ungatedOnly: true });
const { data, source } = await psx.marketWatch();
// source === 'market-summary'
// 589 symbols, OHLC + volume. No change %, no sector codes.By default marketWatch() tries the richer gated source and degrades to the
ungated page rather than throwing:
const { data, source, notice } = await psx.marketWatch();
if (notice) console.warn(`using fallback: ${notice}`);A gated outage costs you changePct and index membership - not your data.
import { PsxAuthError, isPsxError } from 'psx-data-api';
try {
await psx.company('HBL');
} catch (error) {
if (error instanceof PsxAuthError) {
// PSX refused the gate key, even after a refresh.
} else if (isPsxError(error)) {
console.error(error.code, error.url);
}
}PsxError subclasses carry a code you can switch on: AUTH, NOT_FOUND,
RATE_LIMITED, TIMEOUT, NETWORK, SCHEMA, CONFIG, ABORTED, PARSE.
One quirk to expect: PSX answers an unknown ticker with HTTP 500, not 404,
so company('NOSUCHTICKER') surfaces as PsxNetworkError rather than a
not-found error. Verify a symbol against symbols() before calling if that
matters to you.
Parsers validate the page structure before extracting and throw
PsxSchemaError naming the exact selector or column that no longer matches.
PsxSchemaError: PSX response did not match expected structure
(selectors v1): market-watch data rows ... found 0 elements
This is deliberate. Most scrapers return an empty array when a site changes, which is indistinguishable from "market closed" - and that is exactly how several existing PSX libraries became silently broken. A loud, specific failure is recoverable; a silent empty array is not.
number | null, always. PSX genuinely publishes no value for some fields -
halted securities, thin coverage. Encoding that as 0 would render a stock at
zero and corrupt any average computed over it. Absence is data, and it is typed
as such.
Values come from data-order. Numeric cells carry both a formatted display
string and an unformatted machine value. This client reads the machine value, so
it never parses "73,446,994" and never loses precision.
Row-major, not column-major. You get Quote[], so
quotes.map(q => q.current) just works.
CORS blocks browsers. No endpoint on dps.psx.com.pk sends
access-control-allow-origin, so a browser cannot call PSX directly. Use this
package server-side - Node, a serverless function, or an edge worker. Do not
expect fetch from a React component to work.
const psx = createPsxClient();
const status = await psx.diagnostics();
status.hasKey; // is a gate key currently held?
status.keyAgeMs; // how old
status.keyRefreshCount; // how many times PSX rejected oneIf marketWatch() keeps falling back, PSX is refusing this host. PSX region-
blocks some cloud ranges; a deployment in South Asia works more reliably.
npm install
npm run check
npm run verify
npm run recordTests run entirely against recorded fixtures and never touch the network. npm run verify is the live check: it parses each source twice with independent
readers and compares every field.
Publishing and tagging are automated. The only manual steps are the version bump and the tag; the tag is what starts the pipeline.
npm version 0.3.0 --workspace psx-data-api --no-git-tag-version
git commit -am "chore: release 0.3.0"
git tag -a v0.3.0 -m "psx-data-api 0.3.0"
git push origin main --follow-tags--no-git-tag-version is required. Without it npm attempts its own commit and
tag, which races with the lockfile update and leaves you untagged.
Pushing the tag runs .github/workflows/release.yml, which refuses to go
further if the tag does not match the version in
packages/client/package.json, runs the full check and a clean build,
publishes to npm with provenance, then creates the GitHub release with notes
generated from the commits since the previous tag and the packed tarball
attached. If npm already has that version, the publish is skipped and only the
release is created, so a re-run is safe.
There is no NPM_TOKEN in this repository. CI authenticates to npm through
Trusted Publishing, configured once at
npmjs.com: package psx-data-api, publisher GitHub Actions, repository
adeerkhan/PSX-data, workflow release.yml.
To rehearse the whole pipeline without publishing, run the Release workflow by
hand from the Actions tab and leave dry_run on.
All data originates from the Pakistan Stock Exchange and belongs to PSX. This package is a client for publicly reachable endpoints; it does not grant any right to redistribute exchange data. Check PSX's terms before shipping it in a commercial product.
MIT. See LICENSE.
