From 7dd0e3f42105569b6e6ec02aeeec9b5a8bbd9dd3 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 03:25:00 +0800 Subject: Add real AKShare stock daily history probe slice (3d-19) --- src/FundLab.Api/App.fs | 52 ++++++++++++++- src/FundLab.Api/FundLab.Api.fsproj | 1 + src/FundLab.Api/MarketData.fs | 69 ++++++++++++++++++++ src/FundLab.Api/MarketDataService.fs | 4 ++ src/FundLab.Api/Program.fs | 4 +- src/FundLab.Api/StockDailyProbe.fs | 80 +++++++++++++++++++++++ src/FundLab.Api/akshare_collector.py | 88 ++++++++++++++++++++++++- src/FundLab.Web/App.fs | 120 +++++++++++++++++++++++++++++++++++ src/FundLab.Web/src/api.js | 4 ++ src/FundLab.Web/src/styles.css | 5 ++ 10 files changed, 423 insertions(+), 4 deletions(-) create mode 100644 src/FundLab.Api/StockDailyProbe.fs (limited to 'src') diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 9565485..4313329 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -358,6 +358,7 @@ type MarketProbes = NavSeries: INavSeriesProbe BondQuotes: IBondQuoteProbe StockQuotes: IStockQuoteProbe + StockDaily: IStockDailyProbe } type BondQuoteApiResponse = @@ -376,6 +377,20 @@ type StockQuoteApiResponse = currency: string } +type StockDailyObservationApiResponse = + { + date: string + close: string + volume: string option + amount: string option + } + +type StockDailyApiResponse = + { + code: string + observations: StockDailyObservationApiResponse list + } + module App = let addOptionFriendlyJson (services: IServiceCollection) = let settings = @@ -1630,7 +1645,7 @@ module App = lastSeenAt = timestampText observation.LastSeenAt } - let private marketDataNavResponse code observations = + let private marketDataNavResponse code (observations: MarketDataNavRecord list) : MarketDataNavApiResponse = { code = code observations = observations |> List.map marketDataObservationResponse @@ -1753,12 +1768,47 @@ module App = ctx | Error failure -> marketDataError failure next ctx + let private stockDailyObservationResponse (observation: StockDailyObservation) : StockDailyObservationApiResponse = + { + date = dateText observation.BarDate + close = decimalText observation.Close + volume = observation.Volume |> Option.map decimalText + amount = observation.Amount |> Option.map decimalText + } + + let private getStockDaily (probe: IStockDailyProbe) : HttpHandler = + fun next ctx -> + let code = ctx.Request.Query["code"].ToString() + let daysText = ctx.Request.Query["days"].ToString() + + let days = + if String.IsNullOrWhiteSpace daysText then + Ok 5 + else + match Int32.TryParse(daysText, NumberStyles.Integer, invariant) with + | true, value when value >= 1 && value <= 30 -> Ok value + | _ -> Error "days must be an integer between 1 and 30" + + match days with + | Error message -> marketDataError (InvalidMarketDataRequest message) next ctx + | Ok value -> + match probe.RecentDaily(code, value, ctx.RequestAborted) with + | Ok observations -> + json + ({ code = code.Trim() + observations = observations |> List.map stockDailyObservationResponse } + : StockDailyApiResponse) + 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 + GET >=> route "/market/stock-daily" >=> getStockDaily probes.StockDaily ] let private createApplicationInternal diff --git a/src/FundLab.Api/FundLab.Api.fsproj b/src/FundLab.Api/FundLab.Api.fsproj index 62c6324..2703e4b 100644 --- a/src/FundLab.Api/FundLab.Api.fsproj +++ b/src/FundLab.Api/FundLab.Api.fsproj @@ -22,6 +22,7 @@ + diff --git a/src/FundLab.Api/MarketData.fs b/src/FundLab.Api/MarketData.fs index 87ef145..82da1c9 100644 --- a/src/FundLab.Api/MarketData.fs +++ b/src/FundLab.Api/MarketData.fs @@ -59,6 +59,23 @@ type MarketDataStockQuotePayload = Currency: string } +type MarketDataStockDailyBar = + { + BarDate: DateOnly + Close: decimal + Volume: decimal option + Amount: decimal option + } + +type MarketDataStockDailyPayload = + { + Source: string + SourceRevision: string + CollectedAt: DateTimeOffset + Code: string + Bars: MarketDataStockDailyBar list + } + type MarketDataInstrumentRecord = { Code: string @@ -398,3 +415,55 @@ module MarketData = } with | :? JsonException -> Error "payload must be valid JSON" + + let private parseStockDailyBar root = + result { + let! dateText = requiredString root "date" + let! date = parseDate dateText + let! closeProperty = requiredProperty root "close" + let! close = parseDecimal "close" closeProperty + let! volumeProperty = requiredProperty root "volume" + let! volume = optionalDecimal "volume" volumeProperty + let! amountProperty = requiredProperty root "amount" + let! amount = optionalDecimal "amount" amountProperty + + return + { + BarDate = date + Close = close + Volume = volume + Amount = amount + } + } + + let parseStockDailyPayload (json: string) : Result = + try + use document = JsonDocument.Parse(json) + let root = document.RootElement + + result { + let! source, sourceRevision, collectedAt = validateEnvelope root "stock-daily" + 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! observationsProperty = requiredProperty root "observations" + let! barsProperty = requiredProperty root "observations" + let! bars = parseArray parseStockDailyBar barsProperty + + if List.isEmpty bars then + return! Error "observations must not be empty" + + return + { + Source = source + SourceRevision = sourceRevision + CollectedAt = collectedAt + Code = codeProperty + Bars = bars + } + } + with + | :? JsonException -> Error "payload must be valid JSON" diff --git a/src/FundLab.Api/MarketDataService.fs b/src/FundLab.Api/MarketDataService.fs index 7460bad..ee3aa67 100644 --- a/src/FundLab.Api/MarketDataService.fs +++ b/src/FundLab.Api/MarketDataService.fs @@ -20,6 +20,7 @@ type IMarketDataCollector = abstract FetchNav: code: string * CancellationToken -> Result abstract FetchBondQuote: code: string * CancellationToken -> Result abstract FetchStockQuote: code: string * CancellationToken -> Result + abstract FetchStockDaily: code: string * days: int * CancellationToken -> Result type IMarketDataService = abstract Search: query: string * CancellationToken -> Result @@ -211,6 +212,9 @@ type ProcessMarketDataCollector(pythonExecutable: string, scriptPath: string, py member _.FetchStockQuote(code: string, token: CancellationToken) = execute token [ "--operation"; "stock-quote"; "--code"; code ] + member _.FetchStockDaily(code: string, days: int, token: CancellationToken) = + execute token [ "--operation"; "stock-daily"; "--code"; code; "--days"; string days ] + 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 410583c..768fb26 100644 --- a/src/FundLab.Api/Program.fs +++ b/src/FundLab.Api/Program.fs @@ -23,12 +23,14 @@ let main argv = let navSeriesProbe = AkshareNavSeriesProbe(collector) :> INavSeriesProbe let bondQuoteProbe = AkshareBondQuoteProbe(collector) :> IBondQuoteProbe let stockQuoteProbe = AkshareStockQuoteProbe(collector) :> IStockQuoteProbe + let stockDailyProbe = AkshareStockDailyProbe(collector) :> IStockDailyProbe let probes = { NavDates = navDateProbe NavSeries = navSeriesProbe BondQuotes = bondQuoteProbe - StockQuotes = stockQuoteProbe } + StockQuotes = stockQuoteProbe + StockDaily = stockDailyProbe } let app = builder.Build() app.UseGiraffe(App.createApplicationWithMarketDataAndProbes repository marketData probes) diff --git a/src/FundLab.Api/StockDailyProbe.fs b/src/FundLab.Api/StockDailyProbe.fs new file mode 100644 index 0000000..8099267 --- /dev/null +++ b/src/FundLab.Api/StockDailyProbe.fs @@ -0,0 +1,80 @@ +namespace FundLab.Api + +open System +open System.Collections.Concurrent +open System.Threading + +/// One daily close of a stock's history. +type StockDailyObservation = + { + BarDate: DateOnly + Close: decimal + Volume: decimal option + Amount: decimal option + } + +/// Read-only probe that returns recent daily history 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 IStockDailyProbe = + abstract RecentDaily: code: string * days: int * CancellationToken -> Result + +/// AKShare-backed stock-daily probe. Successful lookups are memoised per code +/// and requested window; failures are never cached. A payload whose instrument +/// code disagrees with the request is rejected outright. +type AkshareStockDailyProbe(collector: IMarketDataCollector) = + let cache = ConcurrentDictionary(StringComparer.Ordinal) + + let isStockCode (value: string) = + not (String.IsNullOrWhiteSpace value) + && value.Length = 6 + && value |> Seq.forall Char.IsDigit + + let clip days = + if days < 1 then 1 + elif days > 30 then 30 + else days + + member _.Clear() = cache.Clear() + + interface IStockDailyProbe with + member _.RecentDaily(code, days, 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 + let bounded = clip days + let cacheKey = sprintf "%s|%d" normalized bounded + + match cache.TryGetValue cacheKey with + | true, cached -> Ok cached + | _ -> + match collector.FetchStockDaily(normalized, bounded, token) with + | Error message -> Error(MarketDataCollectorUnavailable message) + | Ok json -> + match MarketData.parseStockDailyPayload 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 observations = + payload.Bars + |> List.filter (fun bar -> bar.Close > 0m) + |> List.map (fun bar -> + { + BarDate = bar.BarDate + Close = bar.Close + Volume = bar.Volume + Amount = bar.Amount + }) + |> List.distinctBy (fun bar -> bar.BarDate) + |> List.sortBy (fun bar -> bar.BarDate) + + cache.[cacheKey] <- observations + Ok observations diff --git a/src/FundLab.Api/akshare_collector.py b/src/FundLab.Api/akshare_collector.py index 62632bf..f9a0f06 100644 --- a/src/FundLab.Api/akshare_collector.py +++ b/src/FundLab.Api/akshare_collector.py @@ -269,6 +269,87 @@ def stock_from_snapshot(code): return None +def stock_daily_observations(frame, date_key, close_key, volume_key, amount_key): + rows = [] + + for _, row in frame.iterrows(): + row_date = date_text(row.get(date_key)) + close = decimal_text(row.get(close_key)) + if row_date is None or close is None or Decimal(close) <= 0: + continue + + rows.append( + { + "date": row_date, + "close": close, + "volume": decimal_text(row.get(volume_key)), + "amount": decimal_text(row.get(amount_key)), + } + ) + + rows.sort(key=lambda item: item["date"]) + return rows + + +def stock_market_symbol(code): + if code[0] in ("6", "9"): + return f"sh{code}" + if code[0] in ("0", "3", "2"): + return f"sz{code}" + if code[0] in ("4", "8"): + return f"bj{code}" + return f"sh{code}" + + +def stock_daily(code, days_text): + code = text(code) + if code is None or not re.fullmatch(r"\d{6}", code): + raise ValueError("stock code must contain exactly six digits") + + if days_text is None: + days = 5 + else: + try: + days = int(days_text) + except (TypeError, ValueError): + raise ValueError("days must be an integer between 1 and 30") + + if days < 1 or days > 30: + raise ValueError("days must be an integer between 1 and 30") + + symbol = stock_market_symbol(code) + providers = [ + ((ak.stock_zh_a_hist, {"symbol": code, "period": "daily", "adjust": ""}), "日期", "收盘", "成交量", "成交额"), + ((ak.stock_zh_a_daily, {"symbol": symbol, "adjust": ""}), "date", "close", "volume", "amount"), + ((ak.stock_zh_a_hist_tx, {"symbol": symbol}), "date", "close", "volume", "amount"), + ] + + for (fetch, kwargs), date_key, close_key, volume_key, amount_key in providers: + try: + frame = fetch(**kwargs) + except Exception: + continue + + if frame is None or frame.empty: + continue + + observations = stock_daily_observations(frame, date_key, close_key, volume_key, amount_key) + if not observations: + continue + + return { + "schema_version": SCHEMA_VERSION, + "operation": "stock-daily", + "source": "akshare", + "source_revision": source_revision(), + "collected_at": collected_at(), + "instrument": {"code": code}, + "observations": observations[-days:], + } + + raise ValueError(f"no valid daily history for {code}") + + def stock_quote(code): code = text(code) if code is None or not re.fullmatch(r"\d{6}", code): @@ -305,9 +386,10 @@ def stock_quote(code): def main(): parser = argparse.ArgumentParser() - parser.add_argument("--operation", choices=("search", "nav", "bond-quote", "stock-quote"), required=True) + parser.add_argument("--operation", choices=("search", "nav", "bond-quote", "stock-quote", "stock-daily"), required=True) parser.add_argument("--query") parser.add_argument("--code") + parser.add_argument("--days") args = parser.parse_args() try: @@ -317,8 +399,10 @@ def main(): payload = nav(args.code) elif args.operation == "bond-quote": payload = bond_quote(args.code) - else: + elif args.operation == "stock-quote": payload = stock_quote(args.code) + else: + payload = stock_daily(args.code, args.days) json.dump(payload, sys.stdout, ensure_ascii=False, separators=(",", ":")) sys.stdout.write("\n") diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 9b4bed9..7a0e56c 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -408,6 +408,20 @@ type RawStockQuote = currency: string } +type RawStockDailyObservation = + { + date: string + close: string + volume: obj + amount: obj + } + +type RawStockDaily = + { + code: string + observations: RawStockDailyObservation array + } + type CreateAttempt = { idempotencyKey: string @@ -724,6 +738,14 @@ type StockQuoteView = currency: string } +type StockDailyBar = + { + date: string + close: string + volume: string option + amount: string option + } + module Api = [] let searchInstruments (token: string) (query: string) : JS.Promise = jsNative @@ -779,6 +801,9 @@ module Api = [] let getStockQuote (token: string) (code: string) : JS.Promise = jsNative + [] + let getStockDaily (token: string) (code: string) (days: int) : JS.Promise = jsNative + [] let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise = jsNative @@ -1067,6 +1092,14 @@ module Api = currency = raw.currency } + let decodeStockDailyBar (raw: RawStockDailyObservation) : StockDailyBar = + { + date = raw.date + close = raw.close + volume = decodeOptionalText raw.volume + amount = decodeOptionalText raw.amount + } + type Model = { token: string @@ -1160,6 +1193,10 @@ type Model = stockQuoteInFlight: bool stockQuote: StockQuoteView option stockQuoteMessage: string option + stockDailySeq: int + stockDailyInFlight: bool + stockDaily: StockDailyBar list + stockDailyMessage: string option returnsSelectedDate: string option planReadSeq: int planInFlight: bool @@ -1268,6 +1305,9 @@ type Msg = | StockQuoteRequested | StockQuoteCompleted of requestId: int * quote: RawStockQuote | StockQuoteFailed of requestId: int * message: string + | StockDailyRequested + | StockDailyCompleted of requestId: int * daily: RawStockDaily + | StockDailyFailed of requestId: int * message: string | ReturnsDateChanged of string | InvestmentPlansReadRequested | InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array @@ -1397,6 +1437,10 @@ let init () = stockQuoteInFlight = false stockQuote = None stockQuoteMessage = None + stockDailySeq = 0 + stockDailyInFlight = false + stockDaily = [] + stockDailyMessage = None returnsSelectedDate = None planReadSeq = 0 planInFlight = false @@ -1595,6 +1639,13 @@ let private readStockQuoteCommand token code requestId = (fun quote -> StockQuoteCompleted(requestId, quote)) (fun error -> StockQuoteFailed(requestId, errorText error)) +let private readStockDailyCommand token code days requestId = + Cmd.OfPromise.either + (fun () -> Api.getStockDaily token code days) + () + (fun daily -> StockDailyCompleted(requestId, daily)) + (fun error -> StockDailyFailed(requestId, errorText error)) + let private readInvestmentPlansCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getInvestmentPlans token fundId) @@ -2909,6 +2960,48 @@ let update message model = Cmd.none else model, Cmd.none + | StockDailyRequested -> + let code = model.stockCode.Trim() + + if String.IsNullOrWhiteSpace model.token then + { model with stockDailyMessage = Some "请输入 API token"; stockDaily = [] }, Cmd.none + elif String.IsNullOrWhiteSpace code then + { model with stockDailyMessage = Some "请输入六位股票代码"; stockDaily = [] }, Cmd.none + else + let requestId = model.stockDailySeq + 1 + + { + model with + stockDailySeq = requestId + stockDailyInFlight = true + stockDailyMessage = None + stockDaily = [] + }, + readStockDailyCommand model.token code 5 requestId + | StockDailyCompleted (requestId, daily) -> + if requestId = model.stockDailySeq then + let rows = daily.observations |> Array.toList |> List.map Api.decodeStockDailyBar + + { + model with + stockDaily = rows + stockDailyMessage = (if List.isEmpty rows then Some "股票日线不可用 — 暂无数据" else None) + stockDailyInFlight = false + }, + Cmd.none + else + model, Cmd.none + | StockDailyFailed (requestId, message) -> + if requestId = model.stockDailySeq then + { + model with + stockDaily = [] + stockDailyMessage = Some(sprintf "股票日线不可用 — %s" message) + stockDailyInFlight = false + }, + Cmd.none + else + model, Cmd.none | ReturnsDateChanged value -> { model with returnsSelectedDate = (if String.IsNullOrWhiteSpace value then None else Some value) }, Cmd.none | InvestmentPlansReadRequested -> @@ -4709,6 +4802,12 @@ let private stocksPanel model dispatch = prop.onClick (fun _ -> dispatch StockQuoteRequested) prop.text ((if model.stockQuoteInFlight then "查询中..." else "查询行情"): string) ] + Html.button [ + prop.className "secondary-action stock-daily-action" + prop.disabled model.stockDailyInFlight + prop.onClick (fun _ -> dispatch StockDailyRequested) + prop.text ((if model.stockDailyInFlight then "读取中..." else "查询近5日日线"): string) + ] ] ] Html.p [ @@ -4733,6 +4832,27 @@ let private stocksPanel model dispatch = ] | None -> Html.p [ prop.className "hint"; prop.text "查询后显示名称/价格/币种;缺失字段以「—」表示。" ] + Html.div [ + prop.className "stock-daily" + prop.hidden (List.isEmpty model.stockDaily) + prop.children ( + Html.p [ prop.className "returns-subheading"; prop.text "近 5 个交易日收盘" ] + :: (model.stockDaily + |> List.map (fun bar -> + Html.div [ + prop.className "order-row stock-daily-row" + prop.children [ + Html.span [ prop.className "fund-detail-label"; prop.text bar.date ] + Html.span [ prop.className "fund-detail-value"; prop.text bar.close ] + ] + ])) + ) + ] + Html.p [ + prop.className "stock-daily-unavailable" + prop.hidden (model.stockDailyMessage.IsNone) + prop.text (model.stockDailyMessage |> Option.defaultValue "") + ] ] ] diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index 7b7e1b9..304c79f 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -143,6 +143,10 @@ export function getStockQuote(token, code) { return requestJson(`/api/market/stock-quote?code=${encodeURIComponent(code)}`, token); } +export function getStockDaily(token, code, days) { + return requestJson(`/api/market/stock-daily?code=${encodeURIComponent(code)}&days=${days}`, token); +} + export function createSipPlan(token, fundId, payload) { const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"amount":${JSON.stringify(payload.amount)},"frequency":${JSON.stringify(payload.frequency)}}`; return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token, { diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css index 17326b5..c0ac4a4 100644 --- a/src/FundLab.Web/src/styles.css +++ b/src/FundLab.Web/src/styles.css @@ -594,6 +594,11 @@ h2 { font-size: 12px; } +.stock-daily-unavailable { + color: #b45309; + font-size: 12px; +} + .returns-pending { color: #b45309; font-size: 12px; -- cgit v1.2.3