Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

---

## [Unreleased]

### Fixed

- `/stocks/{symbol}/quote` no longer returns 404 while a stock has a pending corporate action. PSX lists such stocks under a suffixed ticker (`LUCK` as `LUCKXD` once it goes ex-dividend; `XB` ex-bonus, `XR` ex-rights), so the quote now falls back to that ticker, and a suffixed request falls back to the plain one once the suffix is dropped. The response's `symbol` is the ticker PSX currently lists. The fallback reads the already-cached screener and makes no extra PSX request.

---

## [0.5.0] — 2026-10-02

### Added
Expand Down
32 changes: 31 additions & 1 deletion api/routers/stocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,40 @@ def get_historical(
)


# While a corporate action is pending, PSX lists a stock under a suffixed ticker (LUCK trades as
# LUCKXD once it goes ex-dividend) and drops the suffix again afterwards. XD = ex-dividend,
# XB = ex-bonus, XR = ex-rights.
_CORPORATE_ACTION_SUFFIXES = ("XD", "XB", "XR")


def _quote_aliases(symbol: str) -> list[str]:
"""Other tickers the same stock may be listed under, in lookup order."""
base = symbol
if len(symbol) > 2 and symbol.endswith(_CORPORATE_ACTION_SUFFIXES):
base = symbol[:-2]
candidates = [base + suffix for suffix in _CORPORATE_ACTION_SUFFIXES] + [base]
return [c for c in candidates if c != symbol]


def _find_quote(psx: PsxSource, symbol: str) -> pd.DataFrame:
df = psx.fetch("quote", symbol)
if not df.empty:
return df
# quote() just loaded the screener into psxdata's cache, so this does not call PSX again
screener = psx.fetch("screener")
if screener.empty or "symbol" not in screener.columns:
return df
for alias in _quote_aliases(symbol):
match = screener[screener["symbol"] == alias]
if not match.empty:
return match.reset_index(drop=True)
return df


@router.get("/stocks/{symbol}/quote", response_model=QuoteResponse)
@limiter.limit("60/minute")
def get_quote(request: Request, symbol: str, psx: PsxSource = Depends(psx_source)) -> QuoteResponse:
df = psx.fetch("quote", symbol.upper())
df = _find_quote(psx, symbol.upper())
if df.empty:
raise HTTPException(status_code=404, detail=f"{symbol.upper()} not found")
row = _df_to_records(df)[0]
Expand Down
54 changes: 53 additions & 1 deletion tests/api/test_stocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,65 @@ def test_quote_returns_200(client: TestClient) -> None:


def test_quote_returns_404_for_unknown_symbol(client: TestClient) -> None:
with patch("psxdata.quote", return_value=pd.DataFrame()):
screener = pd.DataFrame({"symbol": ["ENGRO", "LUCKXD"], "price": [481.99, 1100.0]})
with (
patch("psxdata.quote", return_value=pd.DataFrame()),
patch("psxdata.screener", return_value=screener),
):
resp = client.get("/stocks/FAKE/quote")
assert resp.status_code == 404
body = resp.json()
assert body["error"]["code"] == "not_found"


def test_quote_exact_match_skips_screener(client: TestClient) -> None:
df = pd.DataFrame({"symbol": ["ENGRO"], "price": [481.99]})
with (
patch("psxdata.quote", return_value=df),
patch("psxdata.screener") as mock_screener,
):
resp = client.get("/stocks/ENGRO/quote")
assert resp.status_code == 200
mock_screener.assert_not_called()


@pytest.mark.parametrize(
("requested", "listed"),
[
("LUCK", "LUCKXD"), # ex-dividend window: PSX lists the suffixed ticker
("luck", "LUCKXD"),
("FCCL", "FCCLXB"),
("ISL", "ISLXR"),
("LUCKXD", "LUCK"), # window over: a client still asking for the suffixed ticker
("LUCKXD", "LUCKXB"), # one corporate action followed by another
],
)
def test_quote_falls_back_to_corporate_action_ticker(
client: TestClient, requested: str, listed: str
) -> None:
screener = pd.DataFrame({"symbol": ["ENGRO", listed], "price": [481.99, 1100.0]})
with (
patch("psxdata.quote", return_value=pd.DataFrame()),
patch("psxdata.screener", return_value=screener),
):
resp = client.get(f"/stocks/{requested}/quote")
assert resp.status_code == 200
body = resp.json()
assert body["data"]["symbol"] == listed
assert body["data"]["price"] == pytest.approx(1100.0)


def test_quote_does_not_match_unrelated_suffixes(client: TestClient) -> None:
"""Rights letters (JVDCR1) and other instruments are separate securities, not aliases."""
screener = pd.DataFrame({"symbol": ["JVDCR1", "XD"], "price": [5.0, 1.0]})
with (
patch("psxdata.quote", return_value=pd.DataFrame()),
patch("psxdata.screener", return_value=screener),
):
assert client.get("/stocks/JVDC/quote").status_code == 404
assert client.get("/stocks/XD/quote").status_code == 404


def test_fundamentals_returns_200_with_data(client: TestClient) -> None:
df = pd.DataFrame({
"symbol": ["ENGRO"], "year": ["2024"], "type": ["Annual"],
Expand Down
Loading