diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 03:48:12 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 03:48:12 +0800 |
| commit | b5a80f69cb2e2793e5134e23a0eb3e72cdbdbe0b (patch) | |
| tree | eeb22106ceb0d1eb0fe07b7bff18450af11e0d50 /src | |
| parent | 7dd0e3f42105569b6e6ec02aeeec9b5a8bbd9dd3 (diff) | |
| download | fund-lab-b5a80f69cb2e2793e5134e23a0eb3e72cdbdbe0b.tar.gz | |
Add multi-source bond quote probe fields and daily fallback (3d-20)
Diffstat (limited to 'src')
| -rw-r--r-- | src/FundLab.Api/App.fs | 8 | ||||
| -rw-r--r-- | src/FundLab.Api/BondQuoteProbe.fs | 14 | ||||
| -rw-r--r-- | src/FundLab.Api/MarketData.fs | 25 | ||||
| -rw-r--r-- | src/FundLab.Api/akshare_collector.py | 112 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 12 |
5 files changed, 153 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) 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 ] ] |
