diff options
| -rw-r--r-- | src/FundLab.Api/akshare_collector.py | 43 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/BondQuoteProbeTests.fs | 27 |
2 files changed, 61 insertions, 9 deletions
diff --git a/src/FundLab.Api/akshare_collector.py b/src/FundLab.Api/akshare_collector.py index 6543923..652f32d 100644 --- a/src/FundLab.Api/akshare_collector.py +++ b/src/FundLab.Api/akshare_collector.py @@ -150,22 +150,47 @@ def nav(code): } +def bond_maturity_date(code): + # Maturity is a best-effort enrichment: the quote endpoint has no expiry + # date, so a failure here only means maturity_date stays null. + try: + frame = ak.bond_zh_cov_info(symbol=code, indicator="基本信息") + if frame is None or frame.empty: + return None + return date_text(frame.iloc[0].get("EXPIRE_DATE")) + except Exception: + return None + + def bond_quote(code): code = text(code) if code is None or not re.fullmatch(r"\d{6}", code): raise ValueError("bond code must contain exactly six digits") - frame = ak.bond_zh_cov_info(symbol=code, indicator="基本信息") + frame = ak.bond_zh_hs_cov_spot() if frame is None or frame.empty: - raise ValueError(f"AKShare returned no bond info for {code}") + raise ValueError(f"no valid quote for {code}") + + prefix_rank = {"sh": 0, "sz": 1, "bj": 2} + candidates = [] + + for _, row in frame.iterrows(): + symbol = text(row.get("symbol")) or "" + row_code = text(row.get("code")) or "" + if row_code != code and not symbol.endswith(code): + continue + + price = decimal_text(row.get("trade")) + if price is None or Decimal(price) <= 0: + continue + + prefix = symbol[:2].lower() + candidates.append((prefix_rank.get(prefix, 3), symbol, text(row.get("name")), price)) - row = frame.iloc[0] - name = text(row.get("SECURITY_NAME_ABBR")) - price = decimal_text(row.get("CURRENT_BOND_PRICE")) - maturity_date = date_text(row.get("EXPIRE_DATE")) + if not candidates: + raise ValueError(f"no valid quote for {code}") - if name is None and price is None and maturity_date is None: - raise ValueError(f"AKShare returned no usable bond quote fields for {code}") + _, _, name, price = sorted(candidates, key=lambda item: (item[0], item[1]))[0] return { "schema_version": SCHEMA_VERSION, @@ -177,7 +202,7 @@ def bond_quote(code): "quote": { "name": name, "price": price, - "maturity_date": maturity_date, + "maturity_date": bond_maturity_date(code), }, } diff --git a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs index a94bf58..442ed7c 100644 --- a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs +++ b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs @@ -70,6 +70,33 @@ type BondQuoteProbeTests(fixture: PostgresFixture) = | Error failure -> failwithf "expected a bond quote, got %A" failure [<Fact>] + member _.``probe parses a spot-shaped payload where maturity is unknown``() = + let json = BondQuoteProbeSupport.payload "110075" (Some "南航转债") (Some "106.368") None + + let probe = + AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IBondQuoteProbe + + match probe.GetQuote("110075", CancellationToken.None) with + | Ok quote -> + Assert.Equal("110075", quote.Code) + Assert.Equal<string option>(Some "南航转债", quote.Name) + Assert.Equal<decimal option>(Some 106.368m, quote.Price) + Assert.Equal<DateOnly option>(None, quote.MaturityDate) + | Error failure -> failwithf "expected a bond quote, got %A" failure + + [<Fact>] + member _.``probe surfaces a no valid quote collector failure``() = + let probe = + AkshareBondQuoteProbe( + BondQuoteProbeSupport.collectorReturning (fun () -> Error "AKShare collector failed: no valid quote for 999999") + ) + :> IBondQuoteProbe + + match probe.GetQuote("999999", CancellationToken.None) with + | Error(MarketDataCollectorUnavailable message) -> Assert.Contains("no valid quote for 999999", message) + | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other + + [<Fact>] member _.``probe rejects a malformed bond code without touching the collector``() = let probe = AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> failwith "collector should not be called")) |
