Skip to content
adeerkhanPublic

Repository files navigation

psx-data-api

npm version CI license types node coverage verified

psx-data-api - typed client for the Pakistan Stock Exchange

Install from npm: npm install psx-data-api package | 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 | null

Install

npm install psx-data-api

Requires 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-origin on any endpoint, so a browser cannot call it directly - fetch from 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.

What it covers

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

Examples

Find the day's biggest movers

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}`);
}

Is the market open?

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;

Chart five years of history

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,
// }

Compare sectors

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`,
  );
}

Company fundamentals

const hbl = await psx.company('HBL');

hbl.totalShares;      // 1466852508
hbl.freeFloatShares;  // 586741003
hbl.description;      // free-text profile

hbl.warnings;         // [] when the page parsed cleanly

Caching and rate limiting

The 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.

Streaming

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 loop

The interval is measured from the start of each cycle, so a slow fetch pushes the next one out rather than stacking requests behind it.

Two data sources

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.

Errors

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.

When PSX changes its markup

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.

Design notes

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.

Troubleshooting

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 one

If marketWatch() keeps falling back, PSX is refusing this host. PSX region- blocks some cloud ranges; a deployment in South Asia works more reliably.

Development

npm install
npm run check
npm run verify
npm run record

Tests 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.

Releases

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.

Data source

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.

License

MIT. See LICENSE.