diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 03:25:00 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 03:25:00 +0800 |
| commit | 7dd0e3f42105569b6e6ec02aeeec9b5a8bbd9dd3 (patch) | |
| tree | d7374c1b4bb5308374c3386ac3768ac7209a6440 | |
| parent | 8c01adf05f1428bf2ada49574b25529c064da884 (diff) | |
| download | fund-lab-7dd0e3f42105569b6e6ec02aeeec9b5a8bbd9dd3.tar.gz | |
Add real AKShare stock daily history probe slice (3d-19)
| -rw-r--r-- | qa/driver/browser-test.js | 67 | ||||
| -rwxr-xr-x | qa/stub/fund-lab-python | 11 | ||||
| -rw-r--r-- | src/FundLab.Api/App.fs | 52 | ||||
| -rw-r--r-- | src/FundLab.Api/FundLab.Api.fsproj | 1 | ||||
| -rw-r--r-- | src/FundLab.Api/MarketData.fs | 69 | ||||
| -rw-r--r-- | src/FundLab.Api/MarketDataService.fs | 4 | ||||
| -rw-r--r-- | src/FundLab.Api/Program.fs | 4 | ||||
| -rw-r--r-- | src/FundLab.Api/StockDailyProbe.fs | 80 | ||||
| -rw-r--r-- | src/FundLab.Api/akshare_collector.py | 88 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 120 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 4 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 5 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/BondQuoteProbeTests.fs | 6 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 1 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/NavDateProbeTests.fs | 10 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/NavSeriesProbeTests.fs | 3 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/StockDailyProbeTests.fs | 198 | ||||
| -rw-r--r-- | tests/FundLab.Api.Tests/StockQuoteProbeTests.fs | 6 | ||||
| -rw-r--r-- | tests/FundLab.Web.Tests/BoundaryTests.fs | 33 |
19 files changed, 751 insertions, 11 deletions
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js index 424872a..401cb78 100644 --- a/qa/driver/browser-test.js +++ b/qa/driver/browser-test.js @@ -36,6 +36,7 @@ let lastNavDatesResponse = null; let lastNavSeriesResponse = null; let lastBondQuoteResponse = null; let lastStockQuoteResponse = null; +let lastStockDailyResponse = null; function check(name, ok, detail) { results.push({ name, ok, detail: detail || "" }); @@ -195,6 +196,11 @@ async function summaryLabelExists(page, label, name) { lastStockQuoteResponse = await r.json(); } catch {} } + if (r.url().includes("/market/stock-daily") && r.request().method() === "GET" && r.status() < 400) { + try { + lastStockDailyResponse = await r.json(); + } catch {} + } if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/api\/market\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url())) { consoleErrors.push("resource " + r.status() + ": " + r.url()); } @@ -248,6 +254,7 @@ async function summaryLabelExists(page, label, name) { await rebalanceScenario(page); await bondsScenario(page); await stocksScenario(page); + await stockDailyScenario(page); } finally { check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); await browser.close(); @@ -764,6 +771,66 @@ async function stocksScenario(page) { await page.screenshot({ path: SHOTS + "/22-stocks.png" }); } +async function stockDailyScenario(page) { + // S3: 股票日线历史走真实 API(QA 环境由桩采集器提供,非前端 mock)。 + await page.waitForSelector(".stocks-panel", { timeout: 10000 }); + + lastStockDailyResponse = null; + await page.fill(".stock-code-input", "600519"); + await page.click(".stock-daily-action"); + await page.waitForSelector(".stock-daily-row", { timeout: 10000 }); + + const rows = await page.locator(".stock-daily-row").count(); + const daily = lastStockDailyResponse || {}; + const observations = Array.isArray(daily.observations) ? daily.observations : []; + const hasClose = observations.every((item) => typeof item.close === "string" && item.close.length > 0); + + check( + "S3a 真实可用股票日线显示日期行且数量>=3", + daily.code === "600519" && observations.length >= 3 && hasClose && rows >= 3, + `code=${daily.code} observations=${observations.length} rows=${rows}` + ); + + const token = process.env.QA_TOKEN || "qa-token"; + + await page.fill(".stock-code-input", "999999"); + await page.click(".stock-daily-action"); + await page.waitForFunction( + () => { + const element = document.querySelector(".stock-daily-unavailable"); + return element && !element.hidden && element.textContent.includes("股票日线不可用"); + }, + { timeout: 10000 } + ); + + const unavailableText = ((await page.textContent(".stock-daily-unavailable")) || "").trim(); + const rowsGone = (await page.locator(".stock-daily-row").count()) === 0; + + const failure = await page.evaluate(async (token) => { + const response = await fetch("/api/market/stock-daily?code=999999&days=5", { + headers: { Authorization: `Bearer ${token}` }, + }); + let body = null; + try { + body = await response.json(); + } catch {} + return { status: response.status, body }; + }, token); + + check( + "S3b 无有效日线时显示不可用提示且无 0/空表占位", + unavailableText.includes("股票日线不可用") && + rowsGone && + !unavailableText.includes("0.00") && + failure.status === 503 && + failure.body && + failure.body.error === "MARKET_DATA_UNAVAILABLE", + `message=${unavailableText.slice(0, 200)} status=${failure.status}` + ); + + await page.screenshot({ path: SHOTS + "/23-stock-daily.png" }); +} + async function postInvestmentPlanViaApi(page, fundId, token) { return page.evaluate( async ({ fundId, token }) => { diff --git a/qa/stub/fund-lab-python b/qa/stub/fund-lab-python index 5c6daa3..c9ce981 100755 --- a/qa/stub/fund-lab-python +++ b/qa/stub/fund-lab-python @@ -53,6 +53,17 @@ case "$OP" in exit 2 fi ;; + stock-daily) + # Synthetic daily bars mirror the live AKShare A-share history contract + # (>= 3 rows); unknown codes fail like the collector's "no valid daily + # history" error (-> API 503). + if [ "$CODE" = "600519" ]; then + printf '{"schema_version":"fund-lab.akshare.v1","operation":"stock-daily","source":"stub-synthetic","source_revision":"stub-1","_synthetic":true,"collected_at":"%s","instrument":{"code":"%s"},"observations":[{"date":"2026-09-17","close":"1266.98","volume":"1755380.0","amount":"2217338283.0"},{"date":"2026-09-18","close":"1257.12","volume":"2489087.0","amount":"3135849108.0"},{"date":"2026-09-21","close":"1252.57","volume":"2501689.0","amount":"3135910045.0"}]}\n' "$TS" "$CODE" + else + echo "AKShare collector failed: no valid daily history for $CODE" >&2 + exit 2 + fi + ;; *) echo "unknown operation: $OP" >&2 exit 2 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 @@ <Compile Include="NavSeriesProbe.fs" /> <Compile Include="BondQuoteProbe.fs" /> <Compile Include="StockQuoteProbe.fs" /> + <Compile Include="StockDailyProbe.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 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<MarketDataStockDailyPayload, string> = + 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<string, string> abstract FetchBondQuote: code: string * CancellationToken -> Result<string, string> abstract FetchStockQuote: code: string * CancellationToken -> Result<string, string> + abstract FetchStockDaily: code: string * days: int * CancellationToken -> Result<string, string> type IMarketDataService = abstract Search: query: string * CancellationToken -> Result<MarketDataSearchPayload, MarketDataFailure> @@ -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<StockDailyObservation list, MarketDataFailure> + +/// 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<string, StockDailyObservation list>(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 = [<Import("searchInstruments", "./src/api.js")>] let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative @@ -779,6 +801,9 @@ module Api = [<Import("getStockQuote", "./src/api.js")>] let getStockQuote (token: string) (code: string) : JS.Promise<RawStockQuote> = jsNative + [<Import("getStockDaily", "./src/api.js")>] + let getStockDaily (token: string) (code: string) (days: int) : JS.Promise<RawStockDaily> = jsNative + [<Import("createCapitalDeposit", "./src/api.js")>] let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = 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; diff --git a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs index c9821dc..bed590a 100644 --- a/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs +++ b/tests/FundLab.Api.Tests/BondQuoteProbeTests.fs @@ -24,7 +24,8 @@ module BondQuoteProbeSupport = member _.Search(_, _) = Ok "" member _.FetchNav(_, _) = Ok "" member _.FetchBondQuote(_, _) = fetch () - member _.FetchStockQuote(_, _) = Ok "" } + member _.FetchStockQuote(_, _) = Ok "" + member _.FetchStockDaily(_, _, _) = Ok "" } let stubProbe (getQuote: string * CancellationToken -> Result<BondQuote, MarketDataFailure>) = { new IBondQuoteProbe with @@ -40,7 +41,8 @@ type BondQuoteProbeTests(fixture: PostgresFixture) = { NavDates = NavDateProbeSupport.stubNavDateProbe NavSeries = NavDateProbeSupport.stubNavSeriesProbe BondQuotes = probe - StockQuotes = NavDateProbeSupport.stubStockQuoteProbe } + StockQuotes = NavDateProbeSupport.stubStockQuoteProbe + StockDaily = NavDateProbeSupport.stubStockDailyProbe } [<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 755cc1d..83c96bc 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -33,6 +33,7 @@ <Compile Include="NavSeriesProbeTests.fs" /> <Compile Include="BondQuoteProbeTests.fs" /> <Compile Include="StockQuoteProbeTests.fs" /> + <Compile Include="StockDailyProbeTests.fs" /> <Compile Include="Program.fs" /> </ItemGroup> </Project> diff --git a/tests/FundLab.Api.Tests/NavDateProbeTests.fs b/tests/FundLab.Api.Tests/NavDateProbeTests.fs index cc513d7..12ff84a 100644 --- a/tests/FundLab.Api.Tests/NavDateProbeTests.fs +++ b/tests/FundLab.Api.Tests/NavDateProbeTests.fs @@ -29,7 +29,8 @@ module NavDateProbeSupport = member _.Search(_, _) = Ok "" member _.FetchNav(_, _) = fetch () member _.FetchBondQuote(_, _) = Ok "" - member _.FetchStockQuote(_, _) = Ok "" } + member _.FetchStockQuote(_, _) = Ok "" + member _.FetchStockDaily(_, _, _) = Ok "" } let stubBondQuoteProbe = { new IBondQuoteProbe with @@ -39,6 +40,10 @@ module NavDateProbeSupport = { new IStockQuoteProbe with member _.GetQuote(_, _) = Error(InvalidMarketDataRequest "stock probe not configured for this test") } + let stubStockDailyProbe = + { new IStockDailyProbe with + member _.RecentDaily(_, _, _) = Error(InvalidMarketDataRequest "stock daily probe not configured for this test") } + let stubNavDateProbe = { new INavDateProbe with member _.RecentNavDates(_, _, _) = Ok [] } @@ -99,7 +104,8 @@ type NavDateProbeTests(fixture: PostgresFixture) = { NavDates = probe NavSeries = stubSeriesProbe BondQuotes = NavDateProbeSupport.stubBondQuoteProbe - StockQuotes = NavDateProbeSupport.stubStockQuoteProbe } + StockQuotes = NavDateProbeSupport.stubStockQuoteProbe + StockDaily = NavDateProbeSupport.stubStockDailyProbe } [<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 81d1733..19a6a50 100644 --- a/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs +++ b/tests/FundLab.Api.Tests/NavSeriesProbeTests.fs @@ -41,7 +41,8 @@ type NavSeriesProbeTests(fixture: PostgresFixture) = { NavDates = stubDateProbe NavSeries = probe BondQuotes = NavDateProbeSupport.stubBondQuoteProbe - StockQuotes = NavDateProbeSupport.stubStockQuoteProbe } + StockQuotes = NavDateProbeSupport.stubStockQuoteProbe + StockDaily = NavDateProbeSupport.stubStockDailyProbe } [<Fact>] member _.``series parses points descending, keeps accumulated nav, drops zero navs and truncates``() = diff --git a/tests/FundLab.Api.Tests/StockDailyProbeTests.fs b/tests/FundLab.Api.Tests/StockDailyProbeTests.fs new file mode 100644 index 0000000..d8f8ee4 --- /dev/null +++ b/tests/FundLab.Api.Tests/StockDailyProbeTests.fs @@ -0,0 +1,198 @@ +namespace FundLab.Api.Tests + +open System +open System.Threading +open Xunit +open FundLab.Api + +module StockDailyProbeSupport = + let payload (code: string) (observations: (string * string * string option * string option) list) = + let body = + observations + |> List.map (fun (date, close, volume, amount) -> + let optionalText = + function + | Some value -> sprintf "\"%s\"" value + | None -> "null" + + sprintf + "{\"date\":\"%s\",\"close\":\"%s\",\"volume\":%s,\"amount\":%s}" + date + close + (optionalText volume) + (optionalText amount)) + |> String.concat "," + + sprintf + "{\"schema_version\":\"fund-lab.akshare.v1\",\"operation\":\"stock-daily\",\"source\":\"akshare\",\"source_revision\":\"akshare-test/eastmoney\",\"collected_at\":\"2026-09-21T08:00:00Z\",\"instrument\":{\"code\":\"%s\"},\"observations\":[%s]}" + code + body + + let collectorReturning (fetch: unit -> Result<string, string>) = + { new IMarketDataCollector with + member _.Search(_, _) = Ok "" + member _.FetchNav(_, _) = Ok "" + member _.FetchBondQuote(_, _) = Ok "" + member _.FetchStockQuote(_, _) = Ok "" + member _.FetchStockDaily(_, _, _) = fetch () } + + let stubProbe (recent: string * int * CancellationToken -> Result<StockDailyObservation list, MarketDataFailure>) = + { new IStockDailyProbe with + member _.RecentDaily(code, days, token) = recent (code, days, token) } + +[<Collection("postgres")>] +type StockDailyProbeTests(fixture: PostgresFixture) = + let repository () = FundRepository(fixture.ConnectionString) + + let appWithDaily (probe: IStockDailyProbe) = + App.createApplicationWithProbes + (repository ()) + { NavDates = NavDateProbeSupport.stubNavDateProbe + NavSeries = NavDateProbeSupport.stubNavSeriesProbe + BondQuotes = NavDateProbeSupport.stubBondQuoteProbe + StockQuotes = NavDateProbeSupport.stubStockQuoteProbe + StockDaily = probe } + + [<Fact>] + member _.``probe decodes observations ascending and de-duplicates dates``() = + let json = + StockDailyProbeSupport.payload + "600519" + [ + "2026-09-21", "1252.57", Some "2501689.0", Some "3135910045.0" + "2026-09-17", "1266.98", Some "1755380.0", Some "2217338283.0" + "2026-09-18", "1257.12", Some "2489087.0", Some "3135849108.0" + "2026-09-17", "1266.98", Some "1755380.0", Some "2217338283.0" + ] + + let probe = + AkshareStockDailyProbe(StockDailyProbeSupport.collectorReturning (fun () -> Ok json)) :> IStockDailyProbe + + match probe.RecentDaily("600519", 5, CancellationToken.None) with + | Ok observations -> + Assert.Equal<DateOnly list>( + [ DateOnly(2026, 9, 17); DateOnly(2026, 9, 18); DateOnly(2026, 9, 21) ], + observations |> List.map (fun observation -> observation.BarDate) + ) + + Assert.Equal(3, List.length observations) + Assert.Equal<decimal option>(Some 3135910045.0m, (observations |> List.last).Amount) + | Error failure -> failwithf "expected daily observations, got %A" failure + + [<Fact>] + member _.``probe rejects a payload whose code disagrees with the request``() = + let json = StockDailyProbeSupport.payload "000001" [ "2026-09-21", "1252.57", None, None ] + + let probe = + AkshareStockDailyProbe(StockDailyProbeSupport.collectorReturning (fun () -> Ok json)) :> IStockDailyProbe + + match probe.RecentDaily("600519", 5, CancellationToken.None) with + | Error(InvalidMarketDataPayload message) -> Assert.Contains("does not match requested code", message) + | other -> failwithf "expected InvalidMarketDataPayload, got %A" other + + [<Fact>] + member _.``probe rejects a malformed stock code without touching the collector``() = + let probe = + AkshareStockDailyProbe(StockDailyProbeSupport.collectorReturning (fun () -> failwith "collector should not be called")) + :> IStockDailyProbe + + match probe.RecentDaily("abc", 5, 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 history collector failure``() = + let probe = + AkshareStockDailyProbe( + StockDailyProbeSupport.collectorReturning (fun () -> Error "AKShare collector failed: no valid daily history for 999999") + ) + :> IStockDailyProbe + + match probe.RecentDaily("999999", 5, CancellationToken.None) with + | Error(MarketDataCollectorUnavailable message) -> Assert.Contains("no valid daily history for 999999", message) + | other -> failwithf "expected MarketDataCollectorUnavailable, got %A" other + + [<Fact>] + member _.``probe maps malformed payloads to an invalid payload reason``() = + let probe = + AkshareStockDailyProbe(StockDailyProbeSupport.collectorReturning (fun () -> Ok "{not-json")) :> IStockDailyProbe + + match probe.RecentDaily("600519", 5, CancellationToken.None) with + | Error(InvalidMarketDataPayload _) -> () + | other -> failwithf "expected InvalidMarketDataPayload, got %A" other + + [<Fact>] + member _.``market stock-daily endpoint returns the probe observations``() = + let probe = + StockDailyProbeSupport.stubProbe (fun (code, days, _) -> + Assert.Equal("600519", code) + Assert.Equal(5, days) + + Ok + [ + { BarDate = DateOnly(2026, 9, 18); Close = 1257.12m; Volume = Some 2489087m; Amount = Some 3135849108m } + { BarDate = DateOnly(2026, 9, 21); Close = 1252.57m; Volume = Some 2501689m; Amount = Some 3135910045m } + ]) + + let status, body = + PersistenceTestHelpers.invoke + (appWithDaily probe) + "GET" + "/api/market/stock-daily?code=600519&days=5" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(200, status) + Assert.Contains("\"code\":\"600519\"", body) + Assert.Contains("\"date\":\"2026-09-21\"", body) + Assert.Contains("1252.57", body) + + [<Fact>] + member _.``market stock-daily endpoint answers 503 when the probe fails``() = + let probe = + StockDailyProbeSupport.stubProbe (fun _ -> + Error(MarketDataCollectorUnavailable "AKShare collector failed: no valid daily history for 999999")) + + let status, body = + PersistenceTestHelpers.invoke + (appWithDaily probe) + "GET" + "/api/market/stock-daily?code=999999&days=5" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(503, status) + Assert.Contains("MARKET_DATA_UNAVAILABLE", body) + + [<Fact>] + member _.``market stock-daily endpoint rejects a malformed code with 400``() = + let probe = + StockDailyProbeSupport.stubProbe (fun _ -> + Error(InvalidMarketDataRequest "stock code must contain exactly six digits")) + + let status, body = + PersistenceTestHelpers.invoke + (appWithDaily probe) + "GET" + "/api/market/stock-daily?code=abc&days=5" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(400, status) + Assert.Contains("INVALID_MARKET_DATA_REQUEST", body) + + [<Fact>] + member _.``market stock-daily endpoint rejects an out-of-range days with 400``() = + let probe = + StockDailyProbeSupport.stubProbe (fun _ -> failwith "probe should not be called for invalid days") + + let status, body = + PersistenceTestHelpers.invoke + (appWithDaily probe) + "GET" + "/api/market/stock-daily?code=600519&days=999" + [ "Authorization", "Bearer test-token" ] + "" + + Assert.Equal(400, status) + Assert.Contains("days must be an integer between 1 and 30", body) diff --git a/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs b/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs index e29c666..2cad557 100644 --- a/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs +++ b/tests/FundLab.Api.Tests/StockQuoteProbeTests.fs @@ -22,7 +22,8 @@ module StockQuoteProbeSupport = member _.Search(_, _) = Ok "" member _.FetchNav(_, _) = Ok "" member _.FetchBondQuote(_, _) = Ok "" - member _.FetchStockQuote(_, _) = fetch () } + member _.FetchStockQuote(_, _) = fetch () + member _.FetchStockDaily(_, _, _) = Ok "" } let stubProbe (getQuote: string * CancellationToken -> Result<StockQuote, MarketDataFailure>) = { new IStockQuoteProbe with @@ -38,7 +39,8 @@ type StockQuoteProbeTests(fixture: PostgresFixture) = { NavDates = NavDateProbeSupport.stubNavDateProbe NavSeries = NavDateProbeSupport.stubNavSeriesProbe BondQuotes = NavDateProbeSupport.stubBondQuoteProbe - StockQuotes = probe } + StockQuotes = probe + StockDaily = NavDateProbeSupport.stubStockDailyProbe } [<Fact>] member _.``probe parses name, price and currency``() = diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs index 3001273..4714632 100644 --- a/tests/FundLab.Web.Tests/BoundaryTests.fs +++ b/tests/FundLab.Web.Tests/BoundaryTests.fs @@ -522,6 +522,39 @@ module ConfirmationBoundaryTests = Assert.Contains("无效代码", updated.stockQuoteMessage |> Option.defaultValue "") [<Fact>] + let ``stock daily completion stores ascending rows`` () = + let model = init () + + let raw : RawStockDaily = + { + code = "600519" + observations = + [| + { date = "2026-09-17"; close = "1266.98"; volume = null; amount = null } + { date = "2026-09-18"; close = "1257.12"; volume = null; amount = null } + { date = "2026-09-21"; close = "1252.57"; volume = null; amount = null } + |] + } + + let updated, _ = update (StockDailyCompleted(model.stockDailySeq, raw)) model + + Assert.Equal(3, updated.stockDaily.Length) + Assert.Equal("2026-09-17", updated.stockDaily[0].date) + Assert.Equal("1252.57", updated.stockDaily[2].close) + Assert.Equal(None, updated.stockDailyMessage) + + [<Fact>] + let ``stock daily failure shows an unavailable message without rows`` () = + let model = { init () with stockDailyInFlight = true } + + let updated, _ = + update (StockDailyFailed(model.stockDailySeq, "AKShare collector failed: no valid daily history for 999999")) model + + Assert.Empty updated.stockDaily + Assert.Contains("股票日线不可用", updated.stockDailyMessage |> Option.defaultValue "") + Assert.False(updated.stockDailyInFlight) + + [<Fact>] let ``order decode keeps decimal strings and decodes option fields`` () = let confirmed = Api.decodeOrder (sampleConfirmedRaw ()) let pending = Api.decodeOrder (samplePendingRaw ()) |
