summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/FundLab.Api/App.fs26
-rw-r--r--src/FundLab.Api/FundLab.Api.fsproj1
-rw-r--r--src/FundLab.Api/MarketData.fs44
-rw-r--r--src/FundLab.Api/MarketDataService.fs4
-rw-r--r--src/FundLab.Api/Program.fs4
-rw-r--r--src/FundLab.Api/StockQuoteProbe.fs61
-rw-r--r--src/FundLab.Api/akshare_collector.py102
-rw-r--r--tests/FundLab.Api.Tests/BondQuoteProbeTests.fs6
-rw-r--r--tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj1
-rw-r--r--tests/FundLab.Api.Tests/NavDateProbeTests.fs12
-rw-r--r--tests/FundLab.Api.Tests/NavSeriesProbeTests.fs3
-rw-r--r--tests/FundLab.Api.Tests/StockQuoteProbeTests.fs188
12 files changed, 444 insertions, 8 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs
index b083c37..9565485 100644
--- a/src/FundLab.Api/App.fs
+++ b/src/FundLab.Api/App.fs
@@ -357,6 +357,7 @@ type MarketProbes =
NavDates: INavDateProbe
NavSeries: INavSeriesProbe
BondQuotes: IBondQuoteProbe
+ StockQuotes: IStockQuoteProbe
}
type BondQuoteApiResponse =
@@ -367,6 +368,14 @@ type BondQuoteApiResponse =
maturityDate: string option
}
+type StockQuoteApiResponse =
+ {
+ code: string
+ name: string option
+ price: string option
+ currency: string
+ }
+
module App =
let addOptionFriendlyJson (services: IServiceCollection) =
let settings =
@@ -1728,11 +1737,28 @@ module App =
ctx
| Error failure -> marketDataError failure next ctx
+ let private getStockQuote (probe: IStockQuoteProbe) : 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
+ currency = quote.Currency }
+ : StockQuoteApiResponse)
+ 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
+ GET >=> route "/market/stock-quote" >=> getStockQuote probes.StockQuotes
]
let private createApplicationInternal
diff --git a/src/FundLab.Api/FundLab.Api.fsproj b/src/FundLab.Api/FundLab.Api.fsproj
index ef4df25..62c6324 100644
--- a/src/FundLab.Api/FundLab.Api.fsproj
+++ b/src/FundLab.Api/FundLab.Api.fsproj
@@ -21,6 +21,7 @@
<Compile Include="NavDateProbe.fs" />
<Compile Include="NavSeriesProbe.fs" />
<Compile Include="BondQuoteProbe.fs" />
+ <Compile Include="StockQuoteProbe.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 ad2a623..87ef145 100644
--- a/src/FundLab.Api/MarketData.fs
+++ b/src/FundLab.Api/MarketData.fs
@@ -48,6 +48,17 @@ type MarketDataBondQuotePayload =
MaturityDate: DateOnly option
}
+type MarketDataStockQuotePayload =
+ {
+ Source: string
+ SourceRevision: string
+ CollectedAt: DateTimeOffset
+ Code: string
+ Name: string option
+ Price: decimal option
+ Currency: string
+ }
+
type MarketDataInstrumentRecord =
{
Code: string
@@ -354,3 +365,36 @@ module MarketData =
}
with
| :? JsonException -> Error "payload must be valid JSON"
+
+ let parseStockQuotePayload (json: string) : Result<MarketDataStockQuotePayload, string> =
+ try
+ use document = JsonDocument.Parse(json)
+ let root = document.RootElement
+
+ result {
+ let! source, sourceRevision, collectedAt = validateEnvelope root "stock-quote"
+ let! instrumentProperty = requiredProperty root "instrument"
+ let! codeProperty = requiredString instrumentProperty "code"
+
+ if not (isFundCode codeProperty) then
+ return! Error "stock 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! currency = requiredString quoteProperty "currency"
+
+ return
+ {
+ Source = source
+ SourceRevision = sourceRevision
+ CollectedAt = collectedAt
+ Code = codeProperty
+ Name = name
+ Price = price
+ Currency = currency
+ }
+ }
+ with
+ | :? JsonException -> Error "payload must be valid JSON"
diff --git a/src/FundLab.Api/MarketDataService.fs b/src/FundLab.Api/MarketDataService.fs
index 0108ea7..7460bad 100644
--- a/src/FundLab.Api/MarketDataService.fs
+++ b/src/FundLab.Api/MarketDataService.fs
@@ -19,6 +19,7 @@ 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>
+ abstract FetchStockQuote: code: string * CancellationToken -> Result<string, string>
type IMarketDataService =
abstract Search: query: string * CancellationToken -> Result<MarketDataSearchPayload, MarketDataFailure>
@@ -207,6 +208,9 @@ type ProcessMarketDataCollector(pythonExecutable: string, scriptPath: string, py
member _.FetchBondQuote(code: string, token: CancellationToken) =
execute token [ "--operation"; "bond-quote"; "--code"; code ]
+ member _.FetchStockQuote(code: string, token: CancellationToken) =
+ execute token [ "--operation"; "stock-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 e51311f..410583c 100644
--- a/src/FundLab.Api/Program.fs
+++ b/src/FundLab.Api/Program.fs
@@ -22,11 +22,13 @@ let main argv =
let navDateProbe = AkshareNavDateProbe(collector) :> INavDateProbe
let navSeriesProbe = AkshareNavSeriesProbe(collector) :> INavSeriesProbe
let bondQuoteProbe = AkshareBondQuoteProbe(collector) :> IBondQuoteProbe
+ let stockQuoteProbe = AkshareStockQuoteProbe(collector) :> IStockQuoteProbe
let probes =
{ NavDates = navDateProbe
NavSeries = navSeriesProbe
- BondQuotes = bondQuoteProbe }
+ BondQuotes = bondQuoteProbe
+ StockQuotes = stockQuoteProbe }
let app = builder.Build()
app.UseGiraffe(App.createApplicationWithMarketDataAndProbes repository marketData probes)
diff --git a/src/FundLab.Api/StockQuoteProbe.fs b/src/FundLab.Api/StockQuoteProbe.fs
new file mode 100644
index 0000000..a41070a
--- /dev/null
+++ b/src/FundLab.Api/StockQuoteProbe.fs
@@ -0,0 +1,61 @@
+namespace FundLab.Api
+
+open System
+open System.Collections.Concurrent
+open System.Threading
+
+/// A single stock's current quote. Name is optional because a quote venue may
+/// not carry it; missing stays missing rather than being filled in.
+type StockQuote =
+ {
+ Code: string
+ Name: string option
+ Price: decimal option
+ Currency: string
+ }
+
+/// Read-only probe that returns the current quote for a stock code.
+///
+/// Like the other market probes there is no synthetic fallback: a failure is
+/// surfaced as a MarketDataFailure so the API answers 503 with the reason.
+type IStockQuoteProbe =
+ abstract GetQuote: code: string * CancellationToken -> Result<StockQuote, MarketDataFailure>
+
+/// AKShare-backed stock-quote probe. Successful lookups are memoised per code
+///; failures are never cached.
+type AkshareStockQuoteProbe(collector: IMarketDataCollector) =
+ let cache = ConcurrentDictionary<string, StockQuote>(StringComparer.Ordinal)
+
+ let isStockCode (value: string) =
+ not (String.IsNullOrWhiteSpace value)
+ && value.Length = 6
+ && value |> Seq.forall Char.IsDigit
+
+ member _.Clear() = cache.Clear()
+
+ interface IStockQuoteProbe with
+ member _.GetQuote(code, token) =
+ let normalized = if isNull code then "" else code.Trim()
+
+ if not (isStockCode normalized) then
+ Error(InvalidMarketDataRequest "stock code must contain exactly six digits")
+ else
+ match cache.TryGetValue normalized with
+ | true, cached -> Ok cached
+ | _ ->
+ match collector.FetchStockQuote(normalized, token) with
+ | Error message -> Error(MarketDataCollectorUnavailable message)
+ | Ok json ->
+ match MarketData.parseStockQuotePayload json with
+ | Error message -> Error(InvalidMarketDataPayload message)
+ | Ok payload ->
+ let quote =
+ {
+ Code = payload.Code
+ Name = payload.Name
+ Price = payload.Price
+ Currency = payload.Currency
+ }
+
+ cache.[normalized] <- quote
+ Ok quote
diff --git a/src/FundLab.Api/akshare_collector.py b/src/FundLab.Api/akshare_collector.py
index 652f32d..62632bf 100644
--- a/src/FundLab.Api/akshare_collector.py
+++ b/src/FundLab.Api/akshare_collector.py
@@ -207,9 +207,105 @@ def bond_quote(code):
}
+def first_positive_decimal(*values):
+ for value in values:
+ price = decimal_text(value)
+ if price is not None and Decimal(price) > 0:
+ return price
+ return None
+
+
+def stock_price_from_bid_ask(code):
+ # Single-symbol realtime snapshot (eastmoney). Returns None when the venue
+ # rejects the symbol or carries no valid last price.
+ frame = ak.stock_bid_ask_em(symbol=code)
+ if frame is None or frame.empty:
+ return None
+
+ for _, row in frame.iterrows():
+ item = text(row.get("item"))
+ if item in ("最新", "最新价"):
+ return first_positive_decimal(row.get("value"))
+
+ return None
+
+
+def stock_from_snapshot(code):
+ # Whole-market snapshots differ per provider; each entry is
+ # (fetch, code column, name column, price column, exchange prefixes).
+ providers = [
+ (ak.stock_zh_a_spot_tx, "code", "name", "zxj", {"sh": 0, "sz": 1, "bj": 2}),
+ (ak.stock_zh_a_spot_em, "代码", "名称", "最新价", None),
+ ]
+
+ for fetch, code_key, name_key, price_key, prefixes in providers:
+ try:
+ frame = fetch()
+ except Exception:
+ continue
+
+ if frame is None or frame.empty:
+ continue
+
+ candidates = []
+
+ for _, row in frame.iterrows():
+ row_code = text(row.get(code_key)) or ""
+ if row_code != code and not row_code.lower().endswith(code):
+ continue
+
+ price = first_positive_decimal(row.get(price_key))
+ if price is None:
+ continue
+
+ prefix = row_code[:2].lower()
+ rank = prefixes.get(prefix, 3) if prefixes is not None else 0
+ candidates.append((rank, row_code, text(row.get(name_key)), price))
+
+ if candidates:
+ _, _, name, price = sorted(candidates, key=lambda item: (item[0], item[1]))[0]
+ return name, price
+
+ return None
+
+
+def stock_quote(code):
+ code = text(code)
+ if code is None or not re.fullmatch(r"\d{6}", code):
+ raise ValueError("stock code must contain exactly six digits")
+
+ name = None
+ price = None
+
+ try:
+ price = stock_price_from_bid_ask(code)
+ except Exception:
+ price = None
+
+ if price is None:
+ found = stock_from_snapshot(code)
+ if found is None:
+ raise ValueError(f"no valid quote for {code}")
+ name, price = found
+
+ return {
+ "schema_version": SCHEMA_VERSION,
+ "operation": "stock-quote",
+ "source": "akshare",
+ "source_revision": source_revision(),
+ "collected_at": collected_at(),
+ "instrument": {"code": code},
+ "quote": {
+ "name": name,
+ "price": price,
+ "currency": "CNY",
+ },
+ }
+
+
def main():
parser = argparse.ArgumentParser()
- parser.add_argument("--operation", choices=("search", "nav", "bond-quote"), required=True)
+ parser.add_argument("--operation", choices=("search", "nav", "bond-quote", "stock-quote"), required=True)
parser.add_argument("--query")
parser.add_argument("--code")
args = parser.parse_args()
@@ -219,8 +315,10 @@ def main():
payload = search(args.query)
elif args.operation == "nav":
payload = nav(args.code)
- else:
+ elif args.operation == "bond-quote":
payload = bond_quote(args.code)
+ else:
+ payload = stock_quote(args.code)
json.dump(payload, sys.stdout, ensure_ascii=False, separators=(",", ":"))
sys.stdout.write("\n")
diff --git a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs
index 442ed7c..c9821dc 100644
--- a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs
+++ b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs
@@ -23,7 +23,8 @@ module BondQuoteProbeSupport =
{ new IMarketDataCollector with
member _.Search(_, _) = Ok ""
member _.FetchNav(_, _) = Ok ""
- member _.FetchBondQuote(_, _) = fetch () }
+ member _.FetchBondQuote(_, _) = fetch ()
+ member _.FetchStockQuote(_, _) = Ok "" }
let stubProbe (getQuote: string * CancellationToken -> Result<BondQuote, MarketDataFailure>) =
{ new IBondQuoteProbe with
@@ -38,7 +39,8 @@ type BondQuoteProbeTests(fixture: PostgresFixture) =
(repository ())
{ NavDates = NavDateProbeSupport.stubNavDateProbe
NavSeries = NavDateProbeSupport.stubNavSeriesProbe
- BondQuotes = probe }
+ BondQuotes = probe
+ StockQuotes = NavDateProbeSupport.stubStockQuoteProbe }
[<Fact>]
member _.``probe parses name, price and maturity date``() =
diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
index be044a1..755cc1d 100644
--- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
+++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj
@@ -32,6 +32,7 @@
<Compile Include="NavDateProbeTests.fs" />
<Compile Include="NavSeriesProbeTests.fs" />
<Compile Include="BondQuoteProbeTests.fs" />
+ <Compile Include="StockQuoteProbeTests.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
</Project>
diff --git a/tests/FundLab.Api.Tests/NavDateProbeTests.fs b/tests/FundLab.Api.Tests/NavDateProbeTests.fs
index 1c57fd1..cc513d7 100644
--- a/tests/FundLab.Api.Tests/NavDateProbeTests.fs
+++ b/tests/FundLab.Api.Tests/NavDateProbeTests.fs
@@ -28,12 +28,17 @@ module NavDateProbeSupport =
{ new IMarketDataCollector with
member _.Search(_, _) = Ok ""
member _.FetchNav(_, _) = fetch ()
- member _.FetchBondQuote(_, _) = Ok "" }
+ member _.FetchBondQuote(_, _) = Ok ""
+ member _.FetchStockQuote(_, _) = Ok "" }
let stubBondQuoteProbe =
{ new IBondQuoteProbe with
member _.GetQuote(_, _) = Error(InvalidMarketDataRequest "bond probe not configured for this test") }
+ let stubStockQuoteProbe =
+ { new IStockQuoteProbe with
+ member _.GetQuote(_, _) = Error(InvalidMarketDataRequest "stock probe not configured for this test") }
+
let stubNavDateProbe =
{ new INavDateProbe with
member _.RecentNavDates(_, _, _) = Ok [] }
@@ -91,7 +96,10 @@ type NavDateProbeTests(fixture: PostgresFixture) =
let appWithProbe (probe: INavDateProbe) =
App.createApplicationWithProbes
(repository ())
- { NavDates = probe; NavSeries = stubSeriesProbe; BondQuotes = NavDateProbeSupport.stubBondQuoteProbe }
+ { NavDates = probe
+ NavSeries = stubSeriesProbe
+ BondQuotes = NavDateProbeSupport.stubBondQuoteProbe
+ StockQuotes = NavDateProbeSupport.stubStockQuoteProbe }
[<Fact>]
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 f753cbe..81d1733 100644
--- a/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs
+++ b/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs
@@ -40,7 +40,8 @@ type NavSeriesProbeTests(fixture: PostgresFixture) =
(repository ())
{ NavDates = stubDateProbe
NavSeries = probe
- BondQuotes = NavDateProbeSupport.stubBondQuoteProbe }
+ BondQuotes = NavDateProbeSupport.stubBondQuoteProbe
+ StockQuotes = NavDateProbeSupport.stubStockQuoteProbe }
[<Fact>]
member _.``series parses points descending, keeps accumulated nav, drops zero navs and truncates``() =
diff --git a/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs b/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs
new file mode 100644
index 0000000..e29c666
--- /dev/null
+++ b/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs
@@ -0,0 +1,188 @@
+namespace FundLab.Api.Tests
+
+open System.Threading
+open Xunit
+open FundLab.Api
+
+module StockQuoteProbeSupport =
+ let payload (code: string) (name: string option) (price: string option) =
+ let optionalText =
+ function
+ | Some value -> sprintf "\"%s\"" value
+ | None -> "null"
+
+ sprintf
+ "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"stock-quote\",\"source\":\"akshare\",\"source_revision\":\"akshare-test/eastmoney\",\"collected_at\":\"2026-09-21T08:00:00Z\",\"instrument\":{\"code\":\"%s\"},\"quote\":{\"name\":%s,\"price\":%s,\"currency\":\"CNY\"}}"
+ code
+ (optionalText name)
+ (optionalText price)
+
+ let collectorReturning (fetch: unit -> Result<string, string>) =
+ { new IMarketDataCollector with
+ member _.Search(_, _) = Ok ""
+ member _.FetchNav(_, _) = Ok ""
+ member _.FetchBondQuote(_, _) = Ok ""
+ member _.FetchStockQuote(_, _) = fetch () }
+
+ let stubProbe (getQuote: string * CancellationToken -> Result<StockQuote, MarketDataFailure>) =
+ { new IStockQuoteProbe with
+ member _.GetQuote(code, token) = getQuote (code, token) }
+
+[<Collection("postgres")>]
+type StockQuoteProbeTests(fixture: PostgresFixture) =
+ let repository () = FundRepository(fixture.ConnectionString)
+
+ let appWithQuote (probe: IStockQuoteProbe) =
+ App.createApplicationWithProbes
+ (repository ())
+ { NavDates = NavDateProbeSupport.stubNavDateProbe
+ NavSeries = NavDateProbeSupport.stubNavSeriesProbe
+ BondQuotes = NavDateProbeSupport.stubBondQuoteProbe
+ StockQuotes = probe }
+
+ [<Fact>]
+ member _.``probe parses name, price and currency``() =
+ let json = StockQuoteProbeSupport.payload "600519" (Some "贵州茅台") (Some "1252.57")
+
+ let probe =
+ AkshareStockQuoteProbe(StockQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IStockQuoteProbe
+
+ match probe.GetQuote("600519", CancellationToken.None) with
+ | Ok quote ->
+ Assert.Equal("600519", quote.Code)
+ Assert.Equal<string option>(Some "贵州茅台", quote.Name)
+ Assert.Equal<decimal option>(Some 1252.57m, quote.Price)
+ Assert.Equal("CNY", quote.Currency)
+ | Error failure -> failwithf "expected a stock quote, got %A" failure
+
+ [<Fact>]
+ member _.``probe keeps a missing name missing instead of fabricating data``() =
+ let json = StockQuoteProbeSupport.payload "600519" None (Some "1252.57")
+
+ let probe =
+ AkshareStockQuoteProbe(StockQuoteProbeSupport.collectorReturning (fun () -> Ok json)) :> IStockQuoteProbe
+
+ match probe.GetQuote("600519", CancellationToken.None) with
+ | Ok quote ->
+ Assert.Equal<string option>(None, quote.Name)
+ Assert.Equal<decimal option>(Some 1252.57m, quote.Price)
+ | Error failure -> failwithf "expected a stock quote, got %A" failure
+
+ [<Fact>]
+ member _.``probe rejects a malformed stock code without touching the collector``() =
+ let probe =
+ AkshareStockQuoteProbe(StockQuoteProbeSupport.collectorReturning (fun () -> failwith "collector should not be called"))
+ :> IStockQuoteProbe
+
+ match probe.GetQuote("abc", CancellationToken.None) with
+ | Error(InvalidMarketDataRequest message) -> Assert.Contains("six digits", message)
+ | other -> failwithf "expected InvalidMarketDataRequest, got %A" other
+
+ [<Fact>]
+ member _.``probe surfaces a no valid quote collector failure and does not cache it``() =
+ let mutable calls = 0
+
+ let collector =
+ StockQuoteProbeSupport.collectorReturning (fun () ->
+ calls <- calls + 1
+ Error "AKShare collector failed: no valid quote for 000000")
+
+ let probe = AkshareStockQuoteProbe(collector) :> IStockQuoteProbe
+
+ match probe.GetQuote("000000", CancellationToken.None) with
+ | Error(MarketDataCollectorUnavailable message) -> Assert.Contains("no valid quote for 000000", message)
+ | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other
+
+ match probe.GetQuote("000000", CancellationToken.None) with
+ | Error(MarketDataCollectorUnavailable _) -> ()
+ | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other
+
+ Assert.Equal(2, calls)
+
+ [<Fact>]
+ member _.``probe maps malformed payloads to an invalid payload reason``() =
+ let probe =
+ AkshareStockQuoteProbe(StockQuoteProbeSupport.collectorReturning (fun () -> Ok "{not-json")) :> IStockQuoteProbe
+
+ match probe.GetQuote("600519", CancellationToken.None) with
+ | Error(InvalidMarketDataPayload _) -> ()
+ | other -> failwithf "expected InvalidMarketDataPayload, got %A" other
+
+ [<Fact>]
+ member _.``probe memoises successful lookups per stock code``() =
+ let mutable calls = 0
+
+ let collector =
+ StockQuoteProbeSupport.collectorReturning (fun () ->
+ calls <- calls + 1
+ Ok(StockQuoteProbeSupport.payload "600519" (Some "贵州茅台") (Some "1252.57")))
+
+ let probe = AkshareStockQuoteProbe(collector) :> IStockQuoteProbe
+
+ let first = probe.GetQuote("600519", CancellationToken.None)
+ let second = probe.GetQuote("600519", CancellationToken.None)
+
+ Assert.Equal(1, calls)
+ Assert.Equal(first |> Result.map (fun quote -> quote.Price), second |> Result.map (fun quote -> quote.Price))
+
+ [<Fact>]
+ member _.``market stock-quote endpoint returns the probe record``() =
+ let probe =
+ StockQuoteProbeSupport.stubProbe (fun (code, _) ->
+ Assert.Equal("600519", code)
+
+ Ok
+ {
+ Code = "600519"
+ Name = Some "贵州茅台"
+ Price = Some 1252.57m
+ Currency = "CNY"
+ })
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithQuote probe)
+ "GET"
+ "/api/market/stock-quote?code=600519"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(200, status)
+ Assert.Contains("\"code\":\"600519\"", body)
+ Assert.Contains("\"name\":\"贵州茅台\"", body)
+ Assert.Contains("1252.57", body)
+ Assert.Contains("\"currency\":\"CNY\"", body)
+
+ [<Fact>]
+ member _.``market stock-quote endpoint answers 503 when the probe fails``() =
+ let probe =
+ StockQuoteProbeSupport.stubProbe (fun _ -> Error(MarketDataCollectorUnavailable "collector exited with code 2"))
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithQuote probe)
+ "GET"
+ "/api/market/stock-quote?code=000000"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(503, status)
+ Assert.Contains("MARKET_DATA_UNAVAILABLE", body)
+ Assert.Contains("code 2", body)
+
+ [<Fact>]
+ member _.``market stock-quote endpoint rejects a malformed code with 400``() =
+ let probe =
+ StockQuoteProbeSupport.stubProbe (fun _ ->
+ Error(InvalidMarketDataRequest "stock code must contain exactly six digits"))
+
+ let status, body =
+ PersistenceTestHelpers.invoke
+ (appWithQuote probe)
+ "GET"
+ "/api/market/stock-quote?code=abc"
+ [ "Authorization", "Bearer test-token" ]
+ ""
+
+ Assert.Equal(400, status)
+ Assert.Contains("INVALID_MARKET_DATA_REQUEST", body)