diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 01:54:55 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 01:54:55 +0800 |
| commit | 0d1af831c10683bb1e5a5853e5924c5ddfb1310d (patch) | |
| tree | 638c7a94fb213926aebaa443761313ddf7dcd499 | |
| parent | aa4dba4f70749b459c3922ce68610c4d78e644e6 (diff) | |
| download | fund-lab-0d1af831c10683bb1e5a5853e5924c5ddfb1310d.tar.gz | |
Add bonds probe panel with live bond quote flow (3d-16)
| -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 | 159 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 4 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 11 | ||||
| -rw-r--r-- | tests/FundLab.Web.Tests/BoundaryTests.fs | 29 |
6 files changed, 284 insertions, 0 deletions
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js index a52fa03..dddc562 100644 --- a/qa/driver/browser-test.js +++ b/qa/driver/browser-test.js @@ -34,6 +34,7 @@ let lastRebalanceExecuteResponse = null; let lastRebalanceExecutionsResponse = null; let lastNavDatesResponse = null; let lastNavSeriesResponse = null; +let lastBondQuoteResponse = null; function check(name, ok, detail) { results.push({ name, ok, detail: detail || "" }); @@ -183,6 +184,11 @@ async function summaryLabelExists(page, label, name) { lastNavSeriesResponse = await r.json(); } catch {} } + if (r.url().includes("/market/bond-quote") && r.request().method() === "GET" && r.status() < 400) { + try { + lastBondQuoteResponse = 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()); } @@ -234,6 +240,7 @@ async function summaryLabelExists(page, label, name) { await navSeriesScenario(page); await plansScenario(page); await rebalanceScenario(page); + await bondsScenario(page); } finally { check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); await browser.close(); @@ -624,6 +631,69 @@ async function navSeriesScenario(page) { await page.screenshot({ path: SHOTS + "/20-nav-series.png" }); } +async function bondsScenario(page) { + // B 系列: 债券行情探针面板走真实 API(QA 环境由桩采集器提供,非前端 mock)。 + await page.waitForSelector(".bonds-panel", { timeout: 10000 }); + + lastBondQuoteResponse = null; + await page.fill(".bond-code-input", "110075"); + await page.click(".bond-quote-action"); + await page.waitForSelector(".bond-quote-result", { timeout: 10000 }); + + const resultText = ((await page.textContent(".bond-quote-result")) || "").trim(); + const quote = lastBondQuoteResponse || {}; + + check( + "B1 真实可用券返回名称与价格字符串", + quote.code === "110075" && + typeof quote.name === "string" && + quote.name.length > 0 && + typeof quote.price === "string" && + quote.price.length > 0 && + resultText.includes(quote.name) && + resultText.includes(quote.price), + `quote=${JSON.stringify(quote).slice(0, 160)} result=${resultText.slice(0, 160)}` + ); + + await page.fill(".bond-code-input", "019547"); + await page.click(".bond-quote-action"); + await page.waitForFunction( + () => { + const element = document.querySelector(".bond-quote-unavailable"); + return element && !element.hidden && element.textContent.includes("不可用"); + }, + { timeout: 10000 } + ); + + const unavailableText = ((await page.textContent(".bond-quote-unavailable")) || "").trim(); + const resultGone = (await page.locator(".bond-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/bond-quote?code=019547", { + headers: { Authorization: `Bearer ${token}` }, + }); + let body = null; + try { + body = await response.json(); + } catch {} + return { status: response.status, body }; + }, token); + + check( + "B2 无有效报价时显示不可用提示且不补零", + unavailableText.includes("不可用") && + resultGone && + failure.status === 503 && + failure.body && + failure.body.error === "MARKET_DATA_UNAVAILABLE", + `message=${unavailableText.slice(0, 200)} status=${failure.status}` + ); + + await page.screenshot({ path: SHOTS + "/21-bonds.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 cefcecd..9a171bc 100755 --- a/qa/stub/fund-lab-python +++ b/qa/stub/fund-lab-python @@ -31,6 +31,17 @@ case "$OP" in fi printf '{"schema_version":"fund-lab.akshare.v1","operation":"nav","source":"stub-synthetic","source_revision":"stub-1","_synthetic":true,"collected_at":"%s","instrument":{"code":"%s"},"observations":[{"nav_date":"2026-09-15","published_at":null,"nav":"1.1000","accumulated_nav":null,"daily_return":null},{"nav_date":"2026-09-16","published_at":null,"nav":"1.1205","accumulated_nav":null,"daily_return":"1.86"},{"nav_date":"2026-09-17","published_at":null,"nav":"1.1180","accumulated_nav":null,"daily_return":"-0.22"},{"nav_date":"2026-09-18","published_at":null,"nav":"1.1500","accumulated_nav":null,"daily_return":"2.86"},{"nav_date":"2026-09-21","published_at":null,"nav":"1.1902","accumulated_nav":null,"daily_return":"3.49"}]}\n' "$TS" "$CODE" ;; + bond-quote) + # Synthetic rows mirror the live AKShare convertible-bond 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" = "110075" ]; then + printf '{"schema_version":"fund-lab.akshare.v1","operation":"bond-quote","source":"stub-synthetic","source_revision":"stub-1","_synthetic":true,"collected_at":"%s","instrument":{"code":"%s"},"quote":{"name":"南航转债","price":"106.368","maturity_date":"2026-10-15"}}\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 1552e15..ca0c61c 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -392,6 +392,14 @@ type RawMarketNavSeries = points: RawMarketNavSeriesPoint array } +type RawBondQuote = + { + code: string + name: obj + price: obj + maturityDate: obj + } + type CreateAttempt = { idempotencyKey: string @@ -692,6 +700,14 @@ type MarketNavSeriesPoint = accumulatedNav: string option } +type BondQuoteView = + { + code: string + name: string option + price: string option + maturityDate: string option + } + module Api = [<Import("searchInstruments", "./src/api.js")>] let searchInstruments (token: string) (query: string) : JS.Promise<RawSearchResponse> = jsNative @@ -741,6 +757,9 @@ module Api = [<Import("getMarketNavSeries", "./src/api.js")>] let getMarketNavSeries (token: string) (code: string) : JS.Promise<RawMarketNavSeries> = jsNative + [<Import("getBondQuote", "./src/api.js")>] + let getBondQuote (token: string) (code: string) : JS.Promise<RawBondQuote> = jsNative + [<Import("createCapitalDeposit", "./src/api.js")>] let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative @@ -1013,6 +1032,14 @@ module Api = accumulatedNav = decodeOptionalText raw.accumulatedNav } + let decodeBondQuote (raw: RawBondQuote) : BondQuoteView = + { + code = raw.code + name = decodeOptionalText raw.name + price = decodeOptionalText raw.price + maturityDate = decodeOptionalText raw.maturityDate + } + type Model = { token: string @@ -1096,6 +1123,11 @@ type Model = navSeriesInFlight: bool navSeries: MarketNavSeriesPoint list navSeriesAvailable: bool + bondCode: string + bondQuoteSeq: int + bondQuoteInFlight: bool + bondQuote: BondQuoteView option + bondQuoteMessage: string option returnsSelectedDate: string option planReadSeq: int planInFlight: bool @@ -1196,6 +1228,10 @@ type Msg = | NavSeriesReadRequested | NavSeriesReadCompleted of requestId: int * series: RawMarketNavSeries | NavSeriesReadFailed of requestId: int * message: string + | BondCodeChanged of string + | BondQuoteRequested + | BondQuoteCompleted of requestId: int * quote: RawBondQuote + | BondQuoteFailed of requestId: int * message: string | ReturnsDateChanged of string | InvestmentPlansReadRequested | InvestmentPlansReadCompleted of requestId: int * plans: RawInvestmentPlan array @@ -1315,6 +1351,11 @@ let init () = navSeriesInFlight = false navSeries = [] navSeriesAvailable = false + bondCode = "" + bondQuoteSeq = 0 + bondQuoteInFlight = false + bondQuote = None + bondQuoteMessage = None returnsSelectedDate = None planReadSeq = 0 planInFlight = false @@ -1499,6 +1540,13 @@ let private readNavSeriesCommand token code requestId = (fun series -> NavSeriesReadCompleted(requestId, series)) (fun error -> NavSeriesReadFailed(requestId, errorText error)) +let private readBondQuoteCommand token code requestId = + Cmd.OfPromise.either + (fun () -> Api.getBondQuote token code) + () + (fun quote -> BondQuoteCompleted(requestId, quote)) + (fun error -> BondQuoteFailed(requestId, errorText error)) + let private readInvestmentPlansCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getInvestmentPlans token fundId) @@ -2725,6 +2773,47 @@ let update message model = Cmd.none else model, Cmd.none + | BondCodeChanged value -> { model with bondCode = value }, Cmd.none + | BondQuoteRequested -> + let code = model.bondCode.Trim() + + if String.IsNullOrWhiteSpace model.token then + { model with bondQuoteMessage = Some "请输入 API token"; bondQuote = None }, Cmd.none + elif String.IsNullOrWhiteSpace code then + { model with bondQuoteMessage = Some "请输入六位债券代码"; bondQuote = None }, Cmd.none + else + let requestId = model.bondQuoteSeq + 1 + + { + model with + bondQuoteSeq = requestId + bondQuoteInFlight = true + bondQuoteMessage = None + bondQuote = None + }, + readBondQuoteCommand model.token code requestId + | BondQuoteCompleted (requestId, quote) -> + if requestId = model.bondQuoteSeq then + { + model with + bondQuote = Some(Api.decodeBondQuote quote) + bondQuoteMessage = None + bondQuoteInFlight = false + }, + Cmd.none + else + model, Cmd.none + | BondQuoteFailed (requestId, message) -> + if requestId = model.bondQuoteSeq then + { + model with + bondQuote = None + bondQuoteMessage = Some(sprintf "债券行情不可用 — %s" message) + bondQuoteInFlight = false + }, + Cmd.none + else + model, Cmd.none | ReturnsDateChanged value -> { model with returnsSelectedDate = (if String.IsNullOrWhiteSpace value then None else Some value) }, Cmd.none | InvestmentPlansReadRequested -> @@ -4423,6 +4512,75 @@ let private investmentPlansPanel model dispatch = ] ] +let private bondQuoteMetric (label: string) (value: string option) = + Html.div [ + prop.className "metric" + prop.children [ + Html.span label + Html.strong (value |> Option.defaultValue "—") + ] + ] + +let private bondsPanel model dispatch = + Html.section [ + prop.className "panel bonds-panel" + prop.children [ + Html.div [ + prop.className "section-heading" + prop.children [ + Html.div [ + Html.p [ prop.className "eyebrow"; prop.text "13 / BONDS" ] + Html.h2 "债券行情探针" + ] + Html.span [ prop.className "section-note"; prop.text "AKShare · convertible spot" ] + ] + ] + Html.p [ + prop.className "bonds-note" + prop.text "数据来源实时 AKShare 可转债行情,仅查询单一债券代码,不做本地模拟。" + ] + Html.div [ + prop.className "search-row" + prop.children [ + Html.input [ + prop.className "text-input bond-code-input" + prop.placeholder "输入六位债券代码,如 110075" + prop.value model.bondCode + prop.onChange (fun value -> dispatch (BondCodeChanged value)) + ] + Html.button [ + prop.className "primary-action bond-quote-action" + prop.disabled model.bondQuoteInFlight + prop.onClick (fun _ -> dispatch BondQuoteRequested) + prop.text ((if model.bondQuoteInFlight then "查询中..." else "查询行情"): string) + ] + ] + ] + Html.p [ + prop.className "bond-quote-unavailable" + prop.hidden (model.bondQuoteMessage.IsNone) + prop.text (model.bondQuoteMessage |> Option.defaultValue "") + ] + match model.bondQuote with + | Some quote -> + Html.div [ + prop.className "nav-content bond-quote-result" + prop.children [ + Html.div [ + prop.className "metric-strip" + prop.children [ + bondQuoteMetric "名称" quote.name + bondQuoteMetric "价格" quote.price + bondQuoteMetric "到期日" quote.maturityDate + ] + ] + ] + ] + | None -> + Html.p [ prop.className "hint"; prop.text "查询后显示名称/价格/到期日;缺失字段以「—」表示,不补零。" ] + ] + ] + let view model dispatch = Html.main [ prop.className "app-shell" @@ -4486,6 +4644,7 @@ let view model dispatch = returnsPanel model dispatch investmentPlansPanel model dispatch rebalancePanel model dispatch + bondsPanel 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 c94a9e0..ed49552 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -135,6 +135,10 @@ export function getMarketNavSeries(token, code) { return requestJson(`/api/market/nav-series?code=${encodeURIComponent(code)}&limit=30`, token); } +export function getBondQuote(token, code) { + return requestJson(`/api/market/bond-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 b994a80..0f67e97 100644 --- a/src/FundLab.Web/src/styles.css +++ b/src/FundLab.Web/src/styles.css @@ -578,6 +578,17 @@ h2 { font-size: 12px; } +.bonds-note { + color: #64748b; + font-size: 12px; + margin-bottom: 8px; +} + +.bond-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 6b4eef8..4f2137a 100644 --- a/tests/FundLab.Web.Tests/BoundaryTests.fs +++ b/tests/FundLab.Web.Tests/BoundaryTests.fs @@ -459,6 +459,35 @@ module ConfirmationBoundaryTests = Assert.False(updated.navSeriesInFlight) [<Fact>] + let ``bond quote completion stores decoded quote and clears the message`` () = + let model = init () + + let raw : RawBondQuote = + { code = "110075"; name = box "南航转债"; price = box "106.368"; maturityDate = null } + + let updated, _ = update (BondQuoteCompleted(model.bondQuoteSeq, raw)) model + + Assert.Equal(Some "南航转债", updated.bondQuote |> Option.bind (fun quote -> quote.name)) + Assert.Equal(Some "106.368", updated.bondQuote |> Option.bind (fun quote -> quote.price)) + Assert.Equal(None, updated.bondQuote |> Option.bind (fun quote -> quote.maturityDate)) + Assert.Equal(None, updated.bondQuoteMessage) + Assert.False(updated.bondQuoteInFlight) + + [<Fact>] + let ``bond quote failure shows an unavailable message without a quote`` () = + let model = { init () with bondQuoteInFlight = true } + + let updated, _ = + update (BondQuoteFailed(model.bondQuoteSeq, "AKShare collector failed: no valid quote for 019547")) model + + Assert.Equal(None, updated.bondQuote) + + let message = updated.bondQuoteMessage |> Option.defaultValue "" + Assert.Contains("不可用", message) + Assert.Contains("no valid quote for 019547", message) + Assert.False(updated.bondQuoteInFlight) + + [<Fact>] let ``order decode keeps decimal strings and decodes option fields`` () = let confirmed = Api.decodeOrder (sampleConfirmedRaw ()) let pending = Api.decodeOrder (samplePendingRaw ()) |
