diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 02:29:30 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 02:29:30 +0800 |
| commit | 8c01adf05f1428bf2ada49574b25529c064da884 (patch) | |
| tree | 6342850684ff70bae877855d91074f7af3e4a030 | |
| parent | 7da3c3d938e4b64a43963d602ab4a0a8c89f6f9e (diff) | |
| download | fund-lab-8c01adf05f1428bf2ada49574b25529c064da884.tar.gz | |
Add stocks probe panel with live stock quote flow (3d-18)
| -rw-r--r-- | qa/driver/browser-test.js | 70 | ||||
| -rwxr-xr-x | qa/stub/fund-lab-python | 11 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 156 | ||||
| -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.Web.Tests/BoundaryTests.fs | 34 |
6 files changed, 280 insertions, 0 deletions
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js index dddc562..424872a 100644 --- a/qa/driver/browser-test.js +++ b/qa/driver/browser-test.js @@ -35,6 +35,7 @@ let lastRebalanceExecutionsResponse = null; let lastNavDatesResponse = null; let lastNavSeriesResponse = null; let lastBondQuoteResponse = null; +let lastStockQuoteResponse = null; function check(name, ok, detail) { results.push({ name, ok, detail: detail || "" }); @@ -189,6 +190,11 @@ async function summaryLabelExists(page, label, name) { lastBondQuoteResponse = await r.json(); } catch {} } + if (r.url().includes("/market/stock-quote") && r.request().method() === "GET" && r.status() < 400) { + try { + lastStockQuoteResponse = 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()); } @@ -241,6 +247,7 @@ async function summaryLabelExists(page, label, name) { await plansScenario(page); await rebalanceScenario(page); await bondsScenario(page); + await stocksScenario(page); } finally { check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); await browser.close(); @@ -694,6 +701,69 @@ async function bondsScenario(page) { await page.screenshot({ path: SHOTS + "/21-bonds.png" }); } +async function stocksScenario(page) { + // S 系列: 股票行情探针面板走真实 API(QA 环境由桩采集器提供,非前端 mock)。 + await page.waitForSelector(".stocks-panel", { timeout: 10000 }); + + lastStockQuoteResponse = null; + await page.fill(".stock-code-input", "600519"); + await page.click(".stock-quote-action"); + await page.waitForSelector(".stock-quote-result", { timeout: 10000 }); + + const resultText = ((await page.textContent(".stock-quote-result")) || "").trim(); + const quote = lastStockQuoteResponse || {}; + + check( + "S1 真实可用股票返回名称与价格字符串", + quote.code === "600519" && + typeof quote.name === "string" && + quote.name.includes("贵州茅台") && + typeof quote.price === "string" && + quote.price.length > 0 && + resultText.includes("贵州茅台") && + resultText.includes(quote.price), + `quote=${JSON.stringify(quote).slice(0, 160)} result=${resultText.slice(0, 160)}` + ); + + await page.fill(".stock-code-input", "999999"); + await page.click(".stock-quote-action"); + await page.waitForFunction( + () => { + const element = document.querySelector(".stock-quote-unavailable"); + return element && !element.hidden && element.textContent.includes("股票行情不可用"); + }, + { timeout: 10000 } + ); + + const unavailableText = ((await page.textContent(".stock-quote-unavailable")) || "").trim(); + const resultGone = (await page.locator(".stock-quote-result").count()) === 0; + const token = process.env.QA_TOKEN || "qa-token"; + + const failure = await page.evaluate(async (token) => { + const response = await fetch("/api/market/stock-quote?code=999999", { + headers: { Authorization: `Bearer ${token}` }, + }); + let body = null; + try { + body = await response.json(); + } catch {} + return { status: response.status, body }; + }, token); + + check( + "S2 无有效报价时显示不可用提示且不显示 0 占位", + unavailableText.includes("股票行情不可用") && + resultGone && + !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 + "/22-stocks.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 9a171bc..5c6daa3 100755 --- a/qa/stub/fund-lab-python +++ b/qa/stub/fund-lab-python @@ -42,6 +42,17 @@ case "$OP" in exit 2 fi ;; + stock-quote) + # Synthetic rows mirror the live AKShare A-share spot contract: a known live + # code answers with a name/price, unknown codes fail like the "no valid + # quote" collector error (-> API 503). + if [ "$CODE" = "600519" ]; then + printf '{"schema_version":"fund-lab.akshare.v1","operation":"stock-quote","source":"stub-synthetic","source_revision":"stub-1","_synthetic":true,"collected_at":"%s","instrument":{"code":"%s"},"quote":{"name":"贵州茅台","price":"1252.57","currency":"CNY"}}\n' "$TS" "$CODE" + else + echo "AKShare collector failed: no valid quote for $CODE" >&2 + exit 2 + fi + ;; *) echo "unknown operation: $OP" >&2 exit 2 diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index ca0c61c..9b4bed9 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -400,6 +400,14 @@ type RawBondQuote = maturityDate: obj } +type RawStockQuote = + { + code: string + name: obj + price: obj + currency: string + } + type CreateAttempt = { idempotencyKey: string @@ -708,6 +716,14 @@ type BondQuoteView = maturityDate: string option } +type StockQuoteView = + { + code: string + name: string option + price: string option + currency: string + } + module Api = [<Import("searchInstruments", "./src/api.js")>] let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative @@ -760,6 +776,9 @@ module Api = [<Import("getBondQuote", "./src/api.js")>] let getBondQuote (token: string) (code: string) : JS.Promise<RawBondQuote> = jsNative + [<Import("getStockQuote", "./src/api.js")>] + let getStockQuote (token: string) (code: string) : JS.Promise<RawStockQuote> = jsNative + [<Import("createCapitalDeposit", "./src/api.js")>] let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative @@ -1040,6 +1059,14 @@ module Api = maturityDate = decodeOptionalText raw.maturityDate } + let decodeStockQuote (raw: RawStockQuote) : StockQuoteView = + { + code = raw.code + name = decodeOptionalText raw.name + price = decodeOptionalText raw.price + currency = raw.currency + } + type Model = { token: string @@ -1128,6 +1155,11 @@ type Model = bondQuoteInFlight: bool bondQuote: BondQuoteView option bondQuoteMessage: string option + stockCode: string + stockQuoteSeq: int + stockQuoteInFlight: bool + stockQuote: StockQuoteView option + stockQuoteMessage: string option returnsSelectedDate: string option planReadSeq: int planInFlight: bool @@ -1232,6 +1264,10 @@ type Msg = | BondQuoteRequested | BondQuoteCompleted of requestId: int * quote: RawBondQuote | BondQuoteFailed of requestId: int * message: string + | StockCodeChanged of string + | StockQuoteRequested + | StockQuoteCompleted of requestId: int * quote: RawStockQuote + | StockQuoteFailed of requestId: int * message: string | ReturnsDateChanged of string | InvestmentPlansReadRequested | InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array @@ -1356,6 +1392,11 @@ let init () = bondQuoteInFlight = false bondQuote = None bondQuoteMessage = None + stockCode = "" + stockQuoteSeq = 0 + stockQuoteInFlight = false + stockQuote = None + stockQuoteMessage = None returnsSelectedDate = None planReadSeq = 0 planInFlight = false @@ -1547,6 +1588,13 @@ let private readBondQuoteCommand token code requestId = (fun quote -> BondQuoteCompleted(requestId, quote)) (fun error -> BondQuoteFailed(requestId, errorText error)) +let private readStockQuoteCommand token code requestId = + Cmd.OfPromise.either + (fun () -> Api.getStockQuote token code) + () + (fun quote -> StockQuoteCompleted(requestId, quote)) + (fun error -> StockQuoteFailed(requestId, errorText error)) + let private readInvestmentPlansCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getInvestmentPlans token fundId) @@ -2814,6 +2862,53 @@ let update message model = Cmd.none else model, Cmd.none + | StockCodeChanged value -> { model with stockCode = value }, Cmd.none + | StockQuoteRequested -> + let code = model.stockCode.Trim() + + if String.IsNullOrWhiteSpace model.token then + { model with stockQuoteMessage = Some "请输入 API token"; stockQuote = None }, Cmd.none + elif String.IsNullOrWhiteSpace code then + { model with stockQuoteMessage = Some "请输入六位股票代码"; stockQuote = None }, Cmd.none + else + let requestId = model.stockQuoteSeq + 1 + + { + model with + stockQuoteSeq = requestId + stockQuoteInFlight = true + stockQuoteMessage = None + stockQuote = None + }, + readStockQuoteCommand model.token code requestId + | StockQuoteCompleted (requestId, quote) -> + if requestId = model.stockQuoteSeq then + { + model with + stockQuote = Some(Api.decodeStockQuote quote) + stockQuoteMessage = None + stockQuoteInFlight = false + }, + Cmd.none + else + model, Cmd.none + | StockQuoteFailed (requestId, message) -> + if requestId = model.stockQuoteSeq then + let text = + if message.Contains("six digits") then + "无效代码 — 请输入六位股票代码" + else + sprintf "股票行情不可用 — %s" message + + { + model with + stockQuote = None + stockQuoteMessage = Some text + stockQuoteInFlight = false + }, + Cmd.none + else + model, Cmd.none | ReturnsDateChanged value -> { model with returnsSelectedDate = (if String.IsNullOrWhiteSpace value then None else Some value) }, Cmd.none | InvestmentPlansReadRequested -> @@ -4581,6 +4676,66 @@ let private bondsPanel model dispatch = ] ] +let private stocksPanel model dispatch = + Html.section [ + prop.className "panel stocks-panel" + prop.children [ + Html.div [ + prop.className "section-heading" + prop.children [ + Html.div [ + Html.p [ prop.className "eyebrow"; prop.text "14 / STOCKS" ] + Html.h2 "股票行情探针" + ] + Html.span [ prop.className "section-note"; prop.text "AKShare · A-share spot" ] + ] + ] + Html.p [ + prop.className "bonds-note" + prop.text "数据来源实时 AKShare A 股行情,仅查询单一六位代码,不做本地模拟,不补零。" + ] + Html.div [ + prop.className "search-row" + prop.children [ + Html.input [ + prop.className "text-input stock-code-input" + prop.placeholder "输入六位股票代码,如 600519" + prop.value model.stockCode + prop.onChange (fun value -> dispatch (StockCodeChanged value)) + ] + Html.button [ + prop.className "primary-action stock-quote-action" + prop.disabled model.stockQuoteInFlight + prop.onClick (fun _ -> dispatch StockQuoteRequested) + prop.text ((if model.stockQuoteInFlight then "查询中..." else "查询行情"): string) + ] + ] + ] + Html.p [ + prop.className "stock-quote-unavailable" + prop.hidden (model.stockQuoteMessage.IsNone) + prop.text (model.stockQuoteMessage |> Option.defaultValue "") + ] + match model.stockQuote with + | Some quote -> + Html.div [ + prop.className "nav-content stock-quote-result" + prop.children [ + Html.div [ + prop.className "metric-strip" + prop.children [ + bondQuoteMetric "名称" quote.name + bondQuoteMetric "价格" quote.price + bondQuoteMetric "币种" (Some quote.currency) + ] + ] + ] + ] + | None -> + Html.p [ prop.className "hint"; prop.text "查询后显示名称/价格/币种;缺失字段以「—」表示。" ] + ] + ] + let view model dispatch = Html.main [ prop.className "app-shell" @@ -4645,6 +4800,7 @@ let view model dispatch = investmentPlansPanel model dispatch rebalancePanel model dispatch bondsPanel model dispatch + stocksPanel model dispatch Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ & SUBSCRIBE" ] ] ] diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index ed49552..7b7e1b9 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -139,6 +139,10 @@ export function getBondQuote(token, code) { return requestJson(`/api/market/bond-quote?code=${encodeURIComponent(code)}`, token); } +export function getStockQuote(token, code) { + return requestJson(`/api/market/stock-quote?code=${encodeURIComponent(code)}`, 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 0f67e97..17326b5 100644 --- a/src/FundLab.Web/src/styles.css +++ b/src/FundLab.Web/src/styles.css @@ -589,6 +589,11 @@ h2 { font-size: 12px; } +.stock-quote-unavailable { + color: #b45309; + font-size: 12px; +} + .returns-pending { color: #b45309; font-size: 12px; diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs index 4f2137a..3001273 100644 --- a/tests/FundLab.Web.Tests/BoundaryTests.fs +++ b/tests/FundLab.Web.Tests/BoundaryTests.fs @@ -488,6 +488,40 @@ module ConfirmationBoundaryTests = Assert.False(updated.bondQuoteInFlight) [<Fact>] + let ``stock quote completion stores decoded quote and clears the message`` () = + let model = init () + + let raw : RawStockQuote = + { code = "600519"; name = box "贵州茅台"; price = box "1252.57"; currency = "CNY" } + + let updated, _ = update (StockQuoteCompleted(model.stockQuoteSeq, raw)) model + + Assert.Equal(Some "贵州茅台", updated.stockQuote |> Option.bind (fun quote -> quote.name)) + Assert.Equal(Some "1252.57", updated.stockQuote |> Option.bind (fun quote -> quote.price)) + Assert.Equal(Some "CNY", updated.stockQuote |> Option.map (fun quote -> quote.currency)) + Assert.Equal(None, updated.stockQuoteMessage) + Assert.False(updated.stockQuoteInFlight) + + [<Fact>] + let ``stock quote failure shows an unavailable message without a quote`` () = + let model = { init () with stockQuoteInFlight = true } + + let updated, _ = update (StockQuoteFailed(model.stockQuoteSeq, "collector exited with code 2")) model + + Assert.Equal(None, updated.stockQuote) + Assert.Contains("股票行情不可用", updated.stockQuoteMessage |> Option.defaultValue "") + Assert.False(updated.stockQuoteInFlight) + + [<Fact>] + let ``stock quote invalid code shows an invalid code message`` () = + let model = { init () with stockQuoteInFlight = true } + + let updated, _ = + update (StockQuoteFailed(model.stockQuoteSeq, "stock code must contain exactly six digits")) model + + Assert.Contains("无效代码", updated.stockQuoteMessage |> Option.defaultValue "") + + [<Fact>] let ``order decode keeps decimal strings and decodes option fields`` () = let confirmed = Api.decodeOrder (sampleConfirmedRaw ()) let pending = Api.decodeOrder (samplePendingRaw ()) |
