Back to Blog

PSX Investor: A FastAPI Tool for Disciplined Stock Investing on the Pakistan Stock Exchange

By · 7 min read
FastAPI Python Finance Open Source PSX API Design

Every month I invest a fixed PKR amount into a Shariah-compliant portfolio aligned with the KMI30 index. The discipline is simple: same amount, same target weights, buy whole shares only. The execution is not. I was opening a spreadsheet, looking up twelve tickers on the PSX data portal, dividing PKR 50,000 by each live price, flooring to whole shares, summing the costs, and manually checking that leftover cash was acceptable. One mistyped price or forgotten ticker skewed the allocation for the month.

PSX Investor is the local tool I built to automate that workflow. It is a FastAPI backend plus a single self-contained HTML page: enter an amount, get a buy plan with live prices, record what you actually purchased, and track positions at cost and market value. It never places trades. It is decision support, not a brokerage.

The hard part of disciplined investing is not picking stocks — it is doing the same correct arithmetic every month without letting friction erode the habit.

The problem: manual math does not scale with discipline

Target-weight investing on the PSX has constraints that generic portfolio calculators ignore:

  • Whole shares only — you cannot buy 3.7 shares of ENGRO; you buy 3 and carry leftover cash.
  • Live prices matter — intraday quotes shift the share count materially on a PKR 50,000 ticket.
  • Many holdings — a KMI30-aligned portfolio might hold a dozen names; each needs a price lookup and a floor division.
  • Tracking over time — after the buy, you need average cost, market value, and unrealized P/L at next month's prices.

Spreadsheets work until they do not. A stale price cell propagates through every row. Copy-paste from the PSX portal breaks when the page layout changes. And every month you repeat the same twelve lookups instead of clicking one button.

Why local, not SaaS

Personal finance data — target weights, purchase history, average cost basis, thesis notes — belongs on your machine. A SaaS portfolio tracker would need accounts, cloud storage, privacy policies, and ongoing maintenance for a tool I use once a month for twenty minutes.

PSX Investor runs on localhost:8787. Portfolio config and transaction history resolve to local JSON files (or a mounted private repo). No API keys, no signup, no data leaving the network except price fetches to the PSX data portal. The public GitHub repo ships a generic example allocation; your real weights and purchase history stay git-ignored or in a separate private repository.

This is infrastructure thinking applied to personal tooling: minimize moving parts, keep the trust boundary on your laptop, and make the default path safe for open-source sharing.

Architecture

The request path for the core workflow is straightforward:

browser  ->  GET /api/plan?amount=50000
             |
             FastAPI (app/main.py)
             |-> load_portfolio()      data/portfolio.json
             |-> psx.get_prices()      dps.psx.com.pk  (async, cached, fallback)
             |-> build_plan()          floor(amount * weight / price)
             <-  JSON: rows + totals

The browser never talks to PSX directly. The Python backend loads portfolio weights, fetches prices concurrently, computes the plan, and returns structured JSON the frontend renders as a table. Purchase recording and position aggregation follow the same pattern — local JSON in, enriched JSON out.

Method Path Purpose
GET /api/plan?amount=&optimize=&refresh= Full buy plan for a PKR amount
GET /api/prices?refresh= Latest prices for portfolio tickers
GET / PUT /api/portfolio Read or save edited holdings and weights
GET / POST / DELETE /api/transactions[/{id}] Purchase history CRUD
GET /api/positions?refresh= Accumulated holdings at live prices

The PSX price proxy pattern

The PSX data portal at dps.psx.com.pk exposes undocumented JSON endpoints — intraday ticks at /timeseries/int/{symbol} and end-of-day history at /timeseries/eod/{symbol}. Both return {"status": 1, "data": [[epoch, price, ...], ...]} with the newest row first.

These endpoints send no CORS headers. A browser fetch from localhost fails before the response arrives. The fix is not a browser extension or a CORS proxy service — it is server-side fetching in Python, which is exactly what FastAPI is for.

async def _fetch_one(client: httpx.AsyncClient, ticker: str) -> PriceInfo | None:
    """Fetch a single ticker: try intraday, then EOD."""
    for url, source in ((INT_URL, "intraday"), (EOD_URL, "eod")):
        try:
            resp = await client.get(url.format(symbol=ticker))
            resp.raise_for_status()
            parsed = _parse_series(resp.json())
        except (httpx.HTTPError, json.JSONDecodeError, ValueError):
            parsed = None
        if parsed is not None:
            price, epoch = parsed
            return PriceInfo(
                ticker=ticker, price=price, source=source, as_of=epoch, stale=False
            )
    return None

PSX may reject requests without a browser-like User-Agent, so the client sends standard browser headers. All tickers fetch concurrently via asyncio.gather. The frontend sees a clean JSON price map; it never needs to know about CORS, referer headers, or the intraday-vs-EOD fallback chain.

Whole-share math and leftover optimization

The buy plan core is intentionally boring — that is the point. For each holding, target PKR is amount * weight / 100, shares are floor(target_pkr / price), and cost is shares * price. Actual weight is whatever you actually spent divided by the total amount.

for h in portfolio.holdings:
    info = prices.get(h.ticker, PriceInfo(ticker=h.ticker))
    target_pkr = amount * h.weight / 100.0
    if info.price and info.price > 0:
        shares = int(math.floor(target_pkr / info.price))
        cost = shares * info.price
    else:
        shares, cost = 0, 0.0
    rows.append(PlanRow(
        ticker=h.ticker,
        weight=h.weight,
        price=info.price,
        stale=info.stale,
        target_pkr=round(target_pkr, 2),
        shares=shares,
        cost=round(cost, 2),
        actual_weight=round((cost / amount * 100) if amount else 0.0, 2),
    ))

Flooring guarantees whole shares but leaves leftover cash — often 3–8% of the ticket on a diversified portfolio. An optional optimize=true flag runs a greedy leftover pass: buy one share at a time on the most underweight affordable name until cash runs out.

def _optimize_leftover(rows: list[PlanRow], amount: float, prices: dict) -> None:
    """Spend residual cash on the most-underweight affordable holding."""
    leftover = amount - sum(r.cost for r in rows)
    for _ in range(10_000):
        best, best_gap = None, 0.0
        for row in rows:
            if not row.price or row.price > leftover:
                continue
            gap = row.weight - row.actual_weight
            if gap > best_gap:
                best_gap, best = gap, row
        if best is None:
            break
        best.shares += 1
        best.cost += best.price
        best.actual_weight = (best.cost / amount * 100) if amount else 0.0
        leftover -= best.price

The optimizer is not mean-variance rebalancing — it is a practical way to deploy cash that would otherwise sit idle until next month. The UI shows both target and actual weight per row so you can see exactly where the drift is before you place orders with your broker.

Resilient price fetching: cache, stale fallback, flag

A buy-plan tool that errors out when PSX hiccups is worse than a spreadsheet. PSX Investor treats price availability as a reliability problem, not a binary success/failure.

The fetch pipeline has three layers:

  1. In-memory TTL cache — 60-second window avoids hammering PSX on rapid refreshes.
  2. Persistent disk cachedata/prices_cache.json mirrors last-good prices across restarts.
  3. Stale fallback — if live fetch fails, return the cached price with stale=True instead of breaking the plan.
for ticker, info in zip(to_fetch, fetched, strict=True):
    if isinstance(info, PriceInfo):
        result[ticker] = info
        _memory_cache[ticker] = (info, time.monotonic())
        persistent[ticker] = {"price": info.price, "source": info.source, "as_of": info.as_of}
    else:
        prev = persistent.get(ticker)
        if prev and prev.get("price"):
            result[ticker] = PriceInfo(
                ticker=ticker, price=prev["price"], source="cache",
                as_of=prev.get("as_of"), stale=True,
            )
        else:
            result[ticker] = PriceInfo(ticker=ticker)  # unavailable

The plan response includes any_stale: true when any row used a fallback price. The UI can surface a warning; you decide whether to refresh or proceed with last-known quotes. Unavailable tickers get zero shares rather than a 500 error — the plan still renders for the names that priced successfully.

Private vs public repo split

Open-sourcing a portfolio tool creates a tension: you want others to clone and run it, but you cannot commit your real allocation or purchase history. PSX Investor resolves config through a priority chain:

def _read_path() -> Path:
    """Resolve which config to read: private repo -> local data -> shipped example."""
    for path in (PRIVATE_DIR / "portfolio.json", DATA_DIR / "portfolio.json", EXAMPLE_FILE):
        if path.exists():
            return path
    return EXAMPLE_FILE

def _write_path() -> Path:
    """Where edits are saved: into the private repo if it exists, else local data/."""
    target_dir = PRIVATE_DIR if PRIVATE_DIR.exists() else DATA_DIR
    return target_dir / "portfolio.json"

A fresh clone reads data/portfolio.example.json — a generic KMI30-style allocation with placeholder weights. When you edit in the browser, saves go to data/portfolio.json, which is git-ignored. If you mount a separate private repository at private/, that takes priority for both reads and writes — portfolio config, thesis notes, and transaction history stay out of the public repo entirely.

Transactions follow the same pattern: private/transactions.jsondata/transactions.json, both git-ignored, no committed example. The public repo demonstrates the tool; the private repo holds your actual financial state.

One-page frontend, no build step

PSX Investor deliberately avoids a Node toolchain. The frontend is a single static/index.html with inline CSS and vanilla JavaScript — fetch calls to /api/plan, table rendering, an edit-weights modal, and a transaction recorder prefilled from the plan rows.

FastAPI serves it directly:

@app.get("/")
async def index() -> FileResponse:
    return FileResponse(STATIC_DIR / "index.html")

app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")

One bash run.sh creates a venv, installs dependencies, and starts uvicorn on port 8787. Interactive API docs live at /docs for debugging. For a personal tool used monthly, zero build step means zero "npm audit fix" before you can check your allocation.

Positions and purchase tracking

After placing orders with your broker, you record what you actually bought — shares and fill price, prefilled from the plan. The /api/positions endpoint aggregates transactions into holdings with average cost, market value at live prices, and unrealized P/L. This closes the loop: plan → execute → record → measure drift over months.

Layer Responsibility Failure mode handled
Price proxy Server-side PSX fetch, CORS bypass Browser cannot reach PSX JSON endpoints
Plan engine Whole-share floor + optional optimizer Fractional-share fantasy allocations
Cache tier Memory TTL + disk + stale flag Transient PSX outage breaks monthly workflow
Config resolution private/ → data/ → example Real allocation committed to public repo
Static UI Single HTML, no bundler Tool rot from abandoned frontend deps

Running it locally

The repo is intentionally small — Python 3.12, FastAPI, httpx, and Pydantic models. Clone, run, open the browser:

git clone https://github.com/humzakt/psx-investor.git
cd psx-investor
bash run.sh
# open http://localhost:8787

The plan endpoint accepts query parameters directly, which makes curl-based sanity checks easy before you trust the UI:

curl "http://localhost:8787/api/plan?amount=50000&optimize=true"

Interactive OpenAPI docs at /docs expose every route — useful when debugging stale-price flags or validating a portfolio edit before saving.

What I would extend next

The current tool stops where a brokerage API would begin — it computes and records, never executes. Reasonable extensions: export the plan as CSV for broker order entry, add dividend reinvestment tracking, or wire a scheduled price refresh for end-of-day batch planning. Each would stay local-first.

The pattern generalizes beyond PSX: any market with a scrapeable or undocumented price feed, whole-share constraints, and a desire to keep personal data off someone else's cloud is a candidate for the same FastAPI-proxy-plus-static-UI architecture.

Disclaimer: PSX Investor is not financial advice. Prices come from unofficial endpoints and may be delayed or wrong. Do your own research and consult a licensed advisor before investing.

Related Articles