diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f162cd..f1162ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/routers/stocks.py b/api/routers/stocks.py index 9562122..35def47 100644 --- a/api/routers/stocks.py +++ b/api/routers/stocks.py @@ -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] diff --git a/tests/api/test_stocks.py b/tests/api/test_stocks.py index 6420bfe..6510d83 100644 --- a/tests/api/test_stocks.py +++ b/tests/api/test_stocks.py @@ -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"],