summaryrefslogtreecommitdiff
path: root/src/FundLab.Api
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 /src/FundLab.Api
parent7dd0e3f42105569b6e6ec02aeeec9b5a8bbd9dd3 (diff)
downloadfund-lab-b5a80f69cb2e2793e5134e23a0eb3e72cdbdbe0b.tar.gz
Add multi-source bond quote probe fields and daily fallback (3d-20)
Diffstat (limited to 'src/FundLab.Api')
-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
4 files changed, 141 insertions, 18 deletions
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)