summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 03:48:12 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 03:48:12 +0800
commitb5a80f69cb2e2793e5134e23a0eb3e72cdbdbe0b (patch)
treeeeb22106ceb0d1eb0fe07b7bff18450af11e0d50
parent7dd0e3f42105569b6e6ec02aeeec9b5a8bbd9dd3 (diff)
downloadfund-lab-b5a80f69cb2e2793e5134e23a0eb3e72cdbdbe0b.tar.gz
Add multi-source bond quote probe fields and daily fallback (3d-20)
-rw-r--r--qa/driver/browser-test.js23
-rwxr-xr-xqa/stub/fund-lab-python13
-rw-r--r--src/FundLab.Api/App.fs8
-rw-r--r--src/FundLab.Api/BondQuoteProbe.fs14
-rw-r--r--src/FundLab.Api/MarketData.fs25
-rw-r--r--src/FundLab.Api/akshare_collector.py112
-rw-r--r--src/FundLab.Web/App.fs12
-rw-r--r--tests/FundLab.Api.Tests/BondQuoteProbeTests.fs74
-rw-r--r--tests/FundLab.Web.Tests/BoundaryTests.fs35
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<JsonElement>
+
+ 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<string, string>) =
{ new IMarketDataCollector with
member _.Search(_, _) = Ok ""
@@ -89,15 +105,51 @@ type BondQuoteProbeTests(fixture: PostgresFixture) =
| Error failure -> failwithf "expected a bond quote, got %A" failure
[<Fact>]
+ 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<decimal option>(Some 119.858m, quote.Price)
+ Assert.Equal<decimal option>(Some 119.858m, quote.CleanPrice)
+ Assert.Equal<decimal option>(Some 0.5m, quote.AccruedInterest)
+ Assert.Equal<DateOnly option>(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
+
+ [<Fact>]
+ 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
+
+ [<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")
+ 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
[<Fact>]
@@ -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
@@ -474,17 +482,38 @@ module ConfirmationBoundaryTests =
Assert.False(updated.bondQuoteInFlight)
[<Fact>]
+ 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))
+
+ [<Fact>]
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)
[<Fact>]