From 3cc0b4dcfeed042e5a69628c6b7f45580ad1e2f9 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 01:12:52 +0800 Subject: Add real AKShare bond quote probe slice (3d-14) --- src/FundLab.Api/App.fs | 26 ++++ src/FundLab.Api/BondQuoteProbe.fs | 62 ++++++++ src/FundLab.Api/FundLab.Api.fsproj | 1 + src/FundLab.Api/MarketData.fs | 62 ++++++++ src/FundLab.Api/MarketDataService.fs | 4 + src/FundLab.Api/Program.fs | 4 +- src/FundLab.Api/akshare_collector.py | 42 ++++- tests/FundLab.Api.Tests/BondQuoteProbeTests.fs | 189 +++++++++++++++++++++++ tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 + tests/FundLab.Api.Tests/NavDateProbeTests.fs | 19 ++- tests/FundLab.Api.Tests/NavSeriesProbeTests.fs | 6 +- 11 files changed, 410 insertions(+), 6 deletions(-) create mode 100644 src/FundLab.Api/BondQuoteProbe.fs create mode 100644 tests/FundLab.Api.Tests/BondQuoteProbeTests.fs diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index e359af9..b083c37 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -356,6 +356,15 @@ type MarketProbes = { NavDates: INavDateProbe NavSeries: INavSeriesProbe + BondQuotes: IBondQuoteProbe + } + +type BondQuoteApiResponse = + { + code: string + name: string option + price: string option + maturityDate: string option } module App = @@ -1703,10 +1712,27 @@ module App = ctx | Error failure -> marketDataError failure next ctx + let private getBondQuote (probe: IBondQuoteProbe) : HttpHandler = + fun next ctx -> + let code = ctx.Request.Query["code"].ToString() + + match probe.GetQuote(code, ctx.RequestAborted) with + | Ok quote -> + json + ({ code = quote.Code + name = quote.Name + price = quote.Price |> Option.map decimalText + maturityDate = quote.MaturityDate |> Option.map dateText } + : BondQuoteApiResponse) + next + ctx + | Error failure -> marketDataError failure next ctx + let private marketProbeRoutes (probes: MarketProbes) = [ GET >=> route "/market/nav-dates" >=> getMarketNavDates probes.NavDates GET >=> route "/market/nav-series" >=> getMarketNavSeries probes.NavSeries + GET >=> route "/market/bond-quote" >=> getBondQuote probes.BondQuotes ] let private createApplicationInternal diff --git a/src/FundLab.Api/BondQuoteProbe.fs b/src/FundLab.Api/BondQuoteProbe.fs new file mode 100644 index 0000000..0cb7c3b --- /dev/null +++ b/src/FundLab.Api/BondQuoteProbe.fs @@ -0,0 +1,62 @@ +namespace FundLab.Api + +open System +open System.Collections.Concurrent +open System.Threading + +/// A single bond's current quote. Every descriptive field is optional because +/// the upstream AKShare endpoint may not carry it; missing stays missing rather +/// than being filled with a fabricated value. +type BondQuote = + { + Code: string + Name: string option + Price: decimal option + MaturityDate: DateOnly option + } + +/// Read-only probe that returns the current quote for a bond code. +/// +/// Like INavDateProbe there is no synthetic fallback: a failed probe surfaces as +/// a MarketDataFailure so the API answers 503 with the underlying reason. +type IBondQuoteProbe = + abstract GetQuote: code: string * CancellationToken -> Result + +/// AKShare-backed bond-quote probe. Successful lookups are memoised per bond +/// code (the F# equivalent of functools.lru_cache); failures are never cached. +type AkshareBondQuoteProbe(collector: IMarketDataCollector) = + let cache = ConcurrentDictionary(StringComparer.Ordinal) + + let isBondCode (value: string) = + not (String.IsNullOrWhiteSpace value) + && value.Length = 6 + && value |> Seq.forall Char.IsDigit + + member _.Clear() = cache.Clear() + + interface IBondQuoteProbe with + member _.GetQuote(code, token) = + let normalized = if isNull code then "" else code.Trim() + + if not (isBondCode normalized) then + Error(InvalidMarketDataRequest "bond code must contain exactly six digits") + else + match cache.TryGetValue normalized with + | true, cached -> Ok cached + | _ -> + match collector.FetchBondQuote(normalized, token) with + | Error message -> Error(MarketDataCollectorUnavailable message) + | Ok json -> + match MarketData.parseBondQuotePayload json with + | Error message -> Error(InvalidMarketDataPayload message) + | Ok payload -> + let quote = + { + Code = payload.Code + Name = payload.Name + Price = payload.Price + MaturityDate = payload.MaturityDate + } + + cache.[normalized] <- quote + Ok quote diff --git a/src/FundLab.Api/FundLab.Api.fsproj b/src/FundLab.Api/FundLab.Api.fsproj index 11df882..ef4df25 100644 --- a/src/FundLab.Api/FundLab.Api.fsproj +++ b/src/FundLab.Api/FundLab.Api.fsproj @@ -20,6 +20,7 @@ + diff --git a/src/FundLab.Api/MarketData.fs b/src/FundLab.Api/MarketData.fs index e396a7d..ad2a623 100644 --- a/src/FundLab.Api/MarketData.fs +++ b/src/FundLab.Api/MarketData.fs @@ -37,6 +37,17 @@ type MarketDataNavPayload = Observations: MarketDataObservation list } +type MarketDataBondQuotePayload = + { + Source: string + SourceRevision: string + CollectedAt: DateTimeOffset + Code: string + Name: string option + Price: decimal option + MaturityDate: DateOnly option + } + type MarketDataInstrumentRecord = { Code: string @@ -292,3 +303,54 @@ module MarketData = } with | :? JsonException -> Error "payload must be valid JSON" + + let parseBondQuotePayload (json: string) : Result = + let optionalIsoDate label (property: JsonElement) = + if property.ValueKind = JsonValueKind.Null then + Ok None + elif property.ValueKind <> JsonValueKind.String then + Error(sprintf "%s must be null or an ISO date" label) + else + let text = property.GetString() + let mutable date = DateOnly.MinValue + + if + not (String.IsNullOrWhiteSpace text) + && DateOnly.TryParseExact(text, "yyyy-MM-dd", invariant, DateTimeStyles.None, &date) + then + Ok(Some date) + else + Error(sprintf "%s must be null or an ISO date" label) + + try + use document = JsonDocument.Parse(json) + let root = document.RootElement + + result { + let! source, sourceRevision, collectedAt = validateEnvelope root "bond-quote" + let! instrumentProperty = requiredProperty root "instrument" + let! codeProperty = requiredString instrumentProperty "code" + + if not (isFundCode codeProperty) then + return! Error "bond code must contain exactly six digits" + + let! quoteProperty = requiredProperty root "quote" + let! name = optionalString quoteProperty "name" + let! priceProperty = requiredProperty quoteProperty "price" + let! price = optionalDecimal "price" priceProperty + let! maturityProperty = requiredProperty quoteProperty "maturity_date" + let! maturityDate = optionalIsoDate "maturity_date" maturityProperty + + return + { + Source = source + SourceRevision = sourceRevision + CollectedAt = collectedAt + Code = codeProperty + Name = name + Price = price + MaturityDate = maturityDate + } + } + with + | :? JsonException -> Error "payload must be valid JSON" diff --git a/src/FundLab.Api/MarketDataService.fs b/src/FundLab.Api/MarketDataService.fs index e9993f3..0108ea7 100644 --- a/src/FundLab.Api/MarketDataService.fs +++ b/src/FundLab.Api/MarketDataService.fs @@ -18,6 +18,7 @@ type MarketDataFailure = type IMarketDataCollector = abstract Search: query: string * CancellationToken -> Result abstract FetchNav: code: string * CancellationToken -> Result + abstract FetchBondQuote: code: string * CancellationToken -> Result type IMarketDataService = abstract Search: query: string * CancellationToken -> Result @@ -203,6 +204,9 @@ type ProcessMarketDataCollector(pythonExecutable: string, scriptPath: string, py member _.FetchNav(code: string, token: CancellationToken) = execute token [ "--operation"; "nav"; "--code"; code ] + member _.FetchBondQuote(code: string, token: CancellationToken) = + execute token [ "--operation"; "bond-quote"; "--code"; code ] + type MarketDataService(repository: FundRepository, collector: IMarketDataCollector) = let codePattern = Text.RegularExpressions.Regex("^[0-9]{6}$", Text.RegularExpressions.RegexOptions.Compiled) diff --git a/src/FundLab.Api/Program.fs b/src/FundLab.Api/Program.fs index fa58219..e51311f 100644 --- a/src/FundLab.Api/Program.fs +++ b/src/FundLab.Api/Program.fs @@ -21,10 +21,12 @@ let main argv = let marketData = MarketDataService(repository, collector) :> IMarketDataService let navDateProbe = AkshareNavDateProbe(collector) :> INavDateProbe let navSeriesProbe = AkshareNavSeriesProbe(collector) :> INavSeriesProbe + let bondQuoteProbe = AkshareBondQuoteProbe(collector) :> IBondQuoteProbe let probes = { NavDates = navDateProbe - NavSeries = navSeriesProbe } + NavSeries = navSeriesProbe + BondQuotes = bondQuoteProbe } let app = builder.Build() app.UseGiraffe(App.createApplicationWithMarketDataAndProbes repository marketData probes) diff --git a/src/FundLab.Api/akshare_collector.py b/src/FundLab.Api/akshare_collector.py index f988a61..6543923 100644 --- a/src/FundLab.Api/akshare_collector.py +++ b/src/FundLab.Api/akshare_collector.py @@ -150,15 +150,53 @@ def nav(code): } +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="基本信息") + if frame is None or frame.empty: + raise ValueError(f"AKShare returned no bond info for {code}") + + 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 name is None and price is None and maturity_date is None: + raise ValueError(f"AKShare returned no usable bond quote fields for {code}") + + 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": maturity_date, + }, + } + + def main(): parser = argparse.ArgumentParser() - parser.add_argument("--operation", choices=("search", "nav"), required=True) + parser.add_argument("--operation", choices=("search", "nav", "bond-quote"), required=True) parser.add_argument("--query") parser.add_argument("--code") args = parser.parse_args() try: - payload = search(args.query) if args.operation == "search" else nav(args.code) + if args.operation == "search": + payload = search(args.query) + elif args.operation == "nav": + payload = nav(args.code) + else: + payload = bond_quote(args.code) + json.dump(payload, sys.stdout, ensure_ascii=False, separators=(",", ":")) sys.stdout.write("\n") return 0 diff --git a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs new file mode 100644 index 0000000..a94bf58 --- /dev/null +++ b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs @@ -0,0 +1,189 @@ +namespace FundLab.Api.Tests + +open System +open System.Threading +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" + + 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}}" + code + (optionalText name) + (optionalText price) + (optionalText maturity) + + let collectorReturning (fetch: unit -> Result) = + { new IMarketDataCollector with + member _.Search(_, _) = Ok "" + member _.FetchNav(_, _) = Ok "" + member _.FetchBondQuote(_, _) = fetch () } + + let stubProbe (getQuote: string * CancellationToken -> Result) = + { new IBondQuoteProbe with + member _.GetQuote(code, token) = getQuote (code, token) } + +[] +type BondQuoteProbeTests(fixture: PostgresFixture) = + let repository () = FundRepository(fixture.ConnectionString) + + let appWithQuote (probe: IBondQuoteProbe) = + App.createApplicationWithProbes + (repository ()) + { NavDates = NavDateProbeSupport.stubNavDateProbe + NavSeries = NavDateProbeSupport.stubNavSeriesProbe + BondQuotes = probe } + + [] + member _.``probe parses name, price and maturity date``() = + let json = BondQuoteProbeSupport.payload "123121" (Some "帝尔转债") (Some "101.5000") (Some "2026-06-03") + + let probe = + AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IBondQuoteProbe + + match probe.GetQuote("123121", CancellationToken.None) with + | Ok quote -> + Assert.Equal("123121", quote.Code) + Assert.Equal(Some "帝尔转债", quote.Name) + Assert.Equal(Some 101.5m, quote.Price) + Assert.Equal(Some(DateOnly(2026, 6, 3)), quote.MaturityDate) + | Error failure -> failwithf "expected a bond quote, got %A" failure + + [] + member _.``probe keeps missing fields missing instead of fabricating data``() = + let json = BondQuoteProbeSupport.payload "123121" (Some "帝尔转债") None None + + let probe = + AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IBondQuoteProbe + + match probe.GetQuote("123121", CancellationToken.None) with + | Ok quote -> + Assert.Equal(Some "帝尔转债", quote.Name) + Assert.Equal(None, quote.Price) + Assert.Equal(None, quote.MaturityDate) + | Error failure -> failwithf "expected a bond quote, got %A" failure + + [] + member _.``probe rejects a malformed bond code without touching the collector``() = + let probe = + AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> failwith "collector should not be called")) + :> IBondQuoteProbe + + match probe.GetQuote("abc", CancellationToken.None) with + | Error(InvalidMarketDataRequest message) -> Assert.Contains("six digits", message) + | other -> failwithf "expected InvalidMarketDataRequest, got %A" other + + [] + member _.``probe maps collector failures to an unavailable reason and does not cache them``() = + let mutable calls = 0 + + let collector = + BondQuoteProbeSupport.collectorReturning (fun () -> + calls <- calls + 1 + Error "collector exited with code 2") + + let probe = AkshareBondQuoteProbe(collector) :> IBondQuoteProbe + + match probe.GetQuote("123121", CancellationToken.None) with + | Error(MarketDataCollectorUnavailable message) -> Assert.Contains("code 2", message) + | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other + + match probe.GetQuote("123121", CancellationToken.None) with + | Error(MarketDataCollectorUnavailable _) -> () + | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other + + Assert.Equal(2, calls) + + [] + member _.``probe maps malformed payloads to an invalid payload reason``() = + let probe = + AkshareBondQuoteProbe(BondQuoteProbeSupport.collectorReturning (fun () -> Ok "{not-json")) :> IBondQuoteProbe + + match probe.GetQuote("123121", CancellationToken.None) with + | Error(InvalidMarketDataPayload _) -> () + | other -> failwithf "expected InvalidMarketDataPayload, got %A" other + + [] + member _.``probe memoises successful lookups per bond code``() = + let mutable calls = 0 + + let collector = + BondQuoteProbeSupport.collectorReturning (fun () -> + calls <- calls + 1 + Ok(BondQuoteProbeSupport.payload "123121" (Some "帝尔转债") None None)) + + let probe = AkshareBondQuoteProbe(collector) :> IBondQuoteProbe + + let first = probe.GetQuote("123121", CancellationToken.None) + let second = probe.GetQuote("123121", CancellationToken.None) + + Assert.Equal(1, calls) + Assert.Equal(first |> Result.map (fun quote -> quote.Code), second |> Result.map (fun quote -> quote.Code)) + + [] + member _.``market bond-quote endpoint returns the probe record``() = + let probe = + BondQuoteProbeSupport.stubProbe (fun (code, _) -> + Assert.Equal("123121", code) + + Ok + { + Code = "123121" + Name = Some "帝尔转债" + Price = Some 101.5m + MaturityDate = Some(DateOnly(2026, 6, 3)) + }) + + let status, body = + PersistenceTestHelpers.invoke + (appWithQuote probe) + "GET" + "/api/market/bond-quote?code=123121" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(200, status) + Assert.Contains("\"code\":\"123121\"", body) + Assert.Contains("\"name\":\"帝尔转债\"", body) + Assert.Contains("101.5", body) + Assert.Contains("\"maturityDate\":\"2026-06-03\"", body) + + [] + member _.``market bond-quote endpoint answers 503 when the probe fails``() = + let probe = + BondQuoteProbeSupport.stubProbe (fun _ -> Error(MarketDataCollectorUnavailable "collector exited with code 2")) + + let status, body = + PersistenceTestHelpers.invoke + (appWithQuote probe) + "GET" + "/api/market/bond-quote?code=123121" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(503, status) + Assert.Contains("MARKET_DATA_UNAVAILABLE", body) + Assert.Contains("code 2", body) + + [] + member _.``market bond-quote endpoint rejects a malformed code with 400``() = + let probe = + BondQuoteProbeSupport.stubProbe (fun _ -> + Error(InvalidMarketDataRequest "bond code must contain exactly six digits")) + + let status, body = + PersistenceTestHelpers.invoke + (appWithQuote probe) + "GET" + "/api/market/bond-quote?code=abc" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(400, status) + Assert.Contains("INVALID_MARKET_DATA_REQUEST", body) diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index 07db95f..be044a1 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -31,6 +31,7 @@ + diff --git a/tests/FundLab.Api.Tests/NavDateProbeTests.fs b/tests/FundLab.Api.Tests/NavDateProbeTests.fs index 6197950..1c57fd1 100644 --- a/tests/FundLab.Api.Tests/NavDateProbeTests.fs +++ b/tests/FundLab.Api.Tests/NavDateProbeTests.fs @@ -27,7 +27,20 @@ module NavDateProbeSupport = let collectorReturning (fetch: unit -> Result) = { new IMarketDataCollector with member _.Search(_, _) = Ok "" - member _.FetchNav(_, _) = fetch () } + member _.FetchNav(_, _) = fetch () + member _.FetchBondQuote(_, _) = Ok "" } + + let stubBondQuoteProbe = + { new IBondQuoteProbe with + member _.GetQuote(_, _) = Error(InvalidMarketDataRequest "bond probe not configured for this test") } + + let stubNavDateProbe = + { new INavDateProbe with + member _.RecentNavDates(_, _, _) = Ok [] } + + let stubNavSeriesProbe = + { new INavSeriesProbe with + member _.RecentNavSeries(_, _, _) = Ok [] } let realAksharePython () = let configured = Environment.GetEnvironmentVariable("FUND_LAB_AKSHARE_PYTHON") @@ -76,7 +89,9 @@ type NavDateProbeTests(fixture: PostgresFixture) = member _.RecentNavSeries(_, _, _) = Ok [] } let appWithProbe (probe: INavDateProbe) = - App.createApplicationWithProbes (repository ()) { NavDates = probe; NavSeries = stubSeriesProbe } + App.createApplicationWithProbes + (repository ()) + { NavDates = probe; NavSeries = stubSeriesProbe; BondQuotes = NavDateProbeSupport.stubBondQuoteProbe } [] member _.``probe parses, dedupes, sorts descending and truncates``() = diff --git a/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs b/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs index 14fd8e8..f753cbe 100644 --- a/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs +++ b/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs @@ -36,7 +36,11 @@ type NavSeriesProbeTests(fixture: PostgresFixture) = member _.RecentNavDates(_, _, _) = Ok [] } let appWithSeries (probe: INavSeriesProbe) = - App.createApplicationWithProbes (repository ()) { NavDates = stubDateProbe; NavSeries = probe } + App.createApplicationWithProbes + (repository ()) + { NavDates = stubDateProbe + NavSeries = probe + BondQuotes = NavDateProbeSupport.stubBondQuoteProbe } [] member _.``series parses points descending, keeps accumulated nav, drops zero navs and truncates``() = -- cgit v1.2.3