summaryrefslogtreecommitdiff
path: root/src/FundLab.Api
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 01:12:52 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 01:12:52 +0800
commit3cc0b4dcfeed042e5a69628c6b7f45580ad1e2f9 (patch)
treeb86805c2680327412d6f5d554d4506ebfe62fc2f /src/FundLab.Api
parent70e1ad4fc8ba248f835d415faade8a91ac3dc1f8 (diff)
downloadfund-lab-3cc0b4dcfeed042e5a69628c6b7f45580ad1e2f9.tar.gz
Add real AKShare bond quote probe slice (3d-14)
Diffstat (limited to 'src/FundLab.Api')
-rw-r--r--src/FundLab.Api/App.fs26
-rw-r--r--src/FundLab.Api/BondQuoteProbe.fs62
-rw-r--r--src/FundLab.Api/FundLab.Api.fsproj1
-rw-r--r--src/FundLab.Api/MarketData.fs62
-rw-r--r--src/FundLab.Api/MarketDataService.fs4
-rw-r--r--src/FundLab.Api/Program.fs4
-rw-r--r--src/FundLab.Api/akshare_collector.py42
7 files changed, 198 insertions, 3 deletions
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<BondQuote, MarketDataFailure>
+
+/// 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<string, BondQuote>(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 @@
<Compile Include="MarketDataService.fs" />
<Compile Include="NavDateProbe.fs" />
<Compile Include="NavSeriesProbe.fs" />
+ <Compile Include="BondQuoteProbe.fs" />
<Compile Include="App.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
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<MarketDataBondQuotePayload, string> =
+ 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<string, string>
abstract FetchNav: code: string * CancellationToken -> Result<string, string>
+ abstract FetchBondQuote: code: string * CancellationToken -> Result<string, string>
type IMarketDataService =
abstract Search: query: string * CancellationToken -> Result<MarketDataSearchPayload, MarketDataFailure>
@@ -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