From b5a80f69cb2e2793e5134e23a0eb3e72cdbdbe0b Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 03:48:12 +0800 Subject: Add multi-source bond quote probe fields and daily fallback (3d-20) --- qa/driver/browser-test.js | 23 ++++- qa/stub/fund-lab-python | 13 +-- src/FundLab.Api/App.fs | 8 ++ src/FundLab.Api/BondQuoteProbe.fs | 14 ++++ src/FundLab.Api/MarketData.fs | 25 ++++++ src/FundLab.Api/akshare_collector.py | 112 +++++++++++++++++++++---- src/FundLab.Web/App.fs | 12 +++ tests/FundLab.Api.Tests/BondQuoteProbeTests.fs | 74 ++++++++++++++-- tests/FundLab.Web.Tests/BoundaryTests.fs | 35 +++++++- 9 files changed, 279 insertions(+), 37 deletions(-) diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js index 401cb78..22385f8 100644 --- a/qa/driver/browser-test.js +++ b/qa/driver/browser-test.js @@ -669,7 +669,7 @@ async function bondsScenario(page) { `quote=${JSON.stringify(quote).slice(0, 160)} result=${resultText.slice(0, 160)}` ); - await page.fill(".bond-code-input", "019547"); + await page.fill(".bond-code-input", "999997"); await page.click(".bond-quote-action"); await page.waitForFunction( () => { @@ -685,7 +685,7 @@ async function bondsScenario(page) { const token = process.env.QA_TOKEN || "qa-token"; const failure = await page.evaluate(async (token) => { - const response = await fetch("/api/market/bond-quote?code=019547", { + const response = await fetch("/api/market/bond-quote?code=999997", { headers: { Authorization: `Bearer ${token}` }, }); let body = null; @@ -705,6 +705,25 @@ async function bondsScenario(page) { `message=${unavailableText.slice(0, 200)} status=${failure.status}` ); + await page.fill(".bond-code-input", "019547"); + await page.click(".bond-quote-action"); + await page.waitForSelector(".bond-quote-result", { timeout: 10000 }); + + const dailyText = ((await page.textContent(".bond-quote-result")) || "").trim(); + const dailyQuote = lastBondQuoteResponse || {}; + + check( + "B3 国债日线路径返回行情日期与净价字符串", + dailyQuote.code === "019547" && + typeof dailyQuote.price === "string" && + dailyQuote.price.length > 0 && + typeof dailyQuote.date === "string" && + dailyQuote.date.length > 0 && + dailyText.includes(dailyQuote.price) && + dailyText.includes(dailyQuote.date), + `quote=${JSON.stringify(dailyQuote).slice(0, 200)}` + ); + await page.screenshot({ path: SHOTS + "/21-bonds.png" }); } diff --git a/qa/stub/fund-lab-python b/qa/stub/fund-lab-python index c9ce981..88372b8 100755 --- a/qa/stub/fund-lab-python +++ b/qa/stub/fund-lab-python @@ -32,13 +32,16 @@ case "$OP" in printf '{"schema_version":"fund-lab.akshare.v1","operation":"nav","source":"stub-synthetic","source_revision":"stub-1","_synthetic":true,"collected_at":"%s","instrument":{"code":"%s"},"observations":[{"nav_date":"2026-09-15","published_at":null,"nav":"1.1000","accumulated_nav":null,"daily_return":null},{"nav_date":"2026-09-16","published_at":null,"nav":"1.1205","accumulated_nav":null,"daily_return":"1.86"},{"nav_date":"2026-09-17","published_at":null,"nav":"1.1180","accumulated_nav":null,"daily_return":"-0.22"},{"nav_date":"2026-09-18","published_at":null,"nav":"1.1500","accumulated_nav":null,"daily_return":"2.86"},{"nav_date":"2026-09-21","published_at":null,"nav":"1.1902","accumulated_nav":null,"daily_return":"3.49"}]}\n' "$TS" "$CODE" ;; bond-quote) - # Synthetic rows mirror the live AKShare convertible-bond spot contract: - # a known live code answers with a name/price, unknown codes fail like the - # "no valid quote" collector error (-> API 503). + # Synthetic rows mirror the live AKShare bond contract: a convertible code + # answers from the spot path (name + price), a treasury-style code answers + # from the daily path (date + clean price, no name), and unknown codes fail + # like the "no valid bond quote" collector error (-> API 503). if [ "$CODE" = "110075" ]; then - printf '{"schema_version":"fund-lab.akshare.v1","operation":"bond-quote","source":"stub-synthetic","source_revision":"stub-1","_synthetic":true,"collected_at":"%s","instrument":{"code":"%s"},"quote":{"name":"南航转债","price":"106.368","maturity_date":"2026-10-15"}}\n' "$TS" "$CODE" + printf '{"schema_version":"fund-lab.akshare.v1","operation":"bond-quote","source":"stub-synthetic","source_revision":"stub-1/hs-cov-spot","_synthetic":true,"collected_at":"%s","instrument":{"code":"%s"},"quote":{"name":"南航转债","price":"106.368","clean_price":"106.368","accrued_interest":null,"date":null,"maturity_date":"2026-10-15"}}\n' "$TS" "$CODE" + elif [ "$CODE" = "019547" ]; then + printf '{"schema_version":"fund-lab.akshare.v1","operation":"bond-quote","source":"stub-synthetic","source_revision":"stub-1/hs-bond-daily","_synthetic":true,"collected_at":"%s","instrument":{"code":"%s"},"quote":{"name":null,"price":"119.858","clean_price":"119.858","accrued_interest":null,"date":"2026-09-21","maturity_date":null}}\n' "$TS" "$CODE" else - echo "AKShare collector failed: no valid quote for $CODE" >&2 + echo "AKShare collector failed: no valid bond quote for $CODE" >&2 exit 2 fi ;; diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 4313329..82f695e 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -364,8 +364,12 @@ type MarketProbes = type BondQuoteApiResponse = { code: string + sourceRevision: string name: string option price: string option + cleanPrice: string option + accruedInterest: string option + date: string option maturityDate: string option } @@ -1744,8 +1748,12 @@ module App = | Ok quote -> json ({ code = quote.Code + sourceRevision = quote.SourceRevision name = quote.Name price = quote.Price |> Option.map decimalText + cleanPrice = quote.CleanPrice |> Option.map decimalText + accruedInterest = quote.AccruedInterest |> Option.map decimalText + date = quote.Date |> Option.map dateText maturityDate = quote.MaturityDate |> Option.map dateText } : BondQuoteApiResponse) next diff --git a/src/FundLab.Api/BondQuoteProbe.fs b/src/FundLab.Api/BondQuoteProbe.fs index 0cb7c3b..311dec5 100644 --- a/src/FundLab.Api/BondQuoteProbe.fs +++ b/src/FundLab.Api/BondQuoteProbe.fs @@ -10,8 +10,12 @@ open System.Threading type BondQuote = { Code: string + SourceRevision: string Name: string option Price: decimal option + CleanPrice: decimal option + AccruedInterest: decimal option + Date: DateOnly option MaturityDate: DateOnly option } @@ -49,12 +53,22 @@ type AkshareBondQuoteProbe(collector: IMarketDataCollector) = | Ok json -> match MarketData.parseBondQuotePayload json with | Error message -> Error(InvalidMarketDataPayload message) + | Ok payload when not (String.Equals(payload.Code, normalized, StringComparison.Ordinal)) -> + Error( + InvalidMarketDataPayload( + sprintf "payload code '%s' does not match requested code '%s'" payload.Code normalized + ) + ) | Ok payload -> let quote = { Code = payload.Code + SourceRevision = payload.SourceRevision Name = payload.Name Price = payload.Price + CleanPrice = payload.CleanPrice + AccruedInterest = payload.AccruedInterest + Date = payload.Date MaturityDate = payload.MaturityDate } diff --git a/src/FundLab.Api/MarketData.fs b/src/FundLab.Api/MarketData.fs index 82da1c9..e621740 100644 --- a/src/FundLab.Api/MarketData.fs +++ b/src/FundLab.Api/MarketData.fs @@ -45,6 +45,9 @@ type MarketDataBondQuotePayload = Code: string Name: string option Price: decimal option + CleanPrice: decimal option + AccruedInterest: decimal option + Date: DateOnly option MaturityDate: DateOnly option } @@ -131,6 +134,16 @@ module MarketData = else Error(sprintf "payload property '%s' is required" name) + let private optionalProperty (root: JsonElement) (name: string) = + let mutable property = Unchecked.defaultof + + if root.ValueKind <> JsonValueKind.Object then + Error "payload root must be a JSON object" + elif root.TryGetProperty(name, &property) then + Ok(Some property) + else + Ok None + let private requiredString (root: JsonElement) name = requiredProperty root name |> Result.bind (fun property -> @@ -366,6 +379,15 @@ module MarketData = let! name = optionalString quoteProperty "name" let! priceProperty = requiredProperty quoteProperty "price" let! price = optionalDecimal "price" priceProperty + let! cleanPrice = + optionalProperty quoteProperty "clean_price" + |> Result.bind (function Some property -> optionalDecimal "clean_price" property | None -> Ok None) + let! accruedInterest = + optionalProperty quoteProperty "accrued_interest" + |> Result.bind (function Some property -> optionalDecimal "accrued_interest" property | None -> Ok None) + let! date = + optionalProperty quoteProperty "date" + |> Result.bind (function Some property -> optionalIsoDate "date" property | None -> Ok None) let! maturityProperty = requiredProperty quoteProperty "maturity_date" let! maturityDate = optionalIsoDate "maturity_date" maturityProperty @@ -377,6 +399,9 @@ module MarketData = Code = codeProperty Name = name Price = price + CleanPrice = cleanPrice + AccruedInterest = accruedInterest + Date = date MaturityDate = maturityDate } } diff --git a/src/FundLab.Api/akshare_collector.py b/src/FundLab.Api/akshare_collector.py index f9a0f06..2fb837e 100644 --- a/src/FundLab.Api/akshare_collector.py +++ b/src/FundLab.Api/akshare_collector.py @@ -162,14 +162,12 @@ def bond_maturity_date(code): 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") - +def bond_quote_from_spot(code): + # Realtime convertible-bond snapshot (sina). Gives name/trade but no date or + # accrued-interest fields; returns None when the code carries no valid price. frame = ak.bond_zh_hs_cov_spot() if frame is None or frame.empty: - raise ValueError(f"no valid quote for {code}") + return None prefix_rank = {"sh": 0, "sz": 1, "bj": 2} candidates = [] @@ -188,25 +186,103 @@ def bond_quote(code): candidates.append((prefix_rank.get(prefix, 3), symbol, text(row.get("name")), price)) if not candidates: - raise ValueError(f"no valid quote for {code}") + return None - _, _, name, price = sorted(candidates, key=lambda item: (item[0], item[1]))[0] + _, symbol, name, price = sorted(candidates, key=lambda item: (item[0], item[1]))[0] + revision = source_revision() return { - "schema_version": SCHEMA_VERSION, - "operation": "bond-quote", "source": "akshare", - "source_revision": source_revision(), - "collected_at": collected_at(), - "instrument": {"code": code}, - "quote": { - "name": name, - "price": price, - "maturity_date": bond_maturity_date(code), - }, + "source_revision": f"{revision}/hs-cov-spot", + "name": name, + "price": price, + "clean_price": price, + "accrued_interest": None, + "date": None, + "maturity_date": bond_maturity_date(code), + "symbol": symbol, } +def bond_quote_from_daily(code): + # Exchange bond daily history (sina). The venue is not derivable from the + # six-digit code alone, so try both prefixes; the first that returns rows + # wins. Gives a settled close and the trade date but no accrued-interest + # breakdown. + for prefix in ("sh", "sz"): + symbol = f"{prefix}{code}" + + try: + frame = ak.bond_zh_hs_daily(symbol=symbol) + except Exception: + continue + + if frame is None or frame.empty or "date" not in frame.columns or "close" not in frame.columns: + continue + + rows = [] + + for _, row in frame.iterrows(): + row_date = date_text(row.get("date")) + close = decimal_text(row.get("close")) + if row_date is None or close is None or Decimal(close) <= 0: + continue + rows.append((row_date, close)) + + if not rows: + continue + + rows.sort(key=lambda item: item[0]) + row_date, close = rows[-1] + revision = source_revision() + + return { + "source": "akshare", + "source_revision": f"{revision}/hs-bond-daily", + "name": None, + "price": close, + "clean_price": close, + "accrued_interest": None, + "date": row_date, + "maturity_date": bond_maturity_date(code), + "symbol": symbol, + } + + 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") + + for attempt in (bond_quote_from_spot, bond_quote_from_daily): + try: + quote = attempt(code) + except Exception: + quote = None + + if quote is not None: + return { + "schema_version": SCHEMA_VERSION, + "operation": "bond-quote", + "source": quote["source"], + "source_revision": quote["source_revision"], + "collected_at": collected_at(), + "instrument": {"code": code}, + "quote": { + "name": quote["name"], + "price": quote["price"], + "clean_price": quote["clean_price"], + "accrued_interest": quote["accrued_interest"], + "date": quote["date"], + "maturity_date": quote["maturity_date"], + }, + } + + raise ValueError(f"no valid bond quote for {code}") + + def first_positive_decimal(*values): for value in values: price = decimal_text(value) diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 7a0e56c..d18cff9 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -397,6 +397,9 @@ type RawBondQuote = code: string name: obj price: obj + cleanPrice: obj + accruedInterest: obj + date: obj maturityDate: obj } @@ -727,6 +730,9 @@ type BondQuoteView = code: string name: string option price: string option + cleanPrice: string option + accruedInterest: string option + quoteDate: string option maturityDate: string option } @@ -1081,6 +1087,9 @@ module Api = code = raw.code name = decodeOptionalText raw.name price = decodeOptionalText raw.price + cleanPrice = decodeOptionalText raw.cleanPrice + accruedInterest = decodeOptionalText raw.accruedInterest + quoteDate = decodeOptionalText raw.date maturityDate = decodeOptionalText raw.maturityDate } @@ -4759,6 +4768,9 @@ let private bondsPanel model dispatch = prop.children [ bondQuoteMetric "名称" quote.name bondQuoteMetric "价格" quote.price + bondQuoteMetric "净价" quote.cleanPrice + bondQuoteMetric "应计利息" quote.accruedInterest + bondQuoteMetric "行情日期" quote.quoteDate bondQuoteMetric "到期日" quote.maturityDate ] ] diff --git a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs index bed590a..1e19f77 100644 --- a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs +++ b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs @@ -6,19 +6,35 @@ open Xunit open FundLab.Api module BondQuoteProbeSupport = - let payload (code: string) (name: string option) (price: string option) (maturity: string option) = - let optionalText = - function - | Some value -> sprintf "\"%s\"" value - | None -> "null" - + let private optionalText = + function + | Some value -> sprintf "\"%s\"" value + | None -> "null" + + let payloadWith + (code: string) + (name: string option) + (price: string option) + (maturity: string option) + (cleanPrice: string option) + (accruedInterest: string option) + (date: string option) + (sourceRevision: string) + = sprintf - "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"bond-quote\",\"source\":\"akshare\",\"source_revision\":\"akshare-test/eastmoney\",\"collected_at\":\"2026-09-21T08:00:00Z\",\"instrument\":{\"code\":\"%s\"},\"quote\":{\"name\":%s,\"price\":%s,\"maturity_date\":%s}}" + "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"bond-quote\",\"source\":\"akshare\",\"source_revision\":\"%s\",\"collected_at\":\"2026-09-21T08:00:00Z\",\"instrument\":{\"code\":\"%s\"},\"quote\":{\"name\":%s,\"price\":%s,\"clean_price\":%s,\"accrued_interest\":%s,\"date\":%s,\"maturity_date\":%s}}" + sourceRevision code (optionalText name) (optionalText price) + (optionalText cleanPrice) + (optionalText accruedInterest) + (optionalText date) (optionalText maturity) + let payload (code: string) (name: string option) (price: string option) (maturity: string option) = + payloadWith code name price maturity None None None "akshare-test/eastmoney" + let collectorReturning (fetch: unit -> Result) = { new IMarketDataCollector with member _.Search(_, _) = Ok "" @@ -88,16 +104,52 @@ type BondQuoteProbeTests(fixture: PostgresFixture) = Assert.Equal(None, quote.MaturityDate) | Error failure -> failwithf "expected a bond quote, got %A" failure + [] + member _.``probe parses clean price, accrued interest and date when present``() = + let json = + BondQuoteProbeSupport.payloadWith + "019547" + None + (Some "119.858") + None + (Some "119.858") + (Some "0.5000") + (Some "2026-09-21") + "akshare-test/eastmoney/hs-bond-daily" + + let probe = + AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IBondQuoteProbe + + match probe.GetQuote("019547", CancellationToken.None) with + | Ok quote -> + Assert.Equal(Some 119.858m, quote.Price) + Assert.Equal(Some 119.858m, quote.CleanPrice) + Assert.Equal(Some 0.5m, quote.AccruedInterest) + Assert.Equal(Some(DateOnly(2026, 9, 21)), quote.Date) + Assert.Equal("akshare-test/eastmoney/hs-bond-daily", quote.SourceRevision) + | Error failure -> failwithf "expected a bond quote, got %A" failure + + [] + member _.``probe rejects a payload whose code disagrees with the request``() = + let json = BondQuoteProbeSupport.payload "123121" (Some "帝尔转债") (Some "101.5000") None + + let probe = + AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IBondQuoteProbe + + match probe.GetQuote("110075", CancellationToken.None) with + | Error(InvalidMarketDataPayload message) -> Assert.Contains("does not match requested code", message) + | other -> failwithf "expected InvalidMarketDataPayload, got %A" other + [] member _.``probe surfaces a no valid quote collector failure``() = let probe = AkshareBondQuoteProbe( - BondQuoteProbeSupport.collectorReturning (fun () -> Error "AKShare collector failed: no valid quote for 999999") + BondQuoteProbeSupport.collectorReturning (fun () -> Error "AKShare collector failed: no valid bond quote for 999999") ) :> IBondQuoteProbe match probe.GetQuote("999999", CancellationToken.None) with - | Error(MarketDataCollectorUnavailable message) -> Assert.Contains("no valid quote for 999999", message) + | Error(MarketDataCollectorUnavailable message) -> Assert.Contains("no valid bond quote for 999999", message) | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other [] @@ -166,8 +218,12 @@ type BondQuoteProbeTests(fixture: PostgresFixture) = Ok { Code = "123121" + SourceRevision = "akshare-test/eastmoney/hs-cov-spot" Name = Some "帝尔转债" Price = Some 101.5m + CleanPrice = Some 101.5m + AccruedInterest = None + Date = None MaturityDate = Some(DateOnly(2026, 6, 3)) }) diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs index 4714632..2892633 100644 --- a/tests/FundLab.Web.Tests/BoundaryTests.fs +++ b/tests/FundLab.Web.Tests/BoundaryTests.fs @@ -463,7 +463,15 @@ module ConfirmationBoundaryTests = let model = init () let raw : RawBondQuote = - { code = "110075"; name = box "南航转债"; price = box "106.368"; maturityDate = null } + { + code = "110075" + name = box "南航转债" + price = box "106.368" + cleanPrice = null + accruedInterest = null + date = null + maturityDate = null + } let updated, _ = update (BondQuoteCompleted(model.bondQuoteSeq, raw)) model @@ -473,18 +481,39 @@ module ConfirmationBoundaryTests = Assert.Equal(None, updated.bondQuoteMessage) Assert.False(updated.bondQuoteInFlight) + [] + let ``bond quote completion keeps clean price, accrued interest and quote date`` () = + let model = init () + + let raw : RawBondQuote = + { + code = "019547" + name = null + price = box "119.858" + cleanPrice = box "119.858" + accruedInterest = box "0.5000" + date = box "2026-09-21" + maturityDate = null + } + + let updated, _ = update (BondQuoteCompleted(model.bondQuoteSeq, raw)) model + + Assert.Equal(Some "119.858", updated.bondQuote |> Option.bind (fun quote -> quote.cleanPrice)) + Assert.Equal(Some "0.5000", updated.bondQuote |> Option.bind (fun quote -> quote.accruedInterest)) + Assert.Equal(Some "2026-09-21", updated.bondQuote |> Option.bind (fun quote -> quote.quoteDate)) + [] let ``bond quote failure shows an unavailable message without a quote`` () = let model = { init () with bondQuoteInFlight = true } let updated, _ = - update (BondQuoteFailed(model.bondQuoteSeq, "AKShare collector failed: no valid quote for 019547")) model + update (BondQuoteFailed(model.bondQuoteSeq, "AKShare collector failed: no valid bond quote for 019547")) model Assert.Equal(None, updated.bondQuote) let message = updated.bondQuoteMessage |> Option.defaultValue "" Assert.Contains("不可用", message) - Assert.Contains("no valid quote for 019547", message) + Assert.Contains("no valid bond quote for 019547", message) Assert.False(updated.bondQuoteInFlight) [] -- cgit v1.2.3